ZDDC/zddc/internal/jsonschema/jsonschema_test.go
ZDDC a02a26d3c2
All checks were successful
Build + deploy releases / build-and-deploy (push) Successful in 8s
feat: form-data system v0 (sixth tool + zddc-server endpoints)
Schema-driven form renderer plus zddc-server endpoints that turn any
<name>.form.yaml into a working data-collection form at <path>/<name>.form.html.
Submissions land in <path>/<name>/<YYYY-MM-DD>-<email-sanitized>.yaml,
ACL-gated by the existing .zddc cascade. The form posts back to its own URL;
the server strips ".html" and routes by what's underneath, so create and
update use the same client-side code path.

Form spec dialect: JSON Schema 2020-12 + RJSF-style ui:* hints, written in
YAML. Chosen for LLM authorability — it's the canonical structured-output
target for OpenAI/Anthropic, and the ui:* convention is the most-trained UI
hint vocabulary. Supported subset for v0: type (string/number/integer/boolean/
array/object), enum, min/max, minLength/maxLength, required, additionalProperties:
false, properties, items, format (date, email). Round-trip mode is form-as-truth:
submission YAML is regenerated each save, comments are not preserved (the v1
file-as-truth mode for hand-edited files like .zddc itself is deferred).

New components:
  * form/ — sixth single-file HTML tool, vanilla JS renderer (~760 LoC)
  * zddc/internal/jsonschema/ — focused JSON Schema validator covering only
    the v0 keyword subset. Match-implementation-cost-to-surface-used: a full
    library brings 70%+ surface we don't use; revisit when v1 adds $ref +
    oneOf + if/then/else.
  * zddc/internal/handler/formhandler.go — RecognizeFormRequest / ServeForm,
    capability-URL re-edit, atomic submission writes via the new
    zddc.WriteAtomic helper extracted from writer.go.
  * dispatch() in zddc-server/main.go now intercepts *.form.html and
    *.yaml.html before the static-file path; spec existence is the trigger.

Build pipeline: form joins ZDDC_RELEASE_TOOLS in lockstep, gets its own
embedded copy in handler/form.html (separate from the apps cascade —
the form renderer is fixed, not subject to per-folder version overrides).

Tests: 5 new Playwright specs (form-safety) + 14 new Go tests across the
validator and handler. All 172 Playwright tests + 10 Go packages green.
End-to-end manual verification: GET empty → POST 201 + capability URL →
GET re-edit (pre-filled) → POST update → 200, raw YAML browsable, ACL
deny → 403.

Docs: form/ section added to AGENTS.md and ARCHITECTURE.md. AGENTS.md
also documents the implementation-vs-dependency policy. CLAUDE.md repo-shape
list extended.

Deferred (v1+): .zddc editor migration onto this system, file-as-truth
lossless YAML round-trip, ui:show-when conditional visibility, oneOf/anyOf,
apps-cascade preview hook, cascade-fetched form definitions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:12:16 -05:00

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{
`"casey@example.com"`: false,
`"casey+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":"Casey","age":42}`))
if len(errs) != 0 {
t.Errorf("complete object: unexpected errs %v", errs)
}
errs = Validate(s, jsonAny(t, `{"name":"Casey"}`))
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)
}
}