// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package forms import ( "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // One test per rule Parse applies (design D6; spec form-library "The // handler parses through the declaration"). The declaration below carries // one field of every shape the rules touch; each test submits one body and // asserts one refusal or one accepted value. func testSpec() FormSpec { return FormSpec{ Name: "test.form", Kind: KindCreate, Family: Stacked, Method: "POST", Path: "/partials/test", Commit: "Save", Fields: []Field{ {Name: "name", Label: "Name", Control: Text, MaxLen: 10}, {Name: "note", Label: "Note", Control: Text, Optional: true}, {Name: "quantity", Label: "Quantity", Control: Number, Min: "1", Max: "100"}, {Name: "code", Label: "Code", Control: Text, Optional: true, Pattern: `^[a-z]+$`, PatternHint: "Code is lowercase letters only."}, {Name: "kind", Label: "Kind", Control: Select, Options: []Option{ ChooseOption("a kind"), {Value: "a", Label: "A"}, {Value: "b", Label: "B"}, }}, {Name: "public", Label: "Public", Control: Checkbox, Value: "public"}, {Name: "starts", Label: "Starts", Control: Date, Optional: true}, }, } } func post(t *testing.T, target string, body url.Values) *http.Request { t.Helper() r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") return r } // The query string cannot shadow a field: Parse reads r.PostForm, not // r.FormValue, which merges the two and hides where a value came from // (lesson L§5). func TestParseIgnoresTheQueryString(t *testing.T) { r := post(t, "/partials/test?name=fromquery", url.Values{ "quantity": {"1"}, "kind": {"a"}, }) values, errs := testSpec().Parse(r) if values.Present("name") { t.Errorf("name reported present from the query string: %q", values.Raw("name")) } if errs.Get("name") == "" { t.Error("an absent required field must be refused") } } // A search form reads the query string instead, because that is what a // native GET submits. func TestParseSearchReadsTheQuery(t *testing.T) { spec := testSpec() spec.Kind = KindSearch spec.Method = "GET" spec.Fields = []Field{{Name: "q", Label: "Search", Control: Text, Optional: true}} r := httptest.NewRequest(http.MethodGet, "/operator/lookup?q=jamie", nil) values, errs := spec.Parse(r) if errs.Any() { t.Fatalf("unexpected refusal: %+v", errs) } if got := values.String("q"); got != "jamie" { t.Errorf("q = %q, want %q", got, "jamie") } } func TestParseIgnoresUndeclaredKeys(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {"Widget"}, "quantity": {"2"}, "kind": {"a"}, "csrf_token": {"x"}, "lifecycle_status": {"retired"}, }) values, errs := testSpec().Parse(r) if errs.Any() { t.Fatalf("unexpected refusal: %+v", errs) } if values.Present("lifecycle_status") { t.Error("an undeclared key was read") } } func TestParseTrimsAndRequires(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {" "}, "quantity": {"2"}, "kind": {"a"}, }) values, errs := testSpec().Parse(r) if got := errs.Get("name"); got != "Enter a name." { t.Errorf("name refusal = %q, want %q", got, "Enter a name.") } if values.Raw("name") != "" { t.Errorf("a whitespace-only value must trim to empty, got %q", values.Raw("name")) } } func TestParseOptionalEmptyIsAccepted(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {"Widget"}, "quantity": {"2"}, "kind": {"a"}, "note": {""}, }) _, errs := testSpec().Parse(r) if errs.Has("note") { t.Errorf("an optional empty field must not be refused: %q", errs.Get("note")) } } func TestParseMaxLen(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {strings.Repeat("x", 11)}, "quantity": {"2"}, "kind": {"a"}, }) values, errs := testSpec().Parse(r) if got := errs.Get("name"); got != "Name must be 10 characters or fewer." { t.Errorf("name refusal = %q", got) } // The raw text is carried back for the re-render even though the field // was refused (lesson L§11, finding FA-23). if len(values.Raw("name")) != 11 { t.Errorf("the refused value must still be carried back, got %q", values.Raw("name")) } } func TestParseNumberRange(t *testing.T) { for _, tc := range []struct{ in, want string }{ {"0", "Quantity must be 1 or more."}, {"101", "Quantity must be 100 or fewer."}, {"nope", "Quantity must be a whole number."}, } { r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {tc.in}, "kind": {"a"}}) values, errs := testSpec().Parse(r) if got := errs.Get("quantity"); got != tc.want { t.Errorf("quantity %q refusal = %q, want %q", tc.in, got, tc.want) } if _, ok := values.Int("quantity"); ok { t.Errorf("quantity %q must have no typed value when refused", tc.in) } } r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"7"}, "kind": {"a"}}) values, errs := testSpec().Parse(r) if errs.Any() { t.Fatalf("unexpected refusal: %+v", errs) } if n, ok := values.Int("quantity"); !ok || n != 7 { t.Errorf("quantity = %d, %v; want 7, true", n, ok) } } func TestParsePatternUsesItsHint(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "code": {"AB1"}, }) _, errs := testSpec().Parse(r) if got := errs.Get("code"); got != "Code is lowercase letters only." { t.Errorf("code refusal = %q", got) } } // A value outside the option set is an error, not a silent default // (finding FA-8). func TestParseSelectOutsideTheOptions(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {"W"}, "quantity": {"1"}, "kind": {"c"}, }) values, errs := testSpec().Parse(r) if got := errs.Get("kind"); got != "Choose a kind from the list." { t.Errorf("kind refusal = %q", got) } if values.Raw("kind") != "c" { t.Errorf("the refused value must be carried back, got %q", values.Raw("kind")) } } // The placeholder option's empty value is refused on a select that needs a // choice, and accepted on one whose option list offers an empty value. func TestParseSelectEmptyFollowsTheOptionSet(t *testing.T) { r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {""}}) _, errs := testSpec().Parse(r) if got := errs.Get("kind"); got != "Choose a kind." { t.Errorf("kind refusal = %q, want %q", got, "Choose a kind.") } optional := testSpec() optional.Fields[4].Options = []Option{NoneOption(), {Value: "a", Label: "A"}} r = post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {""}}) values, errs := optional.Parse(r) if errs.Has("kind") { t.Errorf("an offered empty option must be accepted: %q", errs.Get("kind")) } if values.Raw("kind") != "" { t.Errorf("kind = %q, want the empty deliberate choice", values.Raw("kind")) } } // radioSpec declares one radio group, required by default, for the // requiredness tests below. func radioSpec(optional bool) FormSpec { return FormSpec{ Name: "test.radio", Kind: KindCreate, Family: Stacked, Method: "POST", Path: "/partials/test", Commit: "Save", Fields: []Field{ {Name: "disposition", Label: "Disposition", Control: Radio, Optional: optional, Options: []Option{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}}}, }, } } // An optional radio group left untouched is a legitimate empty submission: // unlike a Select, it has no enabled empty-valued option to submit instead // (parse.go's Select/Radio membership branch). func TestParseOptionalRadioAcceptsNoSelection(t *testing.T) { r := post(t, "/partials/test", url.Values{}) values, errs := radioSpec(true).Parse(r) if errs.Has("disposition") { t.Errorf("an optional radio group left untouched must be accepted, got %q", errs.Get("disposition")) } if values.Raw("disposition") != "" { t.Errorf("disposition = %q, want empty", values.Raw("disposition")) } } // A required radio group left untouched is refused, the same as any other // required control. func TestParseRequiredRadioNeedsAChoice(t *testing.T) { r := post(t, "/partials/test", url.Values{}) _, errs := radioSpec(false).Parse(r) if !errs.Has("disposition") { t.Error("a required radio group left untouched must be refused") } } // Options loaded per request reach Parse through the same map the render // used, so what the control offered and what the handler accepts are one // list. func TestParseWithRuntimeOptions(t *testing.T) { spec := testSpec() spec.Fields[4] = Field{Name: "kind", Label: "Kind", Control: Select, RuntimeOptions: true} runtime := map[string][]Option{"kind": {{Value: "live", Label: "Live"}}} r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"live"}}) if _, errs := spec.ParseWith(r, runtime); errs.Any() { t.Fatalf("unexpected refusal: %+v", errs) } r = post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"stale"}}) if _, errs := spec.ParseWith(r, runtime); !errs.Has("kind") { t.Error("a value outside the runtime options must be refused") } } // A checkbox's absence is false and never an error (lesson L§21). func TestParseCheckboxAbsenceIsFalse(t *testing.T) { r := post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"a"}}) values, errs := testSpec().Parse(r) if errs.Has("public") { t.Errorf("an absent checkbox must not be refused: %q", errs.Get("public")) } if values.Bool("public") { t.Error("an absent checkbox must read false") } r = post(t, "/partials/test", url.Values{"name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "public": {"public"}}) values, _ = testSpec().Parse(r) if !values.Bool("public") { t.Error("a ticked checkbox must read true") } } // A checkbox bound to a value set (SetBool, the record-bind and re-render // path) answers Bool the same way a parsed one does: the typed half is // recorded beside the raw value and presence, not left to Parse alone // (design D1, the defect where a bound checkbox read false while its raw // value read true). func TestSetBoolAnswersBool(t *testing.T) { f := testSpec().Fields[5] // "public", the Checkbox field if f.Name != "public" { t.Fatalf("test fixture drifted: field 5 is %q, want %q", f.Name, "public") } values := NewValues() values.SetBool(f, true) if !values.Bool("public") { t.Error("SetBool(f, true) must answer Bool true") } values = NewValues() values.SetBool(f, false) if values.Bool("public") { t.Error("SetBool(f, false) must answer Bool false") } } // Repeated names on a scalar field take the first value, as Go's own form // parsing does; a multi-valued control does not exist in this library. func TestParseRepeatedNameTakesTheFirst(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {"first", "second"}, "quantity": {"1"}, "kind": {"a"}, }) values, _ := testSpec().Parse(r) if got := values.String("name"); got != "first" { t.Errorf("name = %q, want %q", got, "first") } } func TestParseDate(t *testing.T) { r := post(t, "/partials/test", url.Values{ "name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "starts": {"2026-09-03"}, }) values, errs := testSpec().Parse(r) if errs.Any() { t.Fatalf("unexpected refusal: %+v", errs) } got, ok := values.Time("starts") if !ok || got.Format("2006-01-02") != "2026-09-03" { t.Errorf("starts = %v, %v", got, ok) } r = post(t, "/partials/test", url.Values{ "name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "starts": {"the third"}, }) if _, errs := testSpec().Parse(r); errs.Get("starts") != "Starts is not a valid date." { t.Errorf("starts refusal = %q", errs.Get("starts")) } } // A field's side decides whether it is read at all, so a create submission // cannot set an edit-only column. func TestParseSideFiltersOneSidedFields(t *testing.T) { spec := testSpec() spec.Fields = append(spec.Fields, Field{ Name: "is_active", Label: "Active", Control: Checkbox, Value: "true", Only: EditOnly, }) r := post(t, "/partials/test", url.Values{ "name": {"W"}, "quantity": {"1"}, "kind": {"a"}, "is_active": {"true"}, }) values, _ := spec.ParseSide(r, CreateOnly, nil) if values.Present("is_active") { t.Error("an edit-only field must not be read from a create submission") } values, _ = spec.ParseSide(r, EditOnly, nil) if !values.Bool("is_active") { t.Error("an edit-only field must be read from an edit submission") } } // ShowIf gates parsing the way it gates rendering: a field whose condition // does not match is neither validated nor carried into Values, since the // operator never saw a control for it (design D2). func showIfParseSpec() FormSpec { return FormSpec{ Name: "test.showif.parse", Kind: KindSubRecord, Family: Dense, Method: "POST", Path: "/partials/test", Commit: "Add", Fields: []Field{ {Name: "kind", Label: "Kind", Control: Hidden, Optional: true}, {Name: "amount", Label: "Amount", Control: Number, ShowIf: ShowIf{Field: "kind", Equals: []string{"numeric"}}}, }, } } func TestParseShowIfSkipsValidationWhenHidden(t *testing.T) { r := post(t, "/partials/test", url.Values{"kind": {"boolean"}}) values, errs := showIfParseSpec().Parse(r) if errs.Any() { t.Errorf("a field hidden by ShowIf must not be required, got %v", errs) } if values.Present("amount") { t.Error("a field hidden by ShowIf must not be carried into Values") } } func TestParseShowIfValidatesWhenShown(t *testing.T) { r := post(t, "/partials/test", url.Values{"kind": {"numeric"}}) _, errs := showIfParseSpec().Parse(r) if !errs.Has("amount") { t.Error("a required field shown by ShowIf must still be required when absent") } } func TestParseShowIfCarriesBackWhatWasSubmitted(t *testing.T) { r := post(t, "/partials/test", url.Values{"kind": {"numeric"}, "amount": {"not-a-number"}}) values, errs := showIfParseSpec().Parse(r) if !errs.Has("amount") { t.Error("an invalid shown field must still be refused") } if values.Raw("amount") != "not-a-number" { t.Errorf("the refused value must be carried back for the re-render, got %q", values.Raw("amount")) } } // A refusal that belongs to no field reaches the form-level slot through // the same call with an empty field name (lesson L§11). func TestErrorsFormLevel(t *testing.T) { errs := NewErrors() if errs.Any() { t.Error("a fresh error set must be empty") } errs.Field("", "Something is wrong with the whole form.") if errs.FormError() == "" { t.Error("an empty field name must reach the form level") } if !errs.Any() { t.Error("a form-level error must count as a refusal") } } // ParseBatch (design D1's "Rows family contract"): one request carries what // was pressed, every open editor's values, the whole staged batch and the // tray's fields, and every one of them is read through the declaration's // own rules. func TestParseBatchReadsThePressTheRowsTheBatchAndTheTray(t *testing.T) { r := post(t, "/partials/test/rules", url.Values{ "act": {"stage"}, "key": {"rule-12"}, "open": {"rule-12", "rule-77"}, "rows.rule-12.resource_key": {"seats"}, "rows.rule-12.kind": {"numeric"}, "rows.rule-12.limit": {"5"}, "rows.rule-77.resource_key": {"sites"}, "rows.rule-77.kind": {"numeric"}, "rows.rule-77.limit": {"9"}, "staged.0.verb": {"edit"}, "staged.0.key": {"rule-9"}, "staged.0.resource_key": {"sites"}, "staged.0.kind": {"numeric"}, "staged.0.limit": {"3"}, "staged.0.policy": {"force_reduce"}, "note": {"Raising the site limit."}, "csrf_token": {"ignored"}, }) batch := batchSpec().ParseBatch(r) if batch.Errors.Any() { t.Fatalf("a well-formed batch was refused: %+v", batch.Errors) } if batch.Act != "stage" || batch.Key != "rule-12" { t.Errorf("act %q, key %q", batch.Act, batch.Key) } if strings.Join(batch.Open, ",") != "rule-12,rule-77" { t.Errorf("open = %v", batch.Open) } if n, ok := batch.Rows["rule-12"].Int("limit"); !ok || n != 5 { t.Errorf("rule-12 limit = %d, %v", n, ok) } if got := batch.Rows["rule-77"].Raw("resource_key"); got != "sites" { t.Errorf("rule-77 resource_key = %q", got) } if len(batch.Staged) != 1 { t.Fatalf("staged = %+v", batch.Staged) } d := batch.Staged[0] if d.Verb != "edit" || d.Key != "rule-9" { t.Errorf("delta = %+v", d) } if got := d.Values.Raw("policy"); got != "force_reduce" { t.Errorf("delta policy = %q", got) } if n, ok := d.Values.Int("limit"); !ok || n != 3 { t.Errorf("delta limit = %d, %v", n, ok) } if d.Line != "" || d.Error != "" { t.Errorf("the request supplied a line or an error: %+v", d) } if got := batch.Tray.Raw("note"); got != "Raising the site limit." { t.Errorf("tray note = %q", got) } // The declaration is the allowlist here as it is in Parse: a name it // does not declare is read by nothing. if batch.Rows["rule-12"].Present("csrf_token") { t.Error("an undeclared name reached a row's values") } } // Requiredness applies to an open editor and to a staged delta, because // both carry what someone typed; a row that is not open submits nothing and // is not read at all. func TestParseBatchAppliesTheRulesToOpenRowsAndDeltas(t *testing.T) { r := post(t, "/partials/test/rules", url.Values{ "act": {"stage"}, "open": {"rule-12"}, "rows.rule-12.resource_key": {"seats"}, "rows.rule-12.kind": {"numeric"}, "rows.rule-12.limit": {""}, "rows.rule-99.limit": {"4"}, "staged.0.verb": {"edit"}, "staged.0.key": {"rule-9"}, "staged.0.resource_key": {"sites"}, "staged.0.kind": {"numeric"}, "staged.0.limit": {"nope"}, "staged.0.policy": {"force_reduce"}, }) batch := batchSpec().ParseBatch(r) if got := batch.Errors.Get("rows.rule-12.limit"); got != "Enter a limit." { t.Errorf("the open row's refusal = %q", got) } if got := batch.Errors.Get("staged.0.limit"); got != "Limit must be a whole number." { t.Errorf("the delta's refusal = %q", got) } if _, ok := batch.Rows["rule-99"]; ok { t.Error("a row that was not open was read") } // The refusals come back keyed the way the render wants them. if got := batch.Row("rule-12").Errors.Get("limit"); got != "Enter a limit." { t.Errorf("Row's refusal = %q", got) } } // A field the row never showed is neither validated nor carried, per group: // the delta below is a boolean rule, whose Limit was not on the page. func TestParseBatchShowIfIsReadPerGroup(t *testing.T) { r := post(t, "/partials/test/rules", url.Values{ "act": {"undo"}, "staged.0.verb": {"remove"}, "staged.0.key": {"rule-4"}, "staged.0.resource_key": {"seats"}, "staged.0.kind": {"boolean"}, "staged.0.policy": {"clamp"}, }) batch := batchSpec().ParseBatch(r) if batch.Errors.Any() { t.Errorf("a boolean delta was refused: %+v", batch.Errors) } if batch.Staged[0].Values.Present("limit") { t.Error("a field the row never showed was carried") } } // Option membership holds in every group, and a value outside the set is a // refusal rather than a silent default (finding FA-8). func TestParseBatchOptionMembership(t *testing.T) { r := post(t, "/partials/test/rules", url.Values{ "act": {"stage"}, "open": {"rule-12"}, "rows.rule-12.resource_key": {"invented"}, "rows.rule-12.kind": {"boolean"}, "staged.0.verb": {"edit"}, "staged.0.key": {"rule-9"}, "staged.0.resource_key": {"sites"}, "staged.0.kind": {"boolean"}, "staged.0.policy": {"block"}, }) batch := batchSpec().ParseBatch(r) if got := batch.Errors.Get("rows.rule-12.resource_key"); got == "" { t.Error("a resource outside the list was accepted") } if got := batch.Errors.Get("staged.0.policy"); got == "" { t.Error("a policy outside the list was accepted") } // A per-record option the render offered is one the parse accepts, so // the control and the server read one list (ParseBatchWith). with := batchSpec().ParseBatchWith(post(t, "/partials/test/rules", url.Values{ "staged.0.verb": {"edit"}, "staged.0.key": {"rule-9"}, "staged.0.resource_key": {"sites"}, "staged.0.kind": {"boolean"}, "staged.0.policy": {"block"}, }), map[string][]Option{"policy": { {Value: "clamp", Label: "Clamp"}, {Value: "force_reduce", Label: "Force reduce"}, {Value: "block", Label: "Block"}, }}) if with.Errors.Any() { t.Errorf("a policy the render offered was refused: %+v", with.Errors) } } // The batch is read from index 0 up and stops where the groups do, so a // body that numbers a group it never rendered adds nothing. func TestParseBatchReadsContiguousGroupsOnly(t *testing.T) { r := post(t, "/partials/test/rules", url.Values{ "staged.0.verb": {"remove"}, "staged.0.key": {"rule-1"}, "staged.0.resource_key": {"seats"}, "staged.0.kind": {"boolean"}, "staged.0.policy": {"clamp"}, "staged.2.verb": {"remove"}, "staged.2.key": {"rule-3"}, }) batch := batchSpec().ParseBatch(r) if len(batch.Staged) != 1 || batch.Staged[0].Key != "rule-1" { t.Errorf("staged = %+v", batch.Staged) } } // The batch arrives as numbered names, which a forged body can number as // high as it likes; the cap is out of reach in use and refused past it. func TestParseBatchCapsTheBatch(t *testing.T) { body := url.Values{} for i := 0; i <= 250; i++ { body.Set(fmt.Sprintf("staged.%d.verb", i), "remove") body.Set(fmt.Sprintf("staged.%d.key", i), fmt.Sprintf("rule-%d", i)) body.Set(fmt.Sprintf("staged.%d.resource_key", i), "seats") body.Set(fmt.Sprintf("staged.%d.kind", i), "boolean") body.Set(fmt.Sprintf("staged.%d.policy", i), "clamp") } batch := batchSpec().ParseBatch(post(t, "/partials/test/rules", body)) if len(batch.Staged) != maxStagedDeltas { t.Errorf("read %d deltas, want the cap of %d", len(batch.Staged), maxStagedDeltas) } if got := batch.Errors.FormError(); got != "A batch holds at most 200 changes." { t.Errorf("form-level refusal = %q", got) } }