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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user