The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
614 lines
22 KiB
Go
614 lines
22 KiB
Go
// 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)
|
|
}
|
|
}
|