test(18.1-06): taglist and flat reorder unit matrices, partitioned flat smoke and admin specs (task 2)

- TestTaglistFieldValidate: both modes, trim, dedupe, empty, null, wrong shapes,
  customTags false, required; TestTaglistFieldHydrate: stored NULL and bad text as []
- TestReorderFlatNodes: parent 422, duplicate and zero id 422, missing or extra 409,
  no depth or cycle check; TestReorderFlatCompile checks the localized list schema
- TestReorderFlatSmoke/partitioned: flat reorder within one partition on Postgres
- vitest: script markup as text, Backspace, blur, paste, datalist, customTags,
  string mode; flat ArrowUp/ArrowDown and the before/after drop zones
- export_test.go wrappers for the taglist save check, hydration and node check
- check-phase18.1.sh requires the new names in the go, admin and postgres stages
This commit is contained in:
Jakub Zych
2026-10-14 20:38:52 +02:00
parent 0f09586ec0
commit 323b6d684a
8 changed files with 841 additions and 10 deletions

View File

@@ -375,3 +375,5 @@ go test ./modules/cabana/...
The database-backed tests start a PostgreSQL container through testcontainers-go and need Docker. Run `go test -short ./modules/cabana/...` to skip them. YAML fixtures for the schema compiler live in `testdata/`.
The Phase 18 pieces (money field and column, write-only settings secrets, richeditor and tabs on settings forms, danger record actions) are covered by Docker-free unit tests in `money_field_test.go` (TestMoneyFieldCompile, TestMoneyFieldColumnGate, TestMoneyFieldValidate, TestMoneyListColumn), `settings_secret_test.go` (TestSettingsSecretCompile, TestSettingsSecretProjection, TestSettingsSecretPut), `settings_tabs_test.go` (TestSettingsTabs, TestSettingsRichEditor) and `record_action_danger_test.go` (TestRecordActionDanger), and by Postgres tests in `money_field_postgres_test.go` (TestMoneyFieldSave), `settings_secret_postgres_test.go` (TestSettingsSecretPostgres), the smoke tests in `payments_smoke_test.go` and the Phase 18 payload cases of TestPhase10OpenAPIConformance. `scripts/check-phase18.sh` runs them by name.
The Phase 18.1 pieces (the taglist field and the flat reorder) are covered by Docker-free unit tests in `taglist_field_test.go` (TestTaglistFieldCompile, TestTaglistFieldValidate for the save check in both modes, TestTaglistFieldHydrate for the record GET) and `reorder_flat_test.go` (TestReorderFlatCompile, which also checks the localized list schema, and TestReorderFlatNodes for the save's node check), and by Postgres tests in `catalog_smoke_test.go` (TestTaglistFieldSmoke, TestReorderFlatSmoke with a partitioned subtest) and `reorder_test.go` (TestReorderPostgres for the tree reorder). The admin specs are `admin/tests/form/TaglistField.test.ts` and `admin/tests/views/ReorderView.test.ts`. `scripts/check-phase18.1.sh` runs them by name.

View File

@@ -302,7 +302,8 @@ type catalogReorder struct {
// TestReorderFlatSmoke checks the flat reorder routes on Postgres: the GET
// answers flat true with parent_id null on every node, the POST stores the
// submitted sort values (only the sort column) and answers the new order, a
// node with a parent is a 422 on nodes and a missing node a 409.
// node with a parent is a 422 on nodes and a missing node a 409. The
// partitioned subtest runs the same rules within one partition.
func TestReorderFlatSmoke(t *testing.T) {
e := newCatalogEnv(t)
ids := make([]uint, 0, 3)
@@ -371,4 +372,171 @@ func TestReorderFlatSmoke(t *testing.T) {
if rec.Code != http.StatusConflict {
t.Fatalf("missing node status=%d body=%s", rec.Code, rec.Body.String())
}
t.Run("partitioned", reorderFlatPartition)
}
// shelfPin is the record of the partitioned flat reorder fixture
// (acme.shelf): a name, the shelf it belongs to (the partition) and a sort
// column, and no parent_id column, so its reorder is flat.
type shelfPin struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
ShelfID *uint `gorm:"column:shelf_id"`
SortOrder int `gorm:"column:sort_order"`
}
func (shelfPin) TableName() string { return "acme_shelf_pins" }
func (shelfPin) Fillable() []string { return []string{"name", "sort_order"} }
func (shelfPin) Rules() map[string]string { return map[string]string{"name": "required"} }
// shelfShelf is the belongsTo target of the pins' shelf field.
type shelfShelf struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func (shelfShelf) TableName() string { return "acme_shelf_shelves" }
// shelfFS is the acme.shelf fixture tree: the pins controller with a flat
// reorder partitioned by shelf.
var shelfFS = fstest.MapFS{
"controllers/pins/config_list.yaml": {Data: []byte("list: ~/plugins/acme/shelf/models/pin/columns.yaml\nmodelClass: Pin\ntitle: Pins\nrecordUrl: acme/shelf/pins/update/:id\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n")},
"controllers/pins/config_form.yaml": {Data: []byte("name: Pin\nform: ~/plugins/acme/shelf/models/pin/fields.yaml\nmodelClass: Pin\ndefaultRedirect: acme/shelf/pins\n")},
"controllers/pins/config_reorder.yaml": {Data: []byte("title: Reorder pins\nnameFrom: name\nmodelClass: Pin\npartition:\n column: shelf_id\n relation: shelf\n label: Shelf\n")},
"models/pin/columns.yaml": {Data: []byte("columns:\n name:\n label: Name\n sort_order:\n label: Order\n")},
"models/pin/fields.yaml": {Data: []byte("fields:\n name:\n label: Name\n type: text\n shelf:\n label: Shelf\n type: relation\n nameFrom: title\n")},
}
// shelfPlugin registers the pins controller.
type shelfPlugin struct{}
func (shelfPlugin) ID() string { return "acme.shelf" }
func (shelfPlugin) Requires() []string { return nil }
func (shelfPlugin) Register(*backpack.App) error { return nil }
func (shelfPlugin) Boot(*backpack.App) error { return nil }
func (shelfPlugin) AdminFS() fs.FS { return shelfFS }
func (shelfPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{shelfPins{}}
}
func (shelfPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}}
}
// shelfPins is the pins controller.
type shelfPins struct{}
func (shelfPins) ID() string { return "acme.shelf.pins" }
func (shelfPins) ModelName() string { return "Pin" }
func (shelfPins) ConfigDir() string { return "controllers/pins" }
func (shelfPins) RequiredPermissions() []string { return []string{"acme.shelf.access"} }
func (shelfPins) NewRecord() any { return &shelfPin{} }
// AdminFieldRelations binds the shelf relation field, the reorder partition.
func (shelfPins) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "shelf", Kind: "belongsTo", NewRelated: func() any { return &shelfShelf{} }, ForeignKey: "shelf_id"}}
}
// newShelfEnv is a logged-in admin over the acme.shelf fixture with fresh
// tables.
func newShelfEnv(t *testing.T) *conformEnv {
t.Helper()
gdb := adminGorm(t)
models := []any{&shelfPin{}, &shelfShelf{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
login := fmt.Sprintf("shelf-%d", time.Now().UnixNano())
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
h, err := paymentsAssemble(t, gdb, []party.Plugin{shelfPlugin{}})
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: gdb, login: login}
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
return env
}
// reorderFlatPartition checks a partitioned flat reorder on Postgres (plan
// 18.1-06, a subtest of TestReorderFlatSmoke): the GET of one shelf answers flat true with only
// that shelf's pins and the partition options, the POST writes only the sort
// column (the shelf and the other shelf's rows are untouched), a pin of the
// other shelf is the 409 stale set and a parent is a 422 on nodes.
func reorderFlatPartition(t *testing.T) {
e := newShelfEnv(t)
top, low := shelfShelf{Title: "Top"}, shelfShelf{Title: "Low"}
for _, s := range []*shelfShelf{&top, &low} {
if err := e.db.Create(s).Error; err != nil {
t.Fatal(err)
}
}
pins := []shelfPin{{Name: "A", ShelfID: &top.ID, SortOrder: 1}, {Name: "B", ShelfID: &top.ID, SortOrder: 2}, {Name: "X", ShelfID: &low.ID, SortOrder: 7}}
for i := range pins {
if err := e.db.Create(&pins[i]).Error; err != nil {
t.Fatal(err)
}
}
a, b, x := pins[0].ID, pins[1].ID, pins[2].ID
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/shelf/pins/reorder?partition=%d", top.ID), nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String())
}
var got struct {
Data struct {
Flat bool `json:"flat"`
Partition *struct {
Label string `json:"label"`
Value *uint `json:"value"`
Options []struct {
Label string `json:"label"`
} `json:"options"`
} `json:"partition"`
Nodes []struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id"`
} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
d := got.Data
if !d.Flat || d.Partition == nil || d.Partition.Label != "Shelf" || d.Partition.Value == nil || *d.Partition.Value != top.ID || len(d.Partition.Options) != 2 {
t.Fatalf("GET = %s", rec.Body.String())
}
if len(d.Nodes) != 2 || d.Nodes[0].ID != a || d.Nodes[1].ID != b || d.Nodes[0].ParentID != nil || d.Nodes[1].ParentID != nil {
t.Fatalf("GET nodes = %s", rec.Body.String())
}
post := func(nodes ...map[string]any) int {
return e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{"partition": top.ID, "nodes": nodes}, true).Code
}
n := func(id uint, parent any, sort int) map[string]any {
return map[string]any{"id": id, "parent_id": parent, "sort_order": sort}
}
if code := post(n(b, nil, 1), n(a, nil, 2)); code != http.StatusOK {
t.Fatalf("POST status=%d", code)
}
rows := map[uint]shelfPin{}
var all []shelfPin
if err := e.db.Find(&all).Error; err != nil {
t.Fatal(err)
}
for _, p := range all {
rows[p.ID] = p
}
if rows[b].SortOrder != 1 || rows[a].SortOrder != 2 || *rows[a].ShelfID != top.ID || *rows[b].ShelfID != top.ID || rows[x].SortOrder != 7 || *rows[x].ShelfID != low.ID {
t.Fatalf("rows after POST = %+v", rows)
}
if code := post(n(b, nil, 1), n(a, nil, 2), n(x, nil, 3)); code != http.StatusConflict {
t.Fatalf("pin of the other shelf status=%d, want 409", code)
}
if code := post(n(b, nil, 1), n(a, b, 2)); code != http.StatusUnprocessableEntity {
t.Fatalf("parent status=%d, want 422", code)
}
}

View File

@@ -1,11 +1,14 @@
package cabana
import (
"context"
"errors"
"fmt"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
@@ -68,3 +71,34 @@ func CheckMoneyType(model any, field FormField) error { return checkMoneyType(mo
func ProjectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
return projectRow(row, controller, cols)
}
// CheckTaglistValues exposes the taglist save check (every field allowed)
// to the external taglist tests: it rewrites body in place and returns the
// *ValidationError of the refused values, or nil.
func CheckTaglistValues(ctx context.Context, tr *phrasebook.Translator, fields []FormField, body map[string]any, create bool) error {
return checkTaglistValues(ctx, tr, fields, body, nil, create)
}
// HydrateTaglistFields exposes the record GET's taglist hydration to the
// external taglist tests.
func HydrateTaglistFields(fields []FormField, data map[string]any) {
hydrateTaglistFields(fields, data)
}
// CheckReorderNodes exposes the reorder save's node check to the external
// reorder tests, over a reorder that is flat or a tree with maxDepth.
func CheckReorderNodes(flat bool, maxDepth int, locked []AdminReorderNode, nodes []AdminReorderNodeInput, flatParent string) error {
ro := &compiledReorder{flat: flat, maxDepth: maxDepth}
if !flat {
ro.parentColumn = "parent_id"
}
ro.sortColumn = "sort_order"
return checkReorderNodes(ro, locked, nodes, flatParent)
}
// IsReorderStale reports whether err is the reorder save's 409 (the
// submitted node set differs from the stored one).
func IsReorderStale(err error) bool {
var stale reorderStale
return errors.As(err, &stale)
}

View File

@@ -1,18 +1,24 @@
package cabana_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// Smoke tests of the flat (WinterCMS Sortable) reorder (Phase 18.1 D-17),
// Docker-free: the inference rule and the flat flag. The Postgres routes are
// TestReorderFlatSmoke in catalog_smoke_test.go. Full unit coverage is plan
// 06. No application names appear in framework tests.
// TestReorderFlatSmoke in catalog_smoke_test.go; TestReorderFlatNodes is the
// unit matrix of the save's node check (plan 18.1-06). No application names
// appear in framework tests.
// TestReorderFlatCompile pins the flat inference: a config_reorder.yaml
// without parentColumn on a record without a parent_id column compiles as
@@ -60,6 +66,33 @@ func TestReorderFlatCompile(t *testing.T) {
})
}
// The localized list schema (GET .../schema/list) carries the flat flag
// too, not only the compiled one (deferred defect from plan 18.1-04).
t.Run("the localized list schema keeps flat", func(t *testing.T) {
app := backpack.New(nil)
if err := phrasebook.Activate[party.Plugin](app, nil); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok {
t.Fatal("translator missing")
}
view, err := list.Localize(towel.WithLocale(context.Background(), "en"), tr)
if err != nil {
t.Fatal(err)
}
if view.Reorder == nil || !view.Reorder.Flat || view.Reorder.Title != "Reorder badges" {
t.Fatalf("localized reorder = %+v", view.Reorder)
}
raw, err := json.Marshal(view)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"reorder":{"title":"Reorder badges","flat":true}`) {
t.Fatalf("localized list schema JSON lacks flat: %s", raw)
}
})
t.Run("the tree fixture stays a tree", func(t *testing.T) {
tree, err := cabana.CompiledListSchema([]party.Plugin{companionsFSPlugin{fsys: companionsOverlay(t, nil)}}, "acme.companions.links")
if err != nil {
@@ -77,3 +110,104 @@ func TestReorderFlatCompile(t *testing.T) {
}
})
}
// TestReorderFlatNodes is the unit matrix of the reorder save's node check
// on a flat reorder (D-17), Docker-free: any non-null parent is a 422 on
// nodes with the flat_parent message, a duplicate id or a zero id is a 422,
// a missing or an extra node is the 409 stale set, and a valid set in any
// order passes with no depth or cycle evaluation. The same node lists on a
// tree reorder show what flat mode changes. TestReorderFlatSmoke drives the
// rules over HTTP on Postgres.
func TestReorderFlatNodes(t *testing.T) {
const flatParent = "A flat reorder has no parents."
locked := []cabana.AdminReorderNode{{ID: 1, SortOrder: 1}, {ID: 2, SortOrder: 2}, {ID: 3, SortOrder: 3}}
node := func(id uint64, parent *uint64, sort int64) cabana.AdminReorderNodeInput {
return cabana.AdminReorderNodeInput{ID: id, ParentID: parent, SortOrder: sort}
}
ptr := func(v uint64) *uint64 { return &v }
nodesError := func(t *testing.T, err error, want string) {
t.Helper()
var ve *cabana.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want a 422 validation error", err)
}
msgs, _ := ve.Details["nodes"].([]string)
if len(msgs) != 1 || msgs[0] != want || len(ve.Details) != 1 {
t.Fatalf("details = %#v, want nodes: %q", ve.Details, want)
}
}
t.Run("a reversed set passes", func(t *testing.T) {
nodes := []cabana.AdminReorderNodeInput{node(3, nil, 1), node(2, nil, 2), node(1, nil, 3)}
if err := cabana.CheckReorderNodes(true, 0, locked, nodes, flatParent); err != nil {
t.Fatal(err)
}
})
t.Run("equal and negative sort values pass", func(t *testing.T) {
nodes := []cabana.AdminReorderNodeInput{node(1, nil, -5), node(2, nil, 0), node(3, nil, 0)}
if err := cabana.CheckReorderNodes(true, 0, locked, nodes, flatParent); err != nil {
t.Fatal(err)
}
})
t.Run("a long flat list has no depth or cycle check", func(t *testing.T) {
var many []cabana.AdminReorderNode
var nodes []cabana.AdminReorderNodeInput
for i := uint(1); i <= 200; i++ {
many = append(many, cabana.AdminReorderNode{ID: i})
nodes = append(nodes, node(uint64(201-i), nil, int64(i)))
}
if err := cabana.CheckReorderNodes(true, 1, many, nodes, flatParent); err != nil {
t.Fatal(err)
}
})
t.Run("an empty set over no records passes", func(t *testing.T) {
if err := cabana.CheckReorderNodes(true, 0, nil, nil, flatParent); err != nil {
t.Fatal(err)
}
})
for _, tc := range []struct {
name string
nodes []cabana.AdminReorderNodeInput
want string
}{
{"a parent inside the set", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, nil, 3)}, flatParent},
{"a parent outside the set", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, ptr(99), 3)}, flatParent},
{"a parent of zero", []cabana.AdminReorderNodeInput{node(1, ptr(0), 1), node(2, nil, 2), node(3, nil, 3)}, flatParent},
{"its own parent", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, ptr(3), 3)}, flatParent},
{"a duplicate id", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(2, nil, 3)}, "The nodes field lists a record twice."},
{"a zero id", []cabana.AdminReorderNodeInput{node(0, nil, 1), node(2, nil, 2), node(3, nil, 3)}, "The nodes field must list record ids."},
} {
t.Run(tc.name+" is a 422", func(t *testing.T) {
nodesError(t, cabana.CheckReorderNodes(true, 0, locked, tc.nodes, flatParent), tc.want)
})
}
for _, tc := range []struct {
name string
nodes []cabana.AdminReorderNodeInput
}{
{"a missing node", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2)}},
{"an extra node", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(3, nil, 3), node(4, nil, 4)}},
{"a node swapped for another", []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, nil, 2), node(4, nil, 3)}},
{"no nodes", nil},
} {
t.Run(tc.name+" is the 409 stale set", func(t *testing.T) {
err := cabana.CheckReorderNodes(true, 0, locked, tc.nodes, flatParent)
if !cabana.IsReorderStale(err) {
t.Fatalf("err = %v, want the stale set", err)
}
})
}
t.Run("the same parent is accepted on a tree", func(t *testing.T) {
nodes := []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, nil, 3)}
if err := cabana.CheckReorderNodes(false, 0, locked, nodes, flatParent); err != nil {
t.Fatal(err)
}
cycle := []cabana.AdminReorderNodeInput{node(1, ptr(2), 1), node(2, ptr(1), 2), node(3, nil, 3)}
nodesError(t, cabana.CheckReorderNodes(false, 0, locked, cycle, flatParent), "The tree has a cycle.")
deep := []cabana.AdminReorderNodeInput{node(1, nil, 1), node(2, ptr(1), 2), node(3, ptr(2), 3)}
nodesError(t, cabana.CheckReorderNodes(false, 2, locked, deep, flatParent), "The tree may be at most 2 levels deep.")
})
}

View File

@@ -1,20 +1,28 @@
package cabana_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
// Smoke tests of the cabana taglist field (Phase 18.1 D-17), Docker-free:
// the boot gates of the keys and of the model column. The Postgres save
// round trip is TestTaglistFieldSmoke in catalog_smoke_test.go; the fixture
// (acme.catalog) lives there. Full unit coverage is plan 06. No application
// names appear in framework tests.
// (acme.catalog) lives there. TestTaglistFieldValidate and
// TestTaglistFieldHydrate are the unit matrices of the save check and the
// record hydration (plan 18.1-06). No application names appear in framework
// tests.
// catalogFieldsPath is the badge fields.yaml of the fixture.
const catalogFieldsPath = "models/badge/fields.yaml"
@@ -156,3 +164,234 @@ func TestTaglistFieldCompile(t *testing.T) {
}
})
}
// taglistField compiles one taglist field named keywords from the YAML keys
// given (indented under the field) on the badge fixture model.
func taglistField(t *testing.T, keys string) cabana.FormField {
t.Helper()
fields := "fields:\n keywords:\n type: taglist\n" + keys
schema, err := cabana.CompileForm("acme.catalog", catalogBadges{}, catalogOverlay(t, map[string]string{catalogFieldsPath: fields}))
if err != nil {
t.Fatalf("compile %q: %v", keys, err)
}
return schema.Fields[0]
}
// taglistTranslator is the framework translator with the backend phrases.
func taglistTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
app := backpack.New(nil)
if err := phrasebook.Activate[party.Plugin](app, nil); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok {
t.Fatal("translator missing")
}
return tr
}
// TestTaglistFieldValidate is the save-time matrix of the taglist field
// (D-17) on the save check itself, Docker-free: normalisation (trim, drop
// empty, dedupe keeping the first), the stored form per mode (JSON text of
// the list in array mode, the tags joined by the separator in string mode),
// null as no tags, the 422 taglist.invalid for every wrong shape (a string
// in array mode, a list in string mode, a number item, an object, a null
// item), the 422 taglist.not_allowed naming the first tag outside the
// options with customTags false, and the required rule on the normalised
// tags. TestTaglistFieldSmoke drives the same rules over HTTP on Postgres.
func TestTaglistFieldValidate(t *testing.T) {
tr := taglistTranslator(t)
ctx := towel.WithLocale(context.Background(), "en")
array := taglistField(t, " mode: array\n")
comma := taglistField(t, "")
space := taglistField(t, " separator: space\n")
strict := taglistField(t, " mode: array\n options: [alpha, beta]\n customTags: false\n")
strictString := taglistField(t, " options: [alpha, beta]\n customTags: false\n")
open := taglistField(t, " mode: array\n options: [alpha, beta]\n")
required := taglistField(t, " mode: array\n required: true\n")
requiredString := taglistField(t, " required: true\n")
const invalid = "The keywords field must be a list of tags."
ok := []struct {
name string
field cabana.FormField
in any
want any
}{
{"array trims, drops empty and dedupes keeping the first", array, []any{" a ", "a", "", "b", " "}, `["a","b"]`},
{"array keeps the order of first appearance", array, []any{"b", "a", "b"}, `["b","a"]`},
{"array null stores the empty list", array, nil, `[]`},
{"array empty list stays the empty list", array, []any{}, `[]`},
{"array of blanks is the empty list", array, []any{" ", ""}, `[]`},
{"array keeps inner spaces and case", array, []any{"New York", "new york"}, `["New York","new york"]`},
{"array keeps markup as plain text (HTML-escaped JSON)", array, []any{"<b>x</b>"}, `["\u003cb\u003ex\u003c/b\u003e"]`},
{"string comma splits, trims and dedupes", comma, " a , b,a,, ", "a,b"},
{"string comma keeps inner spaces", comma, "big data, ai", "big data,ai"},
{"string null stores an empty string", comma, nil, ""},
{"string empty stays empty", comma, "", ""},
{"string space splits on any whitespace", space, "x y x", "x y"},
{"string space takes tabs and newlines", space, "\tx\ny ", "x y"},
{"string space keeps commas inside a tag", space, "a,b c", "a,b c"},
{"customTags false accepts the options, normalised", strict, []any{"alpha", " beta ", "alpha"}, `["alpha","beta"]`},
{"customTags false accepts no tags", strict, []any{}, `[]`},
{"customTags false in string mode accepts the options", strictString, "beta,alpha", "beta,alpha"},
{"options without customTags false take any tag", open, []any{"delta"}, `["delta"]`},
{"required with a tag", required, []any{"a"}, `["a"]`},
}
for _, tc := range ok {
t.Run(tc.name, func(t *testing.T) {
body := map[string]any{"keywords": tc.in}
if err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{tc.field}, body, false); err != nil {
t.Fatalf("refused: %v %+v", err, err)
}
if body["keywords"] != tc.want {
t.Fatalf("stored %#v, want %#v", body["keywords"], tc.want)
}
})
}
refused := []struct {
name string
field cabana.FormField
body map[string]any
create bool
want string
}{
{"a string in array mode", array, map[string]any{"keywords": "a,b"}, false, invalid},
{"a number item in array mode", array, map[string]any{"keywords": []any{"a", float64(1)}}, false, invalid},
{"a null item in array mode", array, map[string]any{"keywords": []any{"a", nil}}, false, invalid},
{"a nested list in array mode", array, map[string]any{"keywords": []any{[]any{"a"}}}, false, invalid},
{"an object in array mode", array, map[string]any{"keywords": map[string]any{"a": true}}, false, invalid},
{"a boolean in array mode", array, map[string]any{"keywords": true}, false, invalid},
{"a list in string mode", comma, map[string]any{"keywords": []any{"a"}}, false, invalid},
{"a number in string mode", comma, map[string]any{"keywords": float64(5)}, false, invalid},
{"an object in string mode", space, map[string]any{"keywords": map[string]any{}}, false, invalid},
{"customTags false names the first tag outside the options", strict, map[string]any{"keywords": []any{"alpha", "delta", "omega"}}, false, "The tag delta is not allowed."},
{"customTags false compares the trimmed tag", strict, map[string]any{"keywords": []any{" gamma "}}, false, "The tag gamma is not allowed."},
{"customTags false is case-sensitive", strict, map[string]any{"keywords": []any{"Alpha"}}, false, "The tag Alpha is not allowed."},
{"customTags false in string mode", strictString, map[string]any{"keywords": "alpha,delta"}, false, "The tag delta is not allowed."},
{"required with only blanks", required, map[string]any{"keywords": []any{" ", ""}}, false, "The keywords field is required."},
{"required with null", required, map[string]any{"keywords": nil}, false, "The keywords field is required."},
{"required missing on create", required, map[string]any{}, true, "The keywords field is required."},
{"required string of separators", requiredString, map[string]any{"keywords": " , ,"}, false, "The keywords field is required."},
}
for _, tc := range refused {
t.Run(tc.name, func(t *testing.T) {
before, _ := json.Marshal(tc.body)
err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{tc.field}, tc.body, tc.create)
var ve *cabana.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want a validation error", err)
}
msgs, _ := ve.Details["keywords"].([]string)
if len(msgs) != 1 || msgs[0] != tc.want || len(ve.Details) != 1 {
t.Fatalf("details = %#v, want keywords: %q", ve.Details, tc.want)
}
after, _ := json.Marshal(tc.body)
if string(before) != string(after) {
t.Fatalf("a refused value rewrote the body: %s -> %s", before, after)
}
})
}
t.Run("missing on update leaves the body alone", func(t *testing.T) {
body := map[string]any{"name": "x"}
if err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{required}, body, false); err != nil {
t.Fatal(err)
}
if _, ok := body["keywords"]; ok || len(body) != 1 {
t.Fatalf("body = %#v", body)
}
})
t.Run("every refused field is reported at once and other fields are untouched", func(t *testing.T) {
other := strict
other.Name = "tags"
text := cabana.FormField{Name: "name", Type: "text"}
body := map[string]any{"keywords": "a", "tags": []any{"delta"}, "name": []any{"not checked here"}}
err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{array, other, text}, body, false)
var ve *cabana.ValidationError
if !errors.As(err, &ve) || len(ve.Details) != 2 || ve.Details["keywords"] == nil || ve.Details["tags"] == nil {
t.Fatalf("err = %v %+v", err, ve)
}
})
t.Run("the messages follow the request locale", func(t *testing.T) {
body := map[string]any{"keywords": "a"}
err := cabana.CheckTaglistValues(towel.WithLocale(context.Background(), "pl"), tr, []cabana.FormField{array}, body, false)
var ve *cabana.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v", err)
}
if msgs, _ := ve.Details["keywords"].([]string); len(msgs) != 1 || msgs[0] != "Pole keywords musi być listą tagów." {
t.Fatalf("pl details = %#v", ve.Details)
}
})
t.Run("a nil body is accepted", func(t *testing.T) {
if err := cabana.CheckTaglistValues(ctx, tr, []cabana.FormField{required}, nil, true); err != nil {
t.Fatal(err)
}
})
}
// TestTaglistFieldHydrate checks the record GET's hydration of an
// array-mode taglist: the stored JSON text (a string, a *string or a JSON
// column value) becomes the list; a stored NULL, JSON null, unparsable text
// or a JSON value that is not a list of strings becomes the empty list; a
// missing key stays missing; a string-mode value and other fields are served
// as stored.
func TestTaglistFieldHydrate(t *testing.T) {
array := taglistField(t, " mode: array\n")
comma := taglistField(t, "")
comma.Name = "words"
text := `["a","b"]`
var nilText *string
for _, tc := range []struct {
name string
stored any
want []string
}{
{"JSON text", `["a","b"]`, []string{"a", "b"}},
{"a pointer to JSON text", &text, []string{"a", "b"}},
{"a JSON column value", lagoon.Jsonable[[]string]{Data: []string{"x"}, Valid: true}, []string{"x"}},
{"an empty list", `[]`, []string{}},
{"a stored NULL", nil, []string{}},
{"a nil pointer", nilText, []string{}},
{"an invalid JSON column value", lagoon.Jsonable[[]string]{}, []string{}},
{"JSON null", `null`, []string{}},
{"unparsable text", `["a",`, []string{}},
{"plain text", `a,b`, []string{}},
{"a JSON object", `{"a":true}`, []string{}},
{"a list of numbers", `[1,2]`, []string{}},
} {
t.Run(tc.name, func(t *testing.T) {
data := map[string]any{"keywords": tc.stored, "words": "a,b", "name": "kept"}
cabana.HydrateTaglistFields([]cabana.FormField{array, comma, {Name: "name", Type: "text"}}, data)
got, ok := data["keywords"].([]string)
if !ok || got == nil {
t.Fatalf("hydrated %#v, want a non-nil []string", data["keywords"])
}
raw, _ := json.Marshal(got)
want, _ := json.Marshal(tc.want)
if string(raw) != string(want) {
t.Fatalf("hydrated %s, want %s", raw, want)
}
if data["words"] != "a,b" || data["name"] != "kept" {
t.Fatalf("other fields changed: %#v", data)
}
})
}
t.Run("a missing key stays missing", func(t *testing.T) {
data := map[string]any{"name": "x"}
cabana.HydrateTaglistFields([]cabana.FormField{array}, data)
if _, ok := data["keywords"]; ok {
t.Fatalf("data = %#v", data)
}
})
t.Run("nil data is accepted", func(t *testing.T) {
cabana.HydrateTaglistFields([]cabana.FormField{array}, nil)
})
}