package jsonschema import ( "encoding/json" "testing" ) // fl returns a *float64 pointing at v — convenience for table tests. func fl(v float64) *float64 { return &v } func ip(v int) *int { return &v } // jsonAny decodes a JSON literal into the same `any` shape that the form // handler hands to Validate (json.Unmarshal of the request body). func jsonAny(t *testing.T, s string) any { t.Helper() var v any if err := json.Unmarshal([]byte(s), &v); err != nil { t.Fatalf("decode %q: %v", s, err) } return v } func TestValidate_Types(t *testing.T) { cases := []struct { name string schema *Schema valueJS string wantErr bool }{ {"string-ok", &Schema{Type: "string"}, `"hi"`, false}, {"string-wrong-type", &Schema{Type: "string"}, `42`, true}, {"number-ok-int", &Schema{Type: "number"}, `42`, false}, {"number-ok-float", &Schema{Type: "number"}, `3.14`, false}, {"number-wrong-type", &Schema{Type: "number"}, `"hi"`, true}, {"integer-ok", &Schema{Type: "integer"}, `42`, false}, {"integer-rejects-float", &Schema{Type: "integer"}, `3.14`, true}, {"integer-accepts-floaty-int", &Schema{Type: "integer"}, `42.0`, false}, {"boolean-ok", &Schema{Type: "boolean"}, `true`, false}, {"boolean-wrong-type", &Schema{Type: "boolean"}, `"true"`, true}, {"null-ok", &Schema{Type: "null"}, `null`, false}, {"array-ok", &Schema{Type: "array"}, `[]`, false}, {"array-wrong-type", &Schema{Type: "array"}, `{}`, true}, {"object-ok", &Schema{Type: "object"}, `{}`, false}, {"object-wrong-type", &Schema{Type: "object"}, `[]`, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { errs := Validate(tc.schema, jsonAny(t, tc.valueJS)) if (len(errs) > 0) != tc.wantErr { t.Fatalf("Validate errs = %v; wantErr=%v", errs, tc.wantErr) } }) } } func TestValidate_Enum(t *testing.T) { s := &Schema{Type: "string", Enum: []any{"a", "b", "c"}} if errs := Validate(s, "a"); len(errs) != 0 { t.Errorf("a: unexpected errs %v", errs) } if errs := Validate(s, "z"); len(errs) == 0 { t.Errorf("z: expected enum error") } // Numeric enum with int↔float coercion: schema authored with int, value // arrives as float64 (JSON default). num := &Schema{Type: "integer", Enum: []any{1, 2, 3}} if errs := Validate(num, jsonAny(t, `2`)); len(errs) != 0 { t.Errorf("int 2 against int enum: unexpected errs %v", errs) } if errs := Validate(num, jsonAny(t, `4`)); len(errs) == 0 { t.Errorf("int 4 against int enum: expected error") } } func TestValidate_StringConstraints(t *testing.T) { s := &Schema{Type: "string", MinLength: ip(3), MaxLength: ip(8)} cases := map[string]int{ `"hi"`: 1, // too short `"hello"`: 0, `"hellothere"`: 1, // too long } for v, want := range cases { errs := Validate(s, jsonAny(t, v)) if len(errs) != want { t.Errorf("%s: got %d errs (%v), want %d", v, len(errs), errs, want) } } } func TestValidate_NumberConstraints(t *testing.T) { s := &Schema{Type: "integer", Minimum: fl(1), Maximum: fl(5)} cases := []struct { v string want int }{ {`0`, 1}, {`1`, 0}, {`3`, 0}, {`5`, 0}, {`6`, 1}, } for _, tc := range cases { errs := Validate(s, jsonAny(t, tc.v)) if len(errs) != tc.want { t.Errorf("v=%s: got %d errs (%v), want %d", tc.v, len(errs), errs, tc.want) } } } func TestValidate_Format_Date(t *testing.T) { s := &Schema{Type: "string", Format: "date"} cases := map[string]bool{ `"2026-05-01"`: false, `"2026-13-01"`: true, // bad month `"2026-02-30"`: true, // bad day `"05/01/2026"`: true, // wrong format `"2026-5-1"`: true, // missing zero-pad } for v, wantErr := range cases { errs := Validate(s, jsonAny(t, v)) if (len(errs) > 0) != wantErr { t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr) } } } func TestValidate_Format_Email(t *testing.T) { s := &Schema{Type: "string", Format: "email"} cases := map[string]bool{ `"sam@example.com"`: false, `"sam+tag@ex.io"`: false, `"not-an-email"`: true, `"missing@dot"`: true, `"@nouser.com"`: true, } for v, wantErr := range cases { errs := Validate(s, jsonAny(t, v)) if (len(errs) > 0) != wantErr { t.Errorf("%s: got errs=%v wantErr=%v", v, errs, wantErr) } } } func TestValidate_Object_Required(t *testing.T) { s := &Schema{ Type: "object", Required: []string{"name", "age"}, Properties: map[string]*Schema{ "name": {Type: "string"}, "age": {Type: "integer"}, }, } errs := Validate(s, jsonAny(t, `{"name":"Sam","age":42}`)) if len(errs) != 0 { t.Errorf("complete object: unexpected errs %v", errs) } errs = Validate(s, jsonAny(t, `{"name":"Sam"}`)) if len(errs) != 1 || errs[0].Path != "/age" { t.Errorf("missing age: got errs=%v want one error at /age", errs) } errs = Validate(s, jsonAny(t, `{}`)) if len(errs) != 2 { t.Errorf("empty object: got %d errs (%v) want 2", len(errs), errs) } } func TestValidate_Object_AdditionalPropertiesFalse(t *testing.T) { s := &Schema{ Type: "object", Properties: map[string]*Schema{"a": {Type: "string"}}, AdditionalProperties: false, } if errs := Validate(s, jsonAny(t, `{"a":"hi"}`)); len(errs) != 0 { t.Errorf("declared-only: unexpected %v", errs) } errs := Validate(s, jsonAny(t, `{"a":"hi","b":1}`)) if len(errs) != 1 || errs[0].Path != "/b" { t.Errorf("extra prop: got %v want one error at /b", errs) } } func TestValidate_Object_NestedErrorPaths(t *testing.T) { s := &Schema{ Type: "object", Properties: map[string]*Schema{ "inner": { Type: "object", Properties: map[string]*Schema{ "n": {Type: "integer", Minimum: fl(0)}, }, }, }, } errs := Validate(s, jsonAny(t, `{"inner":{"n":-5}}`)) if len(errs) != 1 || errs[0].Path != "/inner/n" { t.Errorf("nested path: got %v want /inner/n", errs) } } func TestValidate_Array_Items(t *testing.T) { s := &Schema{ Type: "array", Items: &Schema{Type: "integer", Minimum: fl(0)}, } if errs := Validate(s, jsonAny(t, `[1,2,3]`)); len(errs) != 0 { t.Errorf("valid array: unexpected %v", errs) } errs := Validate(s, jsonAny(t, `[1,-2,3,-4]`)) if len(errs) != 2 { t.Errorf("two bad items: got %d errs %v want 2", len(errs), errs) } if errs[0].Path != "/1" || errs[1].Path != "/3" { t.Errorf("array paths: got %v want /1 then /3", errs) } } func TestValidate_Array_NestedObjects(t *testing.T) { s := &Schema{ Type: "array", Items: &Schema{ Type: "object", Required: []string{"k"}, Properties: map[string]*Schema{ "k": {Type: "string"}, "v": {Type: "integer", Minimum: fl(1), Maximum: fl(5)}, }, }, } errs := Validate(s, jsonAny(t, `[{"k":"a","v":3},{"v":99}]`)) // Expected: missing k at /1/k, v out of range at /1/v if len(errs) != 2 { t.Fatalf("got %d errs (%v) want 2", len(errs), errs) } gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true} if !gotPaths["/1/k"] || !gotPaths["/1/v"] { t.Errorf("array-of-objects paths: got %v", errs) } } func TestValidate_PtrEnc_SpecialChars(t *testing.T) { s := &Schema{ Type: "object", Required: []string{"a/b", "c~d"}, Properties: map[string]*Schema{ "a/b": {Type: "string"}, "c~d": {Type: "string"}, }, } errs := Validate(s, jsonAny(t, `{}`)) if len(errs) != 2 { t.Fatalf("got %d errs %v", len(errs), errs) } gotPaths := map[string]bool{errs[0].Path: true, errs[1].Path: true} // Per RFC 6901: '/' → '~1', '~' → '~0'. if !gotPaths["/a~1b"] { t.Errorf("expected /a~1b in %v", errs) } if !gotPaths["/c~0d"] { t.Errorf("expected /c~0d in %v", errs) } } func TestValidate_NilSchemaIsNoOp(t *testing.T) { if errs := Validate(nil, "anything"); errs != nil { t.Errorf("nil schema returned errs: %v", errs) } }