// SPDX-License-Identifier: EUPL-1.2 package api_test import ( "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" api "forge.lthn.ai/core/go-api" ) // ── Test helpers ────────────────────────────────────────────────────────── type specStubGroup struct { name string basePath string descs []api.RouteDescription } func (s *specStubGroup) Name() string { return s.name } func (s *specStubGroup) BasePath() string { return s.basePath } func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs } type plainStubGroup struct{} func (plainStubGroup) Name() string { return "plain" } func (plainStubGroup) BasePath() string { return "/plain" } func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} // ── SpecBuilder tests ───────────────────────────────────────────────────── func TestSpecBuilder_Good_EmptyGroups(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Description: "Empty test", Version: "0.0.1", } data, err := sb.Build(nil) if err != nil { t.Fatalf("unexpected error: %v", err) } var spec map[string]any if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("invalid JSON: %v", err) } // Verify OpenAPI version. if spec["openapi"] != "3.1.0" { t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) } // Verify /health path exists. paths := spec["paths"].(map[string]any) if _, ok := paths["/health"]; !ok { t.Fatal("expected /health path in spec") } // Verify system tag exists. tags := spec["tags"].([]any) found := false for _, tag := range tags { tm := tag.(map[string]any) if tm["name"] == "system" { found = true break } } if !found { t.Fatal("expected system tag in spec") } } func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Description: "Test API", Version: "1.0.0", } group := &specStubGroup{ name: "items", basePath: "/api/items", descs: []api.RouteDescription{ { Method: "GET", Path: "/list", Summary: "List items", Tags: []string{"items"}, Response: map[string]any{ "type": "array", "items": map[string]any{ "type": "string", }, }, }, { Method: "POST", Path: "/create", Summary: "Create item", Description: "Creates a new item", Tags: []string{"items"}, RequestBody: map[string]any{ "type": "object", "properties": map[string]any{ "name": map[string]any{"type": "string"}, }, }, Response: map[string]any{ "type": "object", "properties": map[string]any{ "id": map[string]any{"type": "integer"}, }, }, }, }, } data, err := sb.Build([]api.RouteGroup{group}) if err != nil { t.Fatalf("unexpected error: %v", err) } var spec map[string]any if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("invalid JSON: %v", err) } paths := spec["paths"].(map[string]any) // Verify GET /api/items/list exists. listPath, ok := paths["/api/items/list"] if !ok { t.Fatal("expected /api/items/list path in spec") } getOp := listPath.(map[string]any)["get"] if getOp == nil { t.Fatal("expected GET operation on /api/items/list") } if getOp.(map[string]any)["summary"] != "List items" { t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"]) } // Verify POST /api/items/create exists with request body. createPath, ok := paths["/api/items/create"] if !ok { t.Fatal("expected /api/items/create path in spec") } postOp := createPath.(map[string]any)["post"] if postOp == nil { t.Fatal("expected POST operation on /api/items/create") } if postOp.(map[string]any)["summary"] != "Create item" { t.Fatalf("expected summary='Create item', got %v", postOp.(map[string]any)["summary"]) } if postOp.(map[string]any)["requestBody"] == nil { t.Fatal("expected requestBody on POST /api/items/create") } } func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Version: "1.0.0", } group := &specStubGroup{ name: "data", basePath: "/data", descs: []api.RouteDescription{ { Method: "GET", Path: "/fetch", Summary: "Fetch data", Tags: []string{"data"}, Response: map[string]any{ "type": "object", "properties": map[string]any{ "value": map[string]any{"type": "string"}, }, }, }, }, } data, err := sb.Build([]api.RouteGroup{group}) if err != nil { t.Fatalf("unexpected error: %v", err) } var spec map[string]any if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("invalid JSON: %v", err) } paths := spec["paths"].(map[string]any) fetchPath := paths["/data/fetch"].(map[string]any) getOp := fetchPath["get"].(map[string]any) responses := getOp["responses"].(map[string]any) resp200 := responses["200"].(map[string]any) content := resp200["content"].(map[string]any) appJSON := content["application/json"].(map[string]any) schema := appJSON["schema"].(map[string]any) // Verify envelope structure. if schema["type"] != "object" { t.Fatalf("expected schema type=object, got %v", schema["type"]) } properties := schema["properties"].(map[string]any) // Verify success field. success := properties["success"].(map[string]any) if success["type"] != "boolean" { t.Fatalf("expected success.type=boolean, got %v", success["type"]) } // Verify data field contains the original response schema. dataField := properties["data"].(map[string]any) if dataField["type"] != "object" { t.Fatalf("expected data.type=object, got %v", dataField["type"]) } dataProps := dataField["properties"].(map[string]any) if dataProps["value"] == nil { t.Fatal("expected data.properties.value to exist") } // Verify required contains "success". required := schema["required"].([]any) foundSuccess := false for _, r := range required { if r == "success" { foundSuccess = true break } } if !foundSuccess { t.Fatal("expected 'success' in required array") } } func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", Version: "1.0.0", } data, err := sb.Build([]api.RouteGroup{plainStubGroup{}}) if err != nil { t.Fatalf("unexpected error: %v", err) } var spec map[string]any if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("invalid JSON: %v", err) } // Verify plainStubGroup appears in tags. tags := spec["tags"].([]any) foundPlain := false for _, tag := range tags { tm := tag.(map[string]any) if tm["name"] == "plain" { foundPlain = true break } } if !foundPlain { t.Fatal("expected 'plain' tag in spec for non-describable group") } // Verify only /health exists in paths (plain group adds no paths). paths := spec["paths"].(map[string]any) if len(paths) != 1 { t.Fatalf("expected 1 path (/health only), got %d", len(paths)) } if _, ok := paths["/health"]; !ok { t.Fatal("expected /health path in spec") } } func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) { gin.SetMode(gin.TestMode) sb := &api.SpecBuilder{ Title: "Tool API", Version: "1.0.0", } 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) { c.JSON(http.StatusOK, api.OK("ok")) }) 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) { c.JSON(http.StatusOK, api.OK("ok")) }) data, err := sb.Build([]api.RouteGroup{bridge}) if err != nil { t.Fatalf("unexpected error: %v", err) } var spec map[string]any if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("invalid JSON: %v", err) } paths := spec["paths"].(map[string]any) // Verify POST /tools/file_read exists. fileReadPath, ok := paths["/tools/file_read"] if !ok { t.Fatal("expected /tools/file_read path in spec") } postOp := fileReadPath.(map[string]any)["post"] if postOp == nil { t.Fatal("expected POST operation on /tools/file_read") } if postOp.(map[string]any)["summary"] != "Read a file from disk" { t.Fatalf("expected summary='Read a file from disk', got %v", postOp.(map[string]any)["summary"]) } // Verify POST /tools/metrics_query exists. metricsPath, ok := paths["/tools/metrics_query"] if !ok { t.Fatal("expected /tools/metrics_query path in spec") } metricsOp := metricsPath.(map[string]any)["post"] if metricsOp == nil { t.Fatal("expected POST operation on /tools/metrics_query") } if metricsOp.(map[string]any)["summary"] != "Query metrics data" { t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"]) } // Verify request body is present on both (both are POST with InputSchema). if postOp.(map[string]any)["requestBody"] == nil { t.Fatal("expected requestBody on POST /tools/file_read") } if metricsOp.(map[string]any)["requestBody"] == nil { t.Fatal("expected requestBody on POST /tools/metrics_query") } } func TestSpecBuilder_Bad_InfoFields(t *testing.T) { sb := &api.SpecBuilder{ Title: "MyAPI", Description: "Test API", Version: "1.0.0", } data, err := sb.Build(nil) if err != nil { t.Fatalf("unexpected error: %v", err) } var spec map[string]any if err := json.Unmarshal(data, &spec); err != nil { t.Fatalf("invalid JSON: %v", err) } info := spec["info"].(map[string]any) if info["title"] != "MyAPI" { t.Fatalf("expected title=MyAPI, got %v", info["title"]) } if info["description"] != "Test API" { t.Fatalf("expected description='Test API', got %v", info["description"]) } if info["version"] != "1.0.0" { t.Fatalf("expected version=1.0.0, got %v", info["version"]) } }