273 lines
7.6 KiB
Go
273 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|