diff --git a/internal/agents/chat.go b/internal/agents/chat.go index 2ff2f90..c08fd5f 100644 --- a/internal/agents/chat.go +++ b/internal/agents/chat.go @@ -253,7 +253,10 @@ Parameters: ## ExecuteTestGroup Execute a group of tests against the API. Call AFTER GenerateTestPlan. -Response includes: status_code, response_body, headers (check for Retry-After), duration_ms, passed. +Response includes per test: status_code, response_body, duration_ms, passed, schema_valid, schema_errors. +- passed=false → status code did not match expected +- schema_valid=false → response body does not match the OpenAPI schema (even if passed=true) +- Always report schema_errors to the user when schema_valid=false ## wait Wait N seconds before proceeding. Use when: @@ -262,12 +265,14 @@ Wait N seconds before proceeding. Use when: - You want to avoid hitting rate limits between test groups ## GenerateReport -Generate PDF report from test results. Call AFTER tests are executed. +Generate PDF report from test results. +IMPORTANT: Only call this when the user explicitly asks for a report (e.g. "generate report", "save report", "export PDF"). Never call it automatically after tests. # Behavior - User mentions endpoint (e.g., "users", "auth") → fetch details, show info OR generate tests - User says "test X" → fetch details, generate & run tests - User says "list endpoints" → show list from available endpoints (no tool call) +- User says "generate report" / "save PDF" / "export report" → call GenerateReport - After 429 response → call wait(seconds=N) where N comes from Retry-After header or default to 5 - requires_auth=true → CLI adds auth header, requires_auth=false → no auth`, baseURL, endpointsInfo) } diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 52d68a8..6baada7 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -6,12 +6,14 @@ import ( "github.com/Octrafic/octrafic-cli/internal/agents" "github.com/Octrafic/octrafic-cli/internal/core/parser" "github.com/Octrafic/octrafic-cli/internal/core/reporter" + "github.com/Octrafic/octrafic-cli/internal/core/tester" "github.com/Octrafic/octrafic-cli/internal/exporter" "github.com/Octrafic/octrafic-cli/internal/infra/logger" "github.com/Octrafic/octrafic-cli/internal/infra/storage" "maps" "os" "path/filepath" + "strconv" "strings" "time" @@ -327,6 +329,8 @@ func (m *TestUIModel) executeTool(toolCall agent.ToolCall) tea.Cmd { passed := result.StatusCode == expectedStatus + schemaErrors := m.validateResponseSchema(method, endpoint, result.StatusCode, result.ResponseBody) + return toolResultMsg{ toolID: toolCall.ID, toolName: toolCall.Name, @@ -339,6 +343,8 @@ func (m *TestUIModel) executeTool(toolCall agent.ToolCall) tea.Cmd { "headers": result.Headers, "duration_ms": result.Duration.Milliseconds(), "passed": passed, + "schema_valid": len(schemaErrors) == 0, + "schema_errors": schemaErrors, }, err: nil, } @@ -781,6 +787,46 @@ func (m *TestUIModel) loadProjectEndpoints() ([]parser.Endpoint, error) { return storage.LoadEndpoints(m.currentProject.ID, m.currentProject.IsTemporary) } +// validateResponseSchema checks the response body against the OpenAPI schema for the given endpoint. +// Returns a list of validation errors, or nil if no schema is available. +func (m *TestUIModel) validateResponseSchema(method, path string, statusCode int, body string) []string { + if m.analysis == nil || m.analysis.Specification == nil { + return nil + } + statusKey := strconv.Itoa(statusCode) + for _, ep := range m.analysis.Specification.Endpoints { + if strings.EqualFold(ep.Method, method) && matchPath(ep.Path, path) { + if schema, ok := ep.ResponseSchemas[statusKey]; ok { + return tester.ValidateSchema(body, schema) + } + return nil + } + } + return nil +} + +// matchPath checks if a concrete path matches an OpenAPI path template. +// Segments wrapped in {} are treated as wildcards. +func matchPath(template, actual string) bool { + if template == actual { + return true + } + tParts := strings.Split(strings.Trim(template, "/"), "/") + aParts := strings.Split(strings.Trim(actual, "/"), "/") + if len(tParts) != len(aParts) { + return false + } + for i, tp := range tParts { + if strings.HasPrefix(tp, "{") && strings.HasSuffix(tp, "}") { + continue + } + if !strings.EqualFold(tp, aParts[i]) { + return false + } + } + return true +} + func (m *TestUIModel) handleExportTests(toolCall agent.ToolCall) tea.Msg { exportsArg, ok := toolCall.Arguments["exports"] if !ok { diff --git a/internal/cli/handlers_test.go b/internal/cli/handlers_test.go new file mode 100644 index 0000000..772734f --- /dev/null +++ b/internal/cli/handlers_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "testing" + + "github.com/Octrafic/octrafic-cli/internal/core/analyzer" + "github.com/Octrafic/octrafic-cli/internal/core/parser" +) + +func TestMatchPath_Exact(t *testing.T) { + if !matchPath("/users", "/users") { + t.Error("expected exact match") + } +} + +func TestMatchPath_WithParam(t *testing.T) { + if !matchPath("/users/{id}", "/users/42") { + t.Error("expected param segment to match any value") + } +} + +func TestMatchPath_MultipleParams(t *testing.T) { + if !matchPath("/users/{id}/orders/{orderId}", "/users/1/orders/99") { + t.Error("expected multiple params to match") + } +} + +func TestMatchPath_DifferentSegmentCount(t *testing.T) { + if matchPath("/users/{id}", "/users") { + t.Error("expected no match for different segment count") + } +} + +func TestMatchPath_LiteralMismatch(t *testing.T) { + if matchPath("/users/{id}", "/products/42") { + t.Error("expected no match when literal segment differs") + } +} + +func TestMatchPath_CaseInsensitive(t *testing.T) { + if !matchPath("/Users/{id}", "/users/1") { + t.Error("expected case-insensitive match on literal segments") + } +} + +func TestValidateResponseSchema_NilAnalysis(t *testing.T) { + m := &TestUIModel{} + errs := m.validateResponseSchema("GET", "/users", 200, `{"id":"1"}`) + if errs != nil { + t.Errorf("expected nil when analysis is nil, got %v", errs) + } +} + +func TestValidateResponseSchema_NoMatchingEndpoint(t *testing.T) { + m := &TestUIModel{ + analysis: &analyzer.Analysis{ + Specification: &parser.Specification{ + Endpoints: []parser.Endpoint{ + {Method: "GET", Path: "/users"}, + }, + }, + }, + } + errs := m.validateResponseSchema("POST", "/other", 200, `{}`) + if errs != nil { + t.Errorf("expected nil for unmatched endpoint, got %v", errs) + } +} + +func TestValidateResponseSchema_NoSchemaForStatus(t *testing.T) { + m := &TestUIModel{ + analysis: &analyzer.Analysis{ + Specification: &parser.Specification{ + Endpoints: []parser.Endpoint{ + {Method: "GET", Path: "/users"}, + }, + }, + }, + } + errs := m.validateResponseSchema("GET", "/users", 200, `{"id":"1"}`) + if errs != nil { + t.Errorf("expected nil when no schema for status code, got %v", errs) + } +} + +func TestValidateResponseSchema_Valid(t *testing.T) { + m := &TestUIModel{ + analysis: &analyzer.Analysis{ + Specification: &parser.Specification{ + Endpoints: []parser.Endpoint{ + { + Method: "GET", + Path: "/users/{id}", + ResponseSchemas: map[string]map[string]any{ + "200": { + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, + } + errs := m.validateResponseSchema("GET", "/users/42", 200, `{"id":"abc"}`) + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } +} + +func TestValidateResponseSchema_SchemaError(t *testing.T) { + m := &TestUIModel{ + analysis: &analyzer.Analysis{ + Specification: &parser.Specification{ + Endpoints: []parser.Endpoint{ + { + Method: "GET", + Path: "/users/{id}", + ResponseSchemas: map[string]map[string]any{ + "200": { + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, + } + errs := m.validateResponseSchema("GET", "/users/42", 200, `{"id":99}`) + if len(errs) == 0 { + t.Error("expected schema validation error for wrong type") + } +} diff --git a/internal/cli/update_tests.go b/internal/cli/update_tests.go index 82df7a0..d2eb6f2 100644 --- a/internal/cli/update_tests.go +++ b/internal/cli/update_tests.go @@ -212,6 +212,9 @@ func handleRunNextTest(m *TestUIModel, _ runNextTestMsg) (tea.Model, tea.Cmd) { }) } else { passed := result.StatusCode == expectedStatus + schemaErrors := m.validateResponseSchema(method, endpoint, result.StatusCode, result.ResponseBody) + schemaValid := len(schemaErrors) == 0 + statusIcon := "✓" statusStyle := m.successStyle if !passed { @@ -220,6 +223,12 @@ func handleRunNextTest(m *TestUIModel, _ runNextTestMsg) (tea.Model, tea.Cmd) { if m.isHeadless { m.headlessExitCode = 1 } + } else if !schemaValid { + statusIcon = "⚠" + statusStyle = lipgloss.NewStyle().Foreground(Theme.Warning) + if m.isHeadless { + m.headlessExitCode = 1 + } } statusMsg := fmt.Sprintf(" Status: %d", result.StatusCode) @@ -231,6 +240,14 @@ func handleRunNextTest(m *TestUIModel, _ runNextTestMsg) (tea.Model, tea.Cmd) { m.addMessage(fmt.Sprintf(" %s %s %s%s", statusStyle.Render(statusIcon), methodFormatted, endpoint, authIndicator)) m.addMessage(m.subtleStyle.Render(statusMsg)) + if !schemaValid { + schemaStyle := lipgloss.NewStyle().Foreground(Theme.Warning) + m.addMessage(schemaStyle.Render(" Schema mismatch:")) + for _, se := range schemaErrors { + m.addMessage(schemaStyle.Render(" · " + se)) + } + } + m.testGroupResults = append(m.testGroupResults, map[string]any{ "method": method, "endpoint": endpoint, @@ -240,6 +257,8 @@ func handleRunNextTest(m *TestUIModel, _ runNextTestMsg) (tea.Model, tea.Cmd) { "duration_ms": result.Duration.Milliseconds(), "requires_auth": requiresAuth, "passed": passed, + "schema_valid": schemaValid, + "schema_errors": schemaErrors, }) } m.testGroupCompletedCount++ diff --git a/internal/core/parser/parser.go b/internal/core/parser/parser.go index 80316e4..118bb0e 100644 --- a/internal/core/parser/parser.go +++ b/internal/core/parser/parser.go @@ -23,14 +23,15 @@ type Specification struct { } type Endpoint struct { - Method string `json:"method"` - Path string `json:"path"` - Description string `json:"description"` - Parameters []Parameter `json:"parameters,omitempty"` - RequestBody string `json:"request_body,omitempty"` - Responses map[string]string `json:"responses,omitempty"` - RequiresAuth bool `json:"requires_auth"` - AuthType string `json:"auth_type"` // "bearer", "basic", "apikey", "none" + Method string `json:"method"` + Path string `json:"path"` + Description string `json:"description"` + Parameters []Parameter `json:"parameters,omitempty"` + RequestBody string `json:"request_body,omitempty"` + Responses map[string]string `json:"responses,omitempty"` + ResponseSchemas map[string]map[string]any `json:"response_schemas,omitempty"` + RequiresAuth bool `json:"requires_auth"` + AuthType string `json:"auth_type"` // "bearer", "basic", "apikey", "none" } type Parameter struct { @@ -157,14 +158,29 @@ func parseOpenAPI(content []byte) (*Specification, error) { spec.Version = version } + definitions := make(map[string]any) + if components, ok := openapi["components"].(map[string]any); ok { + if schemas, ok := components["schemas"].(map[string]any); ok { + for k, v := range schemas { + definitions[k] = v + } + } + } + if defs, ok := openapi["definitions"].(map[string]any); ok { + for k, v := range defs { + definitions[k] = v + } + } + if paths, ok := openapi["paths"].(map[string]any); ok { for path, methods := range paths { if methodMap, ok := methods.(map[string]any); ok { for method, details := range methodMap { endpoint := Endpoint{ - Method: strings.ToUpper(method), - Path: path, - Responses: make(map[string]string), + Method: strings.ToUpper(method), + Path: path, + Responses: make(map[string]string), + ResponseSchemas: make(map[string]map[string]any), } if detailsMap, ok := details.(map[string]any); ok { @@ -176,6 +192,20 @@ func parseOpenAPI(content []byte) (*Specification, error) { endpoint.Description = summary } } + + if responses, ok := detailsMap["responses"].(map[string]any); ok { + for statusCode, respData := range responses { + if respMap, ok := respData.(map[string]any); ok { + if desc, ok := respMap["description"].(string); ok { + endpoint.Responses[statusCode] = desc + } + schema := extractResponseSchema(respMap, definitions) + if schema != nil { + endpoint.ResponseSchemas[statusCode] = schema + } + } + } + } } spec.Endpoints = append(spec.Endpoints, endpoint) @@ -187,6 +217,63 @@ func parseOpenAPI(content []byte) (*Specification, error) { return spec, nil } +// extractResponseSchema pulls the JSON schema from a response object. +// Handles both OpenAPI 3.x (content/application/json/schema) and Swagger 2.0 (schema). +func extractResponseSchema(response map[string]any, definitions map[string]any) map[string]any { + if content, ok := response["content"].(map[string]any); ok { + for mediaType, mediaData := range content { + if strings.Contains(mediaType, "json") { + if mediaMap, ok := mediaData.(map[string]any); ok { + if schema, ok := mediaMap["schema"].(map[string]any); ok { + return resolveRefs(schema, definitions, 0) + } + } + } + } + } + if schema, ok := response["schema"].(map[string]any); ok { + return resolveRefs(schema, definitions, 0) + } + return nil +} + +// resolveRefs recursively resolves $ref pointers in a JSON schema. +// depth limits recursion to guard against circular references. +func resolveRefs(schema map[string]any, definitions map[string]any, depth int) map[string]any { + if depth > 10 { + return schema + } + if ref, ok := schema["$ref"].(string); ok { + parts := strings.Split(ref, "/") + name := parts[len(parts)-1] + if def, ok := definitions[name].(map[string]any); ok { + return resolveRefs(def, definitions, depth+1) + } + return schema + } + + result := make(map[string]any, len(schema)) + for k, v := range schema { + switch val := v.(type) { + case map[string]any: + result[k] = resolveRefs(val, definitions, depth+1) + case []any: + resolved := make([]any, len(val)) + for i, item := range val { + if itemMap, ok := item.(map[string]any); ok { + resolved[i] = resolveRefs(itemMap, definitions, depth+1) + } else { + resolved[i] = item + } + } + result[k] = resolved + default: + result[k] = v + } + } + return result +} + func isHTTPMethod(s string) bool { methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} return slices.Contains(methods, s) diff --git a/internal/core/parser/parser_test.go b/internal/core/parser/parser_test.go index 90fb255..d1d7669 100644 --- a/internal/core/parser/parser_test.go +++ b/internal/core/parser/parser_test.go @@ -502,3 +502,122 @@ func TestDetectFormatFromContent(t *testing.T) { } } } + +func TestResolveRefs_NoRef(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + } + result := resolveRefs(schema, nil, 0) + if result["type"] != "object" { + t.Errorf("expected type=object, got %v", result["type"]) + } +} + +func TestResolveRefs_WithRef(t *testing.T) { + definitions := map[string]any{ + "User": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + } + schema := map[string]any{"$ref": "#/components/schemas/User"} + result := resolveRefs(schema, definitions, 0) + if result["type"] != "object" { + t.Errorf("expected resolved type=object, got %v", result["type"]) + } +} + +func TestResolveRefs_UnknownRef(t *testing.T) { + schema := map[string]any{"$ref": "#/components/schemas/Missing"} + result := resolveRefs(schema, map[string]any{}, 0) + if result["$ref"] == nil { + t.Error("expected $ref to remain when definition not found") + } +} + +func TestResolveRefs_DepthLimit(t *testing.T) { + schema := map[string]any{"type": "string"} + result := resolveRefs(schema, nil, 11) + if result["type"] != "string" { + t.Errorf("expected schema returned as-is at depth limit, got %v", result) + } +} + +func TestExtractResponseSchema_OpenAPI3(t *testing.T) { + response := map[string]any{ + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "integer"}, + }, + }, + }, + }, + } + schema := extractResponseSchema(response, nil) + if schema == nil { + t.Fatal("expected non-nil schema") + } + if schema["type"] != "object" { + t.Errorf("expected type=object, got %v", schema["type"]) + } +} + +func TestExtractResponseSchema_Swagger2(t *testing.T) { + response := map[string]any{ + "schema": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + }, + }, + } + schema := extractResponseSchema(response, nil) + if schema == nil { + t.Fatal("expected non-nil schema") + } + if schema["type"] != "array" { + t.Errorf("expected type=array, got %v", schema["type"]) + } +} + +func TestExtractResponseSchema_NoSchema(t *testing.T) { + schema := extractResponseSchema(map[string]any{}, nil) + if schema != nil { + t.Errorf("expected nil for response with no schema, got %v", schema) + } +} + +func TestExtractResponseSchema_WithRef(t *testing.T) { + definitions := map[string]any{ + "Product": map[string]any{ + "type": "object", + "properties": map[string]any{ + "price": map[string]any{"type": "number"}, + }, + }, + } + response := map[string]any{ + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{ + "$ref": "#/components/schemas/Product", + }, + }, + }, + } + schema := extractResponseSchema(response, definitions) + if schema == nil { + t.Fatal("expected non-nil schema after ref resolution") + } + if schema["type"] != "object" { + t.Errorf("expected resolved type=object, got %v", schema["type"]) + } +} diff --git a/internal/core/tester/schema_validator.go b/internal/core/tester/schema_validator.go new file mode 100644 index 0000000..6971e44 --- /dev/null +++ b/internal/core/tester/schema_validator.go @@ -0,0 +1,150 @@ +package tester + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ValidateSchema validates a JSON response body against a JSON schema object. +// Returns a slice of human-readable validation errors (empty if valid). +func ValidateSchema(body string, schema map[string]any) []string { + if schema == nil || strings.TrimSpace(body) == "" { + return nil + } + + var parsed any + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + return []string{fmt.Sprintf("response is not valid JSON: %v", err)} + } + + var errors []string + validateValue(parsed, schema, "", &errors) + return errors +} + +func validateValue(value any, schema map[string]any, path string, errors *[]string) { + schemaType, _ := schema["type"].(string) + + if value == nil { + nullable, _ := schema["nullable"].(bool) + if typeArr, ok := schema["type"].([]any); ok { + for _, t := range typeArr { + if s, ok := t.(string); ok && s == "null" { + return + } + } + } + if nullable || schemaType == "" { + return + } + *errors = append(*errors, fmt.Sprintf("%s: is null but not nullable", fieldPath(path))) + return + } + + switch schemaType { + case "object": + validateObject(value, schema, path, errors) + case "array": + validateArray(value, schema, path, errors) + case "string": + if _, ok := value.(string); !ok { + *errors = append(*errors, fieldError(path, "string", value)) + } + case "number": + switch value.(type) { + case float64, int, int64: + default: + *errors = append(*errors, fieldError(path, "number", value)) + } + case "integer": + if f, ok := value.(float64); ok { + if f != float64(int64(f)) { + *errors = append(*errors, fmt.Sprintf("%s: expected integer, got float", fieldPath(path))) + } + } else if _, ok := value.(int); !ok { + *errors = append(*errors, fieldError(path, "integer", value)) + } + case "boolean": + if _, ok := value.(bool); !ok { + *errors = append(*errors, fieldError(path, "boolean", value)) + } + case "": + if _, hasProps := schema["properties"]; hasProps { + validateObject(value, schema, path, errors) + } + if _, hasItems := schema["items"]; hasItems { + validateArray(value, schema, path, errors) + } + } +} + +func validateObject(value any, schema map[string]any, path string, errors *[]string) { + obj, ok := value.(map[string]any) + if !ok { + *errors = append(*errors, fieldError(path, "object", value)) + return + } + + if required, ok := schema["required"].([]any); ok { + for _, req := range required { + if fieldName, ok := req.(string); ok { + if _, exists := obj[fieldName]; !exists { + *errors = append(*errors, fmt.Sprintf("%s: required field is missing", childPath(path, fieldName))) + } + } + } + } + + if properties, ok := schema["properties"].(map[string]any); ok { + for fieldName, propSchema := range properties { + propMap, ok := propSchema.(map[string]any) + if !ok { + continue + } + fieldValue, exists := obj[fieldName] + if !exists { + continue + } + validateValue(fieldValue, propMap, childPath(path, fieldName), errors) + } + } +} + +func validateArray(value any, schema map[string]any, path string, errors *[]string) { + arr, ok := value.([]any) + if !ok { + *errors = append(*errors, fieldError(path, "array", value)) + return + } + + items, ok := schema["items"].(map[string]any) + if !ok { + return + } + + for i, item := range arr { + validateValue(item, items, fmt.Sprintf("%s[%d]", fieldPath(path), i), errors) + if len(*errors) >= 10 { + break + } + } +} + +func childPath(parent, field string) string { + if parent == "" { + return field + } + return parent + "." + field +} + +func fieldPath(path string) string { + if path == "" { + return "response" + } + return path +} + +func fieldError(path, expected string, actual any) string { + return fmt.Sprintf("%s: expected %s, got %T", fieldPath(path), expected, actual) +} diff --git a/internal/core/tester/schema_validator_test.go b/internal/core/tester/schema_validator_test.go new file mode 100644 index 0000000..7dac8d2 --- /dev/null +++ b/internal/core/tester/schema_validator_test.go @@ -0,0 +1,118 @@ +package tester + +import ( + "testing" +) + +func TestValidateSchema_Valid(t *testing.T) { + schema := map[string]any{ + "type": "object", + "required": []any{"id", "email"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "email": map[string]any{"type": "string"}, + "age": map[string]any{"type": "integer"}, + }, + } + + errs := ValidateSchema(`{"id":"abc","email":"x@y.com","age":30}`, schema) + if len(errs) != 0 { + t.Errorf("expected no errors, got: %v", errs) + } +} + +func TestValidateSchema_MissingRequired(t *testing.T) { + schema := map[string]any{ + "type": "object", + "required": []any{"id", "email"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "email": map[string]any{"type": "string"}, + }, + } + + errs := ValidateSchema(`{"id":"abc"}`, schema) + if len(errs) != 1 { + t.Errorf("expected 1 error (missing email), got: %v", errs) + } +} + +func TestValidateSchema_WrongType(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + } + + errs := ValidateSchema(`{"id":42}`, schema) + if len(errs) != 1 { + t.Errorf("expected 1 type error, got: %v", errs) + } +} + +func TestValidateSchema_NullNonNullable(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + } + + errs := ValidateSchema(`{"id":null}`, schema) + if len(errs) != 1 { + t.Errorf("expected null error, got: %v", errs) + } +} + +func TestValidateSchema_Nullable(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string", "nullable": true}, + }, + } + + errs := ValidateSchema(`{"id":null}`, schema) + if len(errs) != 0 { + t.Errorf("expected no errors for nullable field, got: %v", errs) + } +} + +func TestValidateSchema_Array(t *testing.T) { + schema := map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "integer"}, + }, + }, + } + + errs := ValidateSchema(`[{"id":1},{"id":2}]`, schema) + if len(errs) != 0 { + t.Errorf("expected no errors, got: %v", errs) + } + + errs = ValidateSchema(`[{"id":"not-int"}]`, schema) + if len(errs) == 0 { + t.Error("expected type error in array item") + } +} + +func TestValidateSchema_InvalidJSON(t *testing.T) { + schema := map[string]any{"type": "object"} + errs := ValidateSchema(`not json`, schema) + if len(errs) != 1 { + t.Errorf("expected JSON parse error, got: %v", errs) + } +} + +func TestValidateSchema_EmptyBody(t *testing.T) { + schema := map[string]any{"type": "object"} + errs := ValidateSchema("", schema) + if len(errs) != 0 { + t.Errorf("expected no errors for empty body, got: %v", errs) + } +}