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

@@ -152,3 +152,96 @@ describe('TaglistField', () => {
expect(wrapper.find('input').exists()).toBe(false)
})
})
// Plan 18.1-06 coverage of the taglist control: markup safety (T-18.1-02),
// keyboard add and remove, suggestions, customTags and string mode.
describe('TaglistField coverage', () => {
it('renders a tag holding a script element as text and runs nothing', async () => {
;(window as unknown as { __pwned?: boolean }).__pwned = false
const tag = '<script>window.__pwned = true</script>'
const wrapper = mountField({ modelValue: [tag, '<img src=x onerror="window.__pwned=true">'] })
await nextTick()
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.find('img').exists()).toBe(false)
expect(chips(wrapper)).toEqual([tag, '<img src=x onerror="window.__pwned=true">'])
expect(wrapper.find('[data-taglist-remove]').attributes('aria-label')).toBe(`Remove ${tag}`)
expect((window as unknown as { __pwned?: boolean }).__pwned).toBe(false)
})
it('removes the last chip on Backspace in an empty input, and nothing while text is typed', async () => {
const wrapper = mountField({ modelValue: ['a', 'b', 'c'] })
await type(wrapper, 'x')
await input(wrapper).trigger('keydown', { key: 'Backspace' })
expect(emitted(wrapper)).toEqual([])
await type(wrapper, '')
await input(wrapper).trigger('keydown', { key: 'Backspace' })
expect(emitted(wrapper)).toEqual([['a', 'b']])
expect(chips(wrapper)).toEqual(['a', 'b'])
await input(wrapper).trigger('keydown', { key: 'Backspace' })
await input(wrapper).trigger('keydown', { key: 'Backspace' })
await input(wrapper).trigger('keydown', { key: 'Backspace' })
expect(emitted(wrapper)).toEqual([['a', 'b'], ['a'], []])
expect(chips(wrapper)).toEqual([])
})
it('adds the draft on blur', async () => {
const wrapper = mountField({ modelValue: ['a'] })
await type(wrapper, ' b ')
await input(wrapper).trigger('blur')
expect(emitted(wrapper)).toEqual([['a', 'b']])
expect(input(wrapper).element.value).toBe('')
})
it('adds every complete tag of a pasted list and keeps the unfinished rest as the draft', async () => {
const wrapper = mountField({ modelValue: [] })
await type(wrapper, 'a, b,,a,c')
expect(emitted(wrapper)).toEqual([['a'], ['a', 'b']])
expect(input(wrapper).element.value).toBe('c')
await input(wrapper).trigger('keydown', { key: 'Enter' })
expect(emitted(wrapper).at(-1)).toEqual(['a', 'b', 'c'])
})
it('offers the options as datalist suggestions bound to the input, and none without options', () => {
const wrapper = mountField({ modelValue: [] })
const list = wrapper.find('datalist')
expect(list.attributes('id')).toBe('f-tags-options')
expect(input(wrapper).attributes('list')).toBe('f-tags-options')
expect(list.findAll('option').map((o) => o.attributes('value'))).toEqual(['alpha', 'beta'])
const bare = mountField({ modelValue: [], field: { options: [] } })
expect(bare.find('datalist').exists()).toBe(false)
expect(bare.find('input').attributes('list')).toBeUndefined()
})
it('with customTags false ignores a free tag typed with the separator but adds a suggested one', async () => {
const wrapper = mountField({ modelValue: ['alpha'], field: { customTags: false } })
await type(wrapper, 'gamma,')
expect(emitted(wrapper)).toEqual([])
expect(chips(wrapper)).toEqual(['alpha'])
await type(wrapper, 'beta,')
expect(emitted(wrapper)).toEqual([['alpha', 'beta']])
})
it('splits a comma string-mode value, trims and drops duplicates, and emits the joined string', async () => {
const wrapper = mountField({ modelValue: ' a , ,b,a', field: { mode: 'string', separator: 'comma', options: [] } })
expect(chips(wrapper)).toEqual(['a', 'b'])
await type(wrapper, 'c,d,')
expect(emitted(wrapper)).toEqual(['a,b,c', 'a,b,c,d'])
await wrapper.find('[data-taglist-remove]').trigger('click')
expect(emitted(wrapper).at(-1)).toBe('b,c,d')
await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click')
await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click')
await wrapper.findAll('[data-taglist-remove]').at(-1)!.trigger('click')
expect(emitted(wrapper).at(-1)).toBe('')
})
it('reads a string as no tags in array mode', () => {
const wrapper = mountField({ modelValue: 'a,b' })
expect(chips(wrapper)).toEqual([])
})
it('marks the input required and invalid for the form', () => {
const wrapper = mountField({ modelValue: [], invalid: true, field: { required: true } })
expect(input(wrapper).attributes('aria-required')).toBe('true')
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
})
})

View File

@@ -533,6 +533,98 @@ describe('ReorderView flat', () => {
})
})
it('moves by ArrowUp and ArrowDown on the handles, keeps focus and announces, then sends one save with null parents', async () => {
let body: unknown = null
const { wrapper, calls } = await mountApp(
'/acme/companions/links/reorder',
{
[`GET ${REORDER}`]: flatTree(),
[`POST ${REORDER}`]: async (request) => {
body = await request.json()
return flatTree()
},
},
{ attach: true },
)
// ArrowUp on the first row and ArrowDown on the last change nothing.
keydown(handle(wrapper, 1).element, 'ArrowUp')
keydown(handle(wrapper, 3).element, 'ArrowDown')
await flushPromises()
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
expect(wrapper.find('[data-reorder-node="1"] [data-action="reorder-up"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-reorder-node="3"] [data-action="reorder-down"]').attributes('disabled')).toBeDefined()
const down = keydown(handle(wrapper, 1).element, 'ArrowDown')
expect(down.defaultPrevented).toBe(true)
await flushPromises()
expect(shape(wrapper)).toBe('Beta Alpha Gamma')
expect(document.activeElement).toBe(handle(wrapper, 1).element)
await vi.waitFor(() => {
expect(wrapper.find('[data-reorder-live]').text()).toBe('Przeniesiono Alpha na pozycję 2 z 3.')
})
const up = keydown(handle(wrapper, 3).element, 'ArrowUp')
expect(up.defaultPrevented).toBe(true)
await flushPromises()
expect(shape(wrapper)).toBe('Beta Gamma Alpha')
expect(document.activeElement).toBe(handle(wrapper, 3).element)
// ArrowRight and ArrowLeft are not taken from the browser on a flat list.
expect(keydown(handle(wrapper, 3).element, 'ArrowRight').defaultPrevented).toBe(false)
expect(keydown(handle(wrapper, 3).element, 'ArrowLeft').defaultPrevented).toBe(false)
await vi.waitFor(() => {
expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
})
expect(body).toEqual({
partition: null,
nodes: [
{ id: 2, parent_id: null, sort_order: 1 },
{ id: 3, parent_id: null, sort_order: 2 },
{ id: 1, parent_id: null, sort_order: 3 },
],
})
})
it('maps the top third of a row to before, the bottom third to after and the middle to after on a flat list', async () => {
const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: flatTree(), [`POST ${REORDER}`]: flatTree() }, { attach: true })
const box = { top: 0, height: 30, bottom: 30, left: 0, right: 100, width: 100, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
const row = (id: number) => {
const found = wrapper.find(`[data-reorder-node="${id}"]`)
vi.spyOn(found.element, 'getBoundingClientRect').mockReturnValue(box)
return found
}
const lineOf = (id: number) => wrapper.find(`[data-reorder-node="${id}"] [data-reorder-line]`)
// Gamma dragged over the top third of Alpha: a line at the top, dropped before Alpha.
await handle(wrapper, 3).trigger('dragstart')
const alpha = row(1)
await alpha.trigger('dragover', { clientY: 2 })
expect(lineOf(1).classes()).toContain('top-0')
expect(alpha.classes()).not.toContain('outline-dashed')
await alpha.trigger('drop')
await flushPromises()
expect(shape(wrapper)).toBe('Gamma Alpha Beta')
// Gamma dragged over the middle of Beta: never an inside highlight, a line at the bottom, dropped after Beta.
await handle(wrapper, 3).trigger('dragstart')
const beta = row(2)
await beta.trigger('dragover', { clientY: 15 })
expect(lineOf(2).classes()).toContain('bottom-0')
expect(beta.classes()).not.toContain('outline-dashed')
await beta.trigger('drop')
await flushPromises()
expect(shape(wrapper)).toBe('Alpha Beta Gamma')
// Alpha dragged over the bottom third of Gamma lands after it.
await handle(wrapper, 1).trigger('dragstart')
const gamma = row(3)
await gamma.trigger('dragover', { clientY: 28 })
expect(lineOf(3).classes()).toContain('bottom-0')
await gamma.trigger('drop')
await flushPromises()
expect(shape(wrapper)).toBe('Beta Gamma Alpha')
expect(wrapper.findAll('[data-reorder-tree] ul')).toHaveLength(0)
})
it('maps the inside zone to after, keeps sibling moves and flattens with null parents', () => {
expect(flatZone('inside', true)).toBe('after')
expect(flatZone('inside', false)).toBe('inside')

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)
})
}

View File

@@ -61,7 +61,10 @@ PG_LAYER="${PG_LAYER:-$ROOT/../vue-paymentgateway-layer}"
# must pass; a name that skips or does not run fails the stage.
# go stage: Docker-free framework suites (-short).
CABANA_SHORT_TESTS=(TestTaglistFieldCompile)
CABANA_SHORT_TESTS=(
TestTaglistFieldCompile TestTaglistFieldValidate TestTaglistFieldHydrate
TestReorderFlatCompile TestReorderFlatNodes
)
# plugins stage: Docker-free suites (-short), per repository and package.
# An empty list means the package is covered by vet and the -short run only.
@@ -72,7 +75,7 @@ PGSTRIPE_ROOT_TESTS=(TestSDKPin)
PGSTRIPE_SUBS_TESTS=()
# postgres stage: testcontainers suites, per repository and package.
PG_CABANA_TESTS=(TestTaglistFieldSmoke)
PG_CABANA_TESTS=(TestTaglistFieldSmoke TestReorderFlatSmoke TestReorderPostgres)
PG_PGSUB_TESTS=(TestSubscriptionsTracer)
PG_PGSTRIPE_ROOT_TESTS=()
PG_PGSTRIPE_SUBS_TESTS=(TestHostSubscriptionFlow)
@@ -80,17 +83,83 @@ PG_PGSTRIPE_SUBS_TESTS=(TestHostSubscriptionFlow)
# admin stage: vitest spec files and full test names (describe + it).
ADMIN_FILES=(
tests/form/TaglistField.test.ts
tests/views/ReorderView.test.ts
)
ADMIN_REQUIRE=(
"TaglistField renders a loaded list as chips and does not re-emit it untouched"
"TaglistField adds the typed tag on Enter and ignores a duplicate or empty text"
"TaglistField adds a tag on typing a comma"
"TaglistField removes a chip through its labelled remove button"
"TaglistField replaces the chips when the value changes from outside"
"TaglistField renders read-only chips without an input or remove buttons"
"TaglistField renders a tag holding markup as text"
"TaglistField splits and joins a string-mode value on its space separator"
"TaglistField joins a string-mode value with a comma by default"
"TaglistField refuses a tag outside the options when customTags is false"
"TaglistField renders the tags joined as text on the preview screen"
"TaglistField coverage renders a tag holding a script element as text and runs nothing"
"TaglistField coverage removes the last chip on Backspace in an empty input, and nothing while text is typed"
"TaglistField coverage adds the draft on blur"
"TaglistField coverage adds every complete tag of a pasted list and keeps the unfinished rest as the draft"
"TaglistField coverage offers the options as datalist suggestions bound to the input, and none without options"
"TaglistField coverage with customTags false ignores a free tag typed with the separator but adds a suggested one"
"TaglistField coverage splits a comma string-mode value, trims and drops duplicates, and emits the joined string"
"TaglistField coverage reads a string as no tags in array mode"
"TaglistField coverage marks the input required and invalid for the form"
"ReorderView flat offers no indent or outdent, ignores ArrowRight, drops a middle drag after the row and sends parent_id null"
"ReorderView flat moves by ArrowUp and ArrowDown on the handles, keeps focus and announces, then sends one save with null parents"
"ReorderView flat maps the top third of a row to before, the bottom third to after and the middle to after on a flat list"
"ReorderView flat maps the inside zone to after, keeps sibling moves and flattens with null parents"
)
ADMIN_REQUIRE=()
# layers stage: test files (must exist and match the test script's glob), TAP
# test names, and the minimum number of passing tests named "subscriptions: ...".
LAYER_FILES=(
tests/subscriptions.test.ts
tests/subscribe.test.ts
tests/subscriptionComponents.test.ts
tests/i18n.test.ts
)
LAYER_REQUIRE=(
'subscriptions: pl and en carry the same pg.subscriptions keys and placeholders'
'subscriptions: every status and interval is labelled and every pg.subscriptions key used in app/ exists'
'subscriptions: subscribe assigns checkout_url once and stays busy'
'subscriptions: a second click while the checkout runs sends no request'
'subscriptions: each refused status maps to its key and enables the button again'
'subscriptions: an answer without an http(s) URL is never followed'
'subscriptions: PgPlans lists the plans with a working PgSubscribeButton per paid plan'
'subscriptions: subscribe without a currency sends none, and a retry after a refusal clears the error'
'subscriptions: PgSubscribeButton and PgManageSubscriptionButton render enabled with their copy and fetch nothing before a click'
'subscriptions: PgPlans renders two plans with exact prices and features, and no subscribe button for a free plan'
'subscriptions: PgPlans keeps the API order, filters by planCodes and falls back to the first price'
'subscriptions: a plan text with markup renders as text'
'subscriptions: PgPlans shows skeleton cards, the load error with Retry, and the empty copy'
'subscriptions: PgMySubscription without a subscription shows the none copy and the plans link'
'subscriptions: an active subscription shows the status, period end, days until renewal, access, Manage and Cancel'
'subscriptions: a scheduled cancel shows the will-cancel notice with the date and no Cancel button'
'subscriptions: a past_due subscription shows the grace notice and the warning tone, without Cancel'
'subscriptions: PgMySubscription shows the login copy on a 401 and the load error with Retry otherwise'
'subscriptions: useSubscription turns the 404 into null and keeps other failures'
'subscriptions: the manage flow assigns portal_url once and maps a 404 to no_billing_account'
'subscriptions: the cancel flow resolves the updated subscription, or null with the mapped error'
'subscriptions: the plans currency prop picks the matching price in any case, and no currency shows the first'
'subscriptions: the confirmed none answer (404 Subscription not found) renders the no-subscription state'
'subscriptions: the cancel button shows only for an active or trialing subscription with no cancel scheduled'
'subscriptions: the manage flow maps 401 to the login key and 502 to the provider key on open and cancel'
'subscriptions: a second cancel while one runs sends nothing, and a cancel after a portal redirect is refused'
'subscriptions: plans GETs /plans and unwraps {data}; a body without a list is empty'
'subscriptions: every route hits its path with its method and body'
'subscriptions: the client never sends a redirect URL, a price, a subscriber or a subscription id'
'subscriptions: hasNoSubscription is a 404 only'
'subscriptions: planPrice picks the currency, else the first price; status and interval keys'
'subscriptions: on the server the composable uses the internal base, forwards the visitor and sends the bearer'
'subscriptions: in the browser the composable uses the public base and the bearer token only'
'subscriptions: without a token from the hook no authorization header is sent'
'subscriptions: a refused request rejects with the fetcher error unchanged on every route'
'subscriptions: usePlans reads GET /plans under the key pg:subscriptions:plans'
)
LAYER_REQUIRE=()
LAYER_PREFIX='subscriptions:'
LAYER_PREFIX_MIN=6
LAYER_PREFIX_MIN=36
usage() {
cat >&2 <<'EOF'