// SPDX-License-Identifier: EUPL-1.2 package api_test import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" api "dappco.re/go/core/api" ) // ── ToolBridge ───────────────────────────────────────────────────────── func TestToolBridge_Good_RegisterAndServe(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file", Group: "files", }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("result1")) }) bridge.Add(api.ToolDescriptor{ Name: "file_write", Description: "Write a file", Group: "files", }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("result2")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) // POST /tools/file_read w1 := httptest.NewRecorder() req1, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil) engine.ServeHTTP(w1, req1) if w1.Code != http.StatusOK { t.Fatalf("expected 200 for file_read, got %d", w1.Code) } var resp1 api.Response[string] if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp1.Data != "result1" { t.Fatalf("expected Data=%q, got %q", "result1", resp1.Data) } // POST /tools/file_write w2 := httptest.NewRecorder() req2, _ := http.NewRequest(http.MethodPost, "/tools/file_write", nil) engine.ServeHTTP(w2, req2) if w2.Code != http.StatusOK { t.Fatalf("expected 200 for file_write, got %d", w2.Code) } var resp2 api.Response[string] if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp2.Data != "result2" { t.Fatalf("expected Data=%q, got %q", "result2", resp2.Data) } } func TestToolBridge_Good_BasePath(t *testing.T) { bridge := api.NewToolBridge("/api/v1/tools") if bridge.BasePath() != "/api/v1/tools" { t.Fatalf("expected BasePath=%q, got %q", "/api/v1/tools", bridge.BasePath()) } if bridge.Name() != "tools" { t.Fatalf("expected Name=%q, got %q", "tools", bridge.Name()) } } func TestToolBridge_Good_Describe(t *testing.T) { bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file from disk", Group: "files", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string"}, }, }, OutputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "content": map[string]any{"type": "string"}, }, }, }, func(c *gin.Context) {}) bridge.Add(api.ToolDescriptor{ Name: "metrics_query", Description: "Query metrics data", Group: "metrics", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, }, }, }, func(c *gin.Context) {}) // Verify DescribableGroup interface satisfaction. var dg api.DescribableGroup = bridge descs := dg.Describe() if len(descs) != 2 { t.Fatalf("expected 2 descriptions, got %d", len(descs)) } // First tool. if descs[0].Method != "POST" { t.Fatalf("expected descs[0].Method=%q, got %q", "POST", descs[0].Method) } if descs[0].Path != "/file_read" { t.Fatalf("expected descs[0].Path=%q, got %q", "/file_read", descs[0].Path) } if descs[0].Summary != "Read a file from disk" { t.Fatalf("expected descs[0].Summary=%q, got %q", "Read a file from disk", descs[0].Summary) } if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "files" { t.Fatalf("expected descs[0].Tags=[files], got %v", descs[0].Tags) } if descs[0].RequestBody == nil { t.Fatal("expected descs[0].RequestBody to be non-nil") } if descs[0].Response == nil { t.Fatal("expected descs[0].Response to be non-nil") } // Second tool. if descs[1].Path != "/metrics_query" { t.Fatalf("expected descs[1].Path=%q, got %q", "/metrics_query", descs[1].Path) } if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "metrics" { t.Fatalf("expected descs[1].Tags=[metrics], got %v", descs[1].Tags) } if descs[1].Response != nil { t.Fatalf("expected descs[1].Response to be nil, got %v", descs[1].Response) } } func TestToolBridge_Good_DescribeTrimsBlankGroup(t *testing.T) { bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file from disk", Group: " ", }, func(c *gin.Context) {}) descs := bridge.Describe() if len(descs) != 1 { t.Fatalf("expected 1 description, got %d", len(descs)) } if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "tools" { t.Fatalf("expected blank group to fall back to bridge tag, got %v", descs[0].Tags) } } func TestToolBridge_Good_ValidatesRequestBody(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file from disk", Group: "files", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string"}, }, "required": []any{"path"}, }, }, func(c *gin.Context) { var payload map[string]any if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil { t.Fatalf("handler could not read validated body: %v", err) } c.JSON(http.StatusOK, api.OK(payload["path"])) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", bytes.NewBufferString(`{"path":"/tmp/file.txt"}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp api.Response[string] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Data != "/tmp/file.txt" { t.Fatalf("expected validated payload to reach handler, got %q", resp.Data) } } func TestToolBridge_Good_ValidatesResponseBody(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file from disk", Group: "files", OutputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string"}, }, "required": []any{"path"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK(map[string]any{"path": "/tmp/file.txt"})) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil) engine.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp api.Response[map[string]any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if !resp.Success { t.Fatal("expected Success=true") } if resp.Data["path"] != "/tmp/file.txt" { t.Fatalf("expected validated response data to reach client, got %v", resp.Data["path"]) } } func TestToolBridge_Bad_InvalidResponseBody(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file from disk", Group: "files", OutputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string"}, }, "required": []any{"path"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK(map[string]any{"path": 123})) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil) engine.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", w.Code) } var resp api.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false") } if resp.Error == nil || resp.Error.Code != "invalid_tool_response" { t.Fatalf("expected invalid_tool_response error, got %#v", resp.Error) } } func TestToolBridge_Bad_InvalidRequestBody(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "file_read", Description: "Read a file from disk", Group: "files", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string"}, }, "required": []any{"path"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("should not run")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", bytes.NewBufferString(`{"path":123}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var resp api.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false") } if resp.Error == nil || resp.Error.Code != "invalid_request_body" { t.Fatalf("expected invalid_request_body error, got %#v", resp.Error) } } func TestToolBridge_Good_ValidatesEnumValues(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "publish_item", Description: "Publish an item", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "status": map[string]any{ "type": "string", "enum": []any{"draft", "published"}, }, }, "required": []any{"status"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("published")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"published"}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } } func TestToolBridge_Bad_RejectsInvalidEnumValues(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "publish_item", Description: "Publish an item", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "status": map[string]any{ "type": "string", "enum": []any{"draft", "published"}, }, }, "required": []any{"status"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("published")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"archived"}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var resp api.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false") } if resp.Error == nil || resp.Error.Code != "invalid_request_body" { t.Fatalf("expected invalid_request_body error, got %#v", resp.Error) } } func TestToolBridge_Good_ValidatesSchemaCombinators(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "route_choice", Description: "Choose a route", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "choice": map[string]any{ "oneOf": []any{ map[string]any{ "type": "string", "allOf": []any{ map[string]any{"minLength": 2}, map[string]any{"pattern": "^[A-Z]+$"}, }, }, map[string]any{ "type": "string", "pattern": "^A", }, }, }, }, "required": []any{"choice"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("accepted")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/route_choice", bytes.NewBufferString(`{"choice":"BC"}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } } func TestToolBridge_Bad_RejectsAmbiguousOneOfMatches(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "route_choice", Description: "Choose a route", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "choice": map[string]any{ "oneOf": []any{ map[string]any{ "type": "string", "allOf": []any{ map[string]any{"minLength": 1}, map[string]any{"pattern": "^[A-Z]+$"}, }, }, map[string]any{ "type": "string", "pattern": "^A", }, }, }, }, "required": []any{"choice"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("accepted")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/route_choice", bytes.NewBufferString(`{"choice":"A"}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var resp api.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false") } if resp.Error == nil || resp.Error.Code != "invalid_request_body" { t.Fatalf("expected invalid_request_body error, got %#v", resp.Error) } } func TestToolBridge_Bad_RejectsAdditionalProperties(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "publish_item", Description: "Publish an item", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "status": map[string]any{"type": "string"}, }, "required": []any{"status"}, "additionalProperties": false, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("published")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"published","unexpected":true}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var resp api.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false") } if resp.Error == nil || resp.Error.Code != "invalid_request_body" { t.Fatalf("expected invalid_request_body error, got %#v", resp.Error) } } func TestToolBridge_Good_EnforcesStringConstraints(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "publish_code", Description: "Publish a code", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "code": map[string]any{ "type": "string", "minLength": 3, "maxLength": 5, "pattern": "^[A-Z]+$", }, }, "required": []any{"code"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("accepted")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/publish_code", bytes.NewBufferString(`{"code":"ABC"}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } } func TestToolBridge_Bad_RejectsNumericAndCollectionConstraints(t *testing.T) { gin.SetMode(gin.TestMode) engine := gin.New() bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "quota_check", Description: "Check quotas", Group: "items", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "count": map[string]any{ "type": "integer", "minimum": 1, "maximum": 3, }, "labels": map[string]any{ "type": "array", "minItems": 2, "maxItems": 4, "items": map[string]any{ "type": "string", }, }, "payload": map[string]any{ "type": "object", "minProperties": 1, "maxProperties": 2, "additionalProperties": true, }, }, "required": []any{"count", "labels", "payload"}, }, }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("accepted")) }) rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/quota_check", bytes.NewBufferString(`{"count":0,"labels":["one"],"payload":{}}`)) engine.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for numeric/collection constraint failure, got %d", w.Code) } var resp api.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false") } if resp.Error == nil || resp.Error.Code != "invalid_request_body" { t.Fatalf("expected invalid_request_body error, got %#v", resp.Error) } } func TestToolBridge_Good_ToolsAccessor(t *testing.T) { bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {}) bridge.Add(api.ToolDescriptor{Name: "beta", Description: "Tool B", Group: "b"}, func(c *gin.Context) {}) bridge.Add(api.ToolDescriptor{Name: "gamma", Description: "Tool C", Group: "c"}, func(c *gin.Context) {}) tools := bridge.Tools() if len(tools) != 3 { t.Fatalf("expected 3 tools, got %d", len(tools)) } expected := []string{"alpha", "beta", "gamma"} for i, want := range expected { if tools[i].Name != want { t.Fatalf("expected tools[%d].Name=%q, got %q", i, want, tools[i].Name) } } } func TestToolBridge_Bad_EmptyBridge(t *testing.T) { gin.SetMode(gin.TestMode) bridge := api.NewToolBridge("/tools") // RegisterRoutes should not panic with no tools. engine := gin.New() rg := engine.Group(bridge.BasePath()) bridge.RegisterRoutes(rg) // Describe should return empty slice. descs := bridge.Describe() if len(descs) != 0 { t.Fatalf("expected 0 descriptions, got %d", len(descs)) } // Tools should return empty slice. tools := bridge.Tools() if len(tools) != 0 { t.Fatalf("expected 0 tools, got %d", len(tools)) } } func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) { gin.SetMode(gin.TestMode) e, err := api.New() if err != nil { t.Fatalf("unexpected error: %v", err) } bridge := api.NewToolBridge("/tools") bridge.Add(api.ToolDescriptor{ Name: "ping", Description: "Ping tool", Group: "util", }, func(c *gin.Context) { c.JSON(http.StatusOK, api.OK("pong")) }) e.Register(bridge) h := e.Handler() w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/tools/ping", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp api.Response[string] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if !resp.Success { t.Fatal("expected Success=true") } if resp.Data != "pong" { t.Fatalf("expected Data=%q, got %q", "pong", resp.Data) } }