Files
summercms/modules/cabana/dependent_test.go
Jakub Zych 91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00

223 lines
10 KiB
Go

package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestDependentDropdownCompile pins D-08's compile rules for dependsOn: a
// dropdown only, no static options, a sibling of the same form (or repeater
// row) with a scalar value, never itself, and a controller that implements
// pact.DependentOptionsProvider.
func TestDependentDropdownCompile(t *testing.T) {
t.Run("a valid dependent dropdown compiles", func(t *testing.T) {
cc := ucMustCompile(t, ucFields+ucRepeater(""), nil)
if ref := ucField(t, cc, "reference"); ref.DependsOn != "type" || len(ref.Options) != 0 {
t.Fatalf("reference = %+v", ref)
}
if !usesDependsOn(cc.Form.Fields) {
t.Fatal("usesDependsOn = false")
}
})
base := "fields:\n title:\n type: text\n type:\n type: dropdown\n options: typeOptions\n"
for _, tc := range []struct {
name, fields string
ctl pact.AdminController
want string
}{
{"on a text field", base + " reference:\n type: text\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"},
{"on a textarea", base + " reference:\n type: textarea\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"},
{"with static options", base + " reference:\n type: dropdown\n dependsOn: type\n options: typeOptions\n", nil, "a dropdown with dependsOn takes no options"},
{"not a field name", base + " reference:\n type: dropdown\n dependsOn: \"a b\"\n", nil, `dependsOn "a b" is not a field name`},
{"names itself", base + " reference:\n type: dropdown\n dependsOn: reference\n", nil, "field reference: dependsOn names the field itself"},
{"unknown sibling", base + " reference:\n type: dropdown\n dependsOn: kind\n", nil, "field reference: dependsOn kind is not a field of this form"},
{"sibling without a scalar value", base + ucRepeater("") + " pick:\n type: dropdown\n dependsOn: rows\n", nil, "field pick: dependsOn rows must be a field with a scalar value, not type repeater"},
{"no provider", ucFields, ucPlainLinks{}, "field reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"},
{"no provider for a nested dropdown", base + ucRepeater(""), ucPlainLinks{}, "field rows.reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"},
{"nested unknown sibling", base + " rows:\n type: repeater\n form:\n fields:\n reference:\n type: dropdown\n dependsOn: title\n", nil, "field rows.reference: dependsOn title is not a field of the same repeater row"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ucCompile(tc.fields, tc.ctl)
wantErr(t, err, tc.want)
})
}
t.Run("a controller without dependsOn needs no provider", func(t *testing.T) {
if _, err := ucCompile(base, ucPlainLinks{}); err != nil {
t.Fatal(err)
}
})
t.Run("dependent fields are found by path", func(t *testing.T) {
cc := ucMustCompile(t, ucFields+ucRepeater(""), nil)
for _, tc := range []struct {
path, want string
ok bool
}{
{"reference", "reference", true},
{"rows.reference", "rows.reference", true},
{"title", "", false},
{"type", "", false},
{"rows.title", "", false},
{"rows.missing", "", false},
{"rows", "", false},
{"title.reference", "", false},
{"missing", "", false},
} {
field, ok := dependentField(cc, tc.path)
if ok != tc.ok || (ok && (field.Name != tc.want || field.DependsOn != "type")) {
t.Fatalf("dependentField(%q) = %+v, %v", tc.path, field, ok)
}
}
if _, ok := dependentField(nil, "reference"); ok {
t.Fatal("nil controller answered")
}
})
}
// TestDependentValues pins the Docker-free half of the D-08 save recheck:
// sibling values are normalized the same way for the options route and a
// save, null is always accepted, a value outside the options is a 422 on the
// field (field.index.nested in a row), a type that takes no reference stores
// null, the stored sibling or value is used when only the other is written,
// and the provider is asked once per path and value.
func TestDependentValues(t *testing.T) {
for _, tc := range []struct {
in any
want any
}{
{nil, nil}, {"", nil}, {"x", "x"}, {true, "true"}, {false, "false"},
{json.Number("12"), "12"}, {float64(2), "2"}, {2.5, "2.5"}, {3, "3"}, {int64(4), "4"}, {uint(5), "5"},
{[]any{1}, "[1]"},
} {
if got := dependentValue(tc.in); got != tc.want {
t.Fatalf("dependentValue(%#v) = %#v, want %#v", tc.in, got, tc.want)
}
}
set := pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1"}, {Value: "2"}}}
for _, tc := range []struct {
value any
ok bool
}{{nil, true}, {"", true}, {"1", true}, {float64(2), true}, {json.Number("2"), true}, {"3", false}, {"01", false}, {true, false}} {
if got := amongOptions(set, tc.value); got != tc.ok {
t.Fatalf("amongOptions(%#v) = %v", tc.value, got)
}
}
page, list := "acme-page", "acme-list"
stale := "99"
two := "2"
check := func(t *testing.T, target *ucLink, projected map[string]any) (map[string]any, []string, error) {
t.Helper()
var calls []string
cc := ucMustCompile(t, ucFields+ucRepeater(""), ucLinks{calls: &calls})
err := checkDependentValues(context.Background(), cc, nil, target, projected, "update")
var invalid *ValidationError
if errors.As(err, &invalid) {
return invalid.Details, calls, nil
}
return nil, calls, err
}
t.Run("top level", func(t *testing.T) {
for _, tc := range []struct {
name string
target ucLink
projected map[string]any
wantInvalid bool
wantReference any
wantCalls string
}{
{"a value among the options", ucLink{}, map[string]any{"type": page, "reference": "2"}, false, "2", "[reference=map[type:acme-page]]"},
{"a number value is compared as text", ucLink{}, map[string]any{"type": page, "reference": float64(1)}, false, float64(1), "[reference=map[type:acme-page]]"},
{"null is accepted", ucLink{}, map[string]any{"type": page, "reference": nil}, false, nil, "[reference=map[type:acme-page]]"},
{"a value outside the options", ucLink{}, map[string]any{"type": page, "reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
{"a type without references stores null", ucLink{}, map[string]any{"type": list, "reference": "1"}, false, nil, "[reference=map[type:acme-list]]"},
{"an empty type stores null", ucLink{}, map[string]any{"type": "", "reference": "1"}, false, nil, "[reference=map[type:<nil>]]"},
{"only the value: the stored sibling decides", ucLink{Type: &page}, map[string]any{"reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
{"only the value, stored sibling accepts it", ucLink{Type: &page}, map[string]any{"reference": "1"}, false, "1", "[reference=map[type:acme-page]]"},
{"only the sibling: the stored value is checked", ucLink{Reference: &stale}, map[string]any{"type": page}, true, nil, "[reference=map[type:acme-page]]"},
{"only the sibling, stored value still valid", ucLink{Reference: &two}, map[string]any{"type": page}, false, nil, "[reference=map[type:acme-page]]"},
{"only the sibling to a type without references clears the value", ucLink{Reference: &two}, map[string]any{"type": list}, false, nil, "[reference=map[type:acme-list]]"},
{"neither written asks nothing", ucLink{Type: &page, Reference: &stale}, map[string]any{"title": "x"}, false, nil, "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
target := tc.target
details, calls, err := check(t, &target, tc.projected)
if err != nil {
t.Fatal(err)
}
if (details != nil) != tc.wantInvalid {
t.Fatalf("details = %v, want invalid %v", details, tc.wantInvalid)
}
if tc.wantInvalid {
if msgs, _ := details["reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
t.Fatalf("details = %v", details)
}
}
if got, present := tc.projected["reference"]; present || tc.wantReference != nil {
if fmt.Sprint(got) != fmt.Sprint(tc.wantReference) {
t.Fatalf("projected reference = %#v, want %#v", got, tc.wantReference)
}
}
if strings.Contains(tc.name, "stores null") || strings.Contains(tc.name, "clears the value") {
if v, present := tc.projected["reference"]; !present || v != nil {
t.Fatalf("reference not written as null: %#v (present %v)", v, present)
}
}
if fmt.Sprint(calls) != tc.wantCalls {
t.Fatalf("provider calls = %v, want %s", calls, tc.wantCalls)
}
})
}
})
t.Run("repeater rows", func(t *testing.T) {
projected := map[string]any{"rows": []any{
map[string]any{"title": "a", "type": page, "reference": "1"},
map[string]any{"title": "b", "type": page, "reference": "99"},
map[string]any{"title": "c", "type": list, "reference": "1"},
map[string]any{"title": "d", "type": page, "reference": "2"},
"not a row",
}}
details, calls, err := check(t, &ucLink{}, projected)
if err != nil {
t.Fatal(err)
}
if msgs, _ := details["rows.1.reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
t.Fatalf("details = %v", details)
}
rows := projected["rows"].([]any)
if rows[2].(map[string]any)["reference"] != nil || rows[0].(map[string]any)["reference"] != "1" {
t.Fatalf("rows = %v", rows)
}
// One call per distinct path and value: the second acme-page row
// and the fourth reuse the first answer.
if fmt.Sprint(calls) != "[rows.reference=map[type:acme-page] rows.reference=map[type:acme-list]]" {
t.Fatalf("provider calls = %v", calls)
}
})
t.Run("a provider error is returned, not a 422", func(t *testing.T) {
broken := "acme-broken"
_, _, err := check(t, &ucLink{}, map[string]any{"type": broken, "reference": "1"})
if err == nil || !strings.Contains(err.Error(), "references failed") {
t.Fatalf("err = %v", err)
}
})
t.Run("a form without dependsOn asks nothing", func(t *testing.T) {
cc := ucMustCompile(t, "fields:\n title:\n type: text\n", ucPlainLinks{})
if err := checkDependentValues(context.Background(), cc, nil, &ucLink{}, map[string]any{"title": "x"}, "update"); err != nil {
t.Fatal(err)
}
})
}