From 91593da1f7c8a5b632998c24acbfecd28be5460f Mon Sep 17 00:00:00 2001
From: Jakub Zych
Date: Mon, 12 Oct 2026 19:29:37 +0200
Subject: [PATCH] test(17-06): framework and admin SPA unit tests for the blog
companions
- signpost: activation refusals, host overrides, D-02 pattern layers
incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
richeditor keys and notices, reorder and singleton boot checks
(Docker-free); repeater save, dependent recheck, richeditor save,
reorder and singleton routes on Postgres; conformance cases for the
documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
failure; repeater bounds, remove and reorder, collapsed rows;
dependent dropdown states and last request wins; reorder
announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
---
admin/tests/form/DependentDropdown.test.ts | 107 +++++
admin/tests/form/RepeaterField.test.ts | 177 ++++++++-
admin/tests/form/RichEditorField.test.ts | 172 +++++++-
admin/tests/form/SingletonForm.test.ts | 126 ++++++
admin/tests/views/ReorderView.test.ts | 155 +++++++-
modules/cabana/companions_postgres_test.go | 435 +++++++++++++++++++++
modules/cabana/dependent_test.go | 222 +++++++++++
modules/cabana/openapi_conformance_test.go | 99 +++++
modules/cabana/reorder_test.go | 265 +++++++++++++
modules/cabana/repeater_test.go | 426 ++++++++++++++++++++
modules/cabana/richeditor_test.go | 136 +++++++
modules/cabana/singleton_test.go | 113 ++++++
modules/pact/menu_item_types_test.go | 117 ++++++
modules/party/registry_test.go | 94 ++++-
modules/seaglass/seaglass_test.go | 160 ++++++++
modules/signpost/signpost_test.go | 290 ++++++++++++++
scripts/check-phase17.sh | 59 ++-
17 files changed, 3136 insertions(+), 17 deletions(-)
create mode 100644 admin/tests/form/SingletonForm.test.ts
create mode 100644 modules/cabana/companions_postgres_test.go
create mode 100644 modules/cabana/dependent_test.go
create mode 100644 modules/cabana/reorder_test.go
create mode 100644 modules/cabana/repeater_test.go
create mode 100644 modules/cabana/richeditor_test.go
create mode 100644 modules/cabana/singleton_test.go
create mode 100644 modules/pact/menu_item_types_test.go
diff --git a/admin/tests/form/DependentDropdown.test.ts b/admin/tests/form/DependentDropdown.test.ts
index 3be9aff..6922707 100644
--- a/admin/tests/form/DependentDropdown.test.ts
+++ b/admin/tests/form/DependentDropdown.test.ts
@@ -126,3 +126,110 @@ describe('DependentDropdown', () => {
expect(wrapper.find('#field-reference').text()).toContain('Hello world')
})
})
+
+// Plan 06 coverage: every UI-SPEC state, last request wins, stale stored
+// values, the save recheck message and a dropdown inside a repeater row.
+function deferred() {
+ let resolve: (reply: Reply) => void = () => undefined
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+describe('DependentDropdown states', () => {
+ it('shows loading while the options load, then the empty state naming the type', async () => {
+ const pending = deferred()
+ const { wrapper } = await mountApp(
+ '/acme/companions/links/1',
+ routes('category', null, { [`GET ${REFERENCES}`]: () => pending.promise }),
+ { attach: true },
+ )
+ const select = () => wrapper.find('#field-reference')
+ expect(wrapper.find('[data-field="reference"] [data-dependent]').attributes('data-dependent')).toBe('loading')
+ expect(select().attributes('disabled')).toBeDefined()
+ expect(select().attributes('aria-busy')).toBe('true')
+ expect(select().text()).toBe('Wczytywanie…')
+
+ pending.resolve({ body: { data: { applies: true, options: [] }, meta: {} } })
+ await flushPromises()
+ expect(select().attributes('aria-busy')).toBeUndefined()
+ expect(select().attributes('disabled')).toBeDefined()
+ const hint = wrapper.find('[data-dependent-hint]')
+ expect(hint.text()).toBe('Brak rekordów typu „Category”. Najpierw dodaj rekord albo wybierz inny typ.')
+ expect(select().attributes('aria-describedby')).toContain(hint.attributes('id'))
+ })
+
+ it('drops a late answer for an old sibling value: the last request wins', async () => {
+ const slow = deferred()
+ const { wrapper, calls } = await mountApp(
+ '/acme/companions/links/1',
+ routes('post', '7', {
+ [`GET ${REFERENCES}`]: (request) => (queryOf(request).get('values[type]') === 'category' ? slow.promise : references(request)),
+ }),
+ { attach: true },
+ )
+ const select = () => wrapper.find('#field-reference')
+ await wrapper.find('#field-type').setValue('2') // category: pending
+ await flushPromises()
+ expect(select().text()).toBe('Wczytywanie…')
+ await wrapper.find('#field-type').setValue('0') // url: answered at once
+ await flushPromises()
+ expect(select().text()).toBe('Nie dotyczy tego wyboru')
+
+ slow.resolve({ body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } })
+ await flushPromises()
+ expect(select().text()).toBe('Nie dotyczy tego wyboru')
+ // The dropped answer was not cached: choosing category again asks again.
+ await wrapper.find('#field-type').setValue('2')
+ await flushPromises()
+ expect(requestsTo(calls, 'GET', REFERENCES).filter((request) => queryOf(request).get('values[type]') === 'category')).toHaveLength(2)
+ })
+
+ it('shows the placeholder for a stored value no option offers, without clearing it', async () => {
+ let body: Record | null = null
+ const { wrapper } = await mountApp(
+ '/acme/companions/links/1',
+ routes('post', '99', {
+ [`PUT ${RECORD}`]: async (request) => {
+ body = (await request.json()) as Record
+ return {
+ status: 422,
+ body: { error: { code: 'validation_failed', message: 'Validation failed', details: { reference: ['Wybierz jedną z dostępnych opcji.'] } } },
+ }
+ },
+ }),
+ { attach: true },
+ )
+ const select = wrapper.find('#field-reference')
+ expect(select.element.selectedOptions[0]!.text).toBe('Wybierz…')
+ expect(wrapper.find('[data-form-live]').text()).toBe('')
+ await wrapper.find('[data-action="save"]').trigger('click')
+ await flushPromises()
+ expect(body).toMatchObject({ type: 'post', reference: '99' })
+ expect(wrapper.find('[data-field="reference"]').text()).toContain('Wybierz jedną z dostępnych opcji.')
+ expect(wrapper.find('#field-reference').attributes('aria-invalid')).toBe('true')
+ })
+
+ it('a dropdown inside a repeater row asks with the row path and its row sibling', async () => {
+ const TARGETS = `${BASE}/fields/rows.target/dependent-options`
+ const { wrapper, calls } = await mountApp(
+ '/acme/companions/links/1',
+ routes('url', null, {
+ [`GET ${RECORD}`]: {
+ body: { data: { id: 1, title: 'Docs', type: 'url', reference: null, rows: [{ question: 'Q', kind: 'post', target: '8', visible: true }] }, meta: {} },
+ },
+ [`GET ${TARGETS}`]: (request) =>
+ queryOf(request).get('values[kind]') === 'post'
+ ? { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }, { value: '8', label: 'Nested post', depth: 1 }] }, meta: {} } }
+ : { status: 500, body: {} },
+ }),
+ { attach: true },
+ )
+ const asked = requestsTo(calls, 'GET', TARGETS)
+ expect(asked).toHaveLength(1)
+ expect(queryOf(asked[0]!).get('values[kind]')).toBe('post')
+ const target = wrapper.find('[data-repeater-row="0"] [data-field="target"] select')
+ expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post')
+ })
+})
diff --git a/admin/tests/form/RepeaterField.test.ts b/admin/tests/form/RepeaterField.test.ts
index 19f284d..6c6d81f 100644
--- a/admin/tests/form/RepeaterField.test.ts
+++ b/admin/tests/form/RepeaterField.test.ts
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
+import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { ref } from 'vue'
+import type { AdminRecord, FormField } from '../../src/api/types'
+// The registry first: it imports RepeaterField, whose FormGrid imports it back.
+import '../../src/components/form/registry'
+import RepeaterField from '../../src/components/form/fields/RepeaterField.vue'
+import { FORM_FEEDBACK, type FormFeedback } from '../../src/components/form/formContext'
import { companionsFormSchemaFixture } from '../fixtures/typed'
import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers'
@@ -164,3 +170,172 @@ describe('RepeaterField', () => {
expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question')
})
})
+
+// Plan 06 coverage on the field alone, with a recording form feedback:
+// row bounds, remove and reorder semantics, collapsed rows, read-only.
+const field: FormField = {
+ name: 'rows',
+ label: 'Rows',
+ type: 'repeater',
+ span: 'full',
+ titleFrom: 'title',
+ minItems: 1,
+ maxItems: 3,
+ form: {
+ fields: [
+ { name: 'title', type: 'text', label: 'Title', span: 'left' },
+ { name: 'body', type: 'richeditor', label: 'Body', span: 'full' },
+ { name: 'on', type: 'checkbox', label: 'On', span: 'right', default: true },
+ ],
+ },
+} as FormField
+
+function repeater(rows: AdminRecord[], options: { confirm?: boolean; readOnly?: boolean } = {}) {
+ const announced: string[] = []
+ const asked: string[] = []
+ const feedback: FormFeedback = {
+ errors: ref({}),
+ notices: ref({}),
+ clearError: () => undefined,
+ announce: (text) => announced.push(text),
+ confirm: async (request) => {
+ asked.push(request.message)
+ return options.confirm ?? true
+ },
+ }
+ const wrapper: VueWrapper = mount(RepeaterField, {
+ props: {
+ field: options.readOnly ? { ...field, readOnly: true } : field,
+ modelValue: rows,
+ controlId: 'field-rows',
+ 'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }),
+ },
+ global: { provide: { [FORM_FEEDBACK as symbol]: feedback } },
+ attachTo: document.body,
+ })
+ const values = () => (wrapper.props() as { modelValue: AdminRecord[] }).modelValue
+ return { wrapper, announced, asked, values }
+}
+
+const rowsOf = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater-title]').map((node) => node.text())
+const row = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater-row="${index}"]`)
+
+describe('RepeaterField rows', () => {
+ it('enforces minItems and maxItems on remove, add and the counter', async () => {
+ const { wrapper, values } = repeater([{ title: 'Only', body: null, on: true }])
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 1 z 3')
+ expect(wrapper.find('[data-action="repeater-toggle-all"]').exists()).toBe(false)
+
+ await wrapper.find('[data-action="repeater-add"]').trigger('click')
+ await wrapper.find('[data-action="repeater-add"]').trigger('click')
+ await flushPromises()
+ expect(values()).toEqual([
+ { title: 'Only', body: null, on: true },
+ { title: null, body: null, on: true },
+ { title: null, body: null, on: true },
+ ])
+ expect(rowsOf(wrapper)).toEqual(['Only', 'Pozycja 2', 'Pozycja 3'])
+ expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined()
+ expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3')
+ // A fourth add does nothing.
+ await wrapper.find('[data-action="repeater-add"]').trigger('click')
+ expect(values()).toHaveLength(3)
+ expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeUndefined()
+ })
+
+ it('asks before removing a row with a value, never for an empty or default row, and keeps it on cancel', async () => {
+ const { wrapper, asked, values } = repeater(
+ [
+ { title: 'Kept', body: null, on: true },
+ { title: '', body: '', on: true },
+ { title: null, body: 'text
', on: true },
+ ],
+ { confirm: false },
+ )
+ // Only defaults and empty values: removed at once.
+ await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
+ await flushPromises()
+ expect(asked).toEqual([])
+ expect(values()).toHaveLength(2)
+ // A rich text value: asked, and cancel keeps the row.
+ await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
+ await flushPromises()
+ expect(asked).toEqual(['Usunąć „Pozycja 2”? Zmiana zostanie zapisana razem z rekordem.'])
+ expect(values()).toHaveLength(2)
+ expect(rowsOf(wrapper)).toEqual(['Kept', 'Pozycja 2'])
+ // Removing moves focus to the handle that takes the row's place.
+ const confirmed = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }], { confirm: true })
+ await row(confirmed.wrapper, 1).find('[data-action="repeater-remove"]').trigger('click')
+ await flushPromises()
+ expect(confirmed.asked).toHaveLength(1)
+ expect(rowsOf(confirmed.wrapper)).toEqual(['A', 'C'])
+ expect(document.activeElement).toBe(row(confirmed.wrapper, 1).find('[data-repeater-handle]').element)
+ })
+
+ it('reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends', async () => {
+ const { wrapper, announced, values } = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
+ expect(row(wrapper, 0).find('[data-action="repeater-up"]').attributes('disabled')).toBeDefined()
+ expect(row(wrapper, 2).find('[data-action="repeater-down"]').attributes('disabled')).toBeDefined()
+
+ keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowUp')
+ await flushPromises()
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+
+ const down = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowDown')
+ await flushPromises()
+ expect(down.defaultPrevented).toBe(true)
+ expect(rowsOf(wrapper)).toEqual(['B', 'A', 'C'])
+ expect(document.activeElement).toBe(row(wrapper, 1).find('[data-repeater-handle]').element)
+ expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.'])
+
+ await row(wrapper, 2).find('[data-action="repeater-up"]').trigger('click')
+ await flushPromises()
+ expect(rowsOf(wrapper)).toEqual(['B', 'C', 'A'])
+ await row(wrapper, 0).find('[data-action="repeater-down"]').trigger('click')
+ await flushPromises()
+ expect(rowsOf(wrapper)).toEqual(['C', 'B', 'A'])
+ expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.', 'Przeniesiono C na pozycję 2 z 3.', 'Przeniesiono B na pozycję 2 z 3.'])
+ expect(values().map((item) => item.title)).toEqual(['C', 'B', 'A'])
+ // Other keys do nothing.
+ const other = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowRight')
+ expect(other.defaultPrevented).toBe(false)
+ })
+
+ it('collapsed rows do not mount their richeditors and keep their values', async () => {
+ const { wrapper, values } = repeater([
+ { title: 'A', body: 'one
' },
+ { title: 'B', body: 'two
' },
+ ])
+ const editors = () => wrapper.findAll('[data-field="body"]').length
+ expect(editors()).toBe(2)
+ await row(wrapper, 0).find('[data-action="repeater-toggle"]').trigger('click')
+ expect(row(wrapper, 0).find('[data-repeater-body]').exists()).toBe(false)
+ expect(row(wrapper, 0).find('[data-action="repeater-toggle"]').attributes('aria-expanded')).toBe('false')
+ expect(editors()).toBe(1)
+
+ const all = wrapper.find('[data-action="repeater-toggle-all"]')
+ expect(all.text()).toBe('Zwiń wszystkie')
+ await all.trigger('click')
+ expect(editors()).toBe(0)
+ expect(wrapper.find('[data-action="repeater-toggle-all"]').text()).toBe('Rozwiń wszystkie')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ expect(values()).toEqual([
+ { title: 'A', body: 'one
' },
+ { title: 'B', body: 'two
' },
+ ])
+
+ await wrapper.find('[data-action="repeater-toggle-all"]').trigger('click')
+ expect(editors()).toBe(2)
+ expect(row(wrapper, 1).find('[data-action="repeater-toggle"]').attributes('aria-controls')).toBe(row(wrapper, 1).find('[data-repeater-body]').attributes('id'))
+ })
+
+ it('read-only shows every row expanded with no controls', () => {
+ const { wrapper } = repeater([{ title: 'A', body: 'one
' }], { readOnly: true })
+ expect(wrapper.find('[data-repeater-handle]').exists()).toBe(false)
+ expect(wrapper.find('[data-action="repeater-add"]').exists()).toBe(false)
+ expect(wrapper.find('[data-action="repeater-remove"]').exists()).toBe(false)
+ expect(wrapper.find('[data-repeater-body]').exists()).toBe(true)
+ })
+})
diff --git a/admin/tests/form/RichEditorField.test.ts b/admin/tests/form/RichEditorField.test.ts
index 0920e63..53749ec 100644
--- a/admin/tests/form/RichEditorField.test.ts
+++ b/admin/tests/form/RichEditorField.test.ts
@@ -4,7 +4,7 @@ import type { FormField } from '../../src/api/types'
import RichEditorField from '../../src/components/form/fields/RichEditorField.vue'
import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html'
import { isLossy } from '../../src/components/form/richeditor/lossy'
-import { companionsFormSchemaFixture } from '../fixtures/typed'
+import { companionsFormSchemaFixture, langFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
// Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06
@@ -188,3 +188,173 @@ describe('richeditor policy helpers', () => {
expect(displayHtml('')).toBe('a
')
})
})
+
+// Plan 06 coverage: the value contract, the link popover, the notices
+// callout and the chunk-failure fallback (UI-SPEC RichEditorField).
+describe('RichEditorField value contract', () => {
+ it('an empty paragraph is the empty string, any content its HTML', async () => {
+ const { createRichEditor, editorValue, loadContent } = await import('../../src/components/form/richeditor/editor')
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+ let changes = 0
+ const editor = createRichEditor({ element, content: '', attributes: {}, onChange: () => changes++, onTransaction: () => undefined })
+ try {
+ expect(editorValue(editor)).toBe('')
+ loadContent(editor, 'Hello
')
+ expect(changes).toBe(0)
+ expect(editorValue(editor)).toBe('Hello
')
+ editor.commands.clearContent(true)
+ expect(changes).toBe(1)
+ expect(editorValue(editor)).toBe('')
+ editor.commands.setContent('Head
', { emitUpdate: true })
+ expect(editorValue(editor)).toBe('Head
')
+ // Headings other than level 3 and the dropped nodes do not survive the editor.
+ loadContent(editor, 'Big
x
')
+ expect(editorValue(editor)).not.toContain(' {
+ const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
+ const wrapper = mount(RichEditorField, { props: { field, modelValue: '
One
', controlId: 'f-body' }, attachTo: document.body })
+ await editorReady(wrapper)
+ await wrapper.setProps({ modelValue: 'Two b
' })
+ await flushPromises()
+ expect(wrapper.find('[data-richeditor-area]').text()).toBe('Two b')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ // An empty value shows the bare editing area and emits nothing.
+ await wrapper.setProps({ modelValue: null })
+ await flushPromises()
+ expect(wrapper.find('[data-richeditor-area]').text()).toBe('')
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ })
+
+ it('shows the lossy note only for markup outside the editor schema', async () => {
+ const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
+ const clean = mount(RichEditorField, { props: { field, modelValue: 'a b
', controlId: 'f-a' }, attachTo: document.body })
+ await editorReady(clean)
+ expect(clean.find('[data-richeditor-lossy]').exists()).toBe(false)
+ const lossy = mount(RichEditorField, { props: { field, modelValue: 'a
', controlId: 'f-b' }, attachTo: document.body })
+ await editorReady(lossy)
+ expect(lossy.find('[data-richeditor-lossy]').text()).toContain('Części formatowania tego tekstu nie da się pokazać w edytorze.')
+ })
+
+ it('reads empty and read-only values', () => {
+ const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField
+ const empty = mount(RichEditorField, { props: { field, modelValue: '', controlId: 'f-body' } })
+ expect(empty.find('[data-richeditor-readonly]').text()).toBe('—')
+ expect(empty.find('[data-richeditor-loading]').exists()).toBe(false)
+ })
+})
+
+describe('richeditor link popover', () => {
+ async function popover(props: { href?: string; inLink?: boolean } = {}) {
+ const LinkPopover = (await import('../../src/components/form/richeditor/LinkPopover.vue')).default
+ const wrapper = mount(LinkPopover, {
+ props: { open: false, href: props.href ?? '', inLink: props.inLink ?? false, idPrefix: 'f-body' },
+ slots: { default: '' },
+ attachTo: document.body,
+ })
+ await wrapper.setProps({ open: true })
+ await flushPromises()
+ const input = () => document.body.querySelector('#f-body-link-url')!
+ return { wrapper, input }
+ }
+
+ it('refuses a URL outside the allowlist and applies a policy URL on Enter', async () => {
+ const { wrapper, input } = await popover()
+ expect(document.activeElement).toBe(input())
+ for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://example.test/x']) {
+ input().value = bad
+ input().dispatchEvent(new Event('input'))
+ ;(document.body.querySelector('[data-action="richeditor-link-apply"]') as HTMLElement).click()
+ await flushPromises()
+ expect(wrapper.emitted('apply')).toBeUndefined()
+ expect(document.body.querySelector('#f-body-link-error')?.textContent).toContain('Link musi używać http, https, mailto, tel albo adresu względnego.')
+ expect(input().getAttribute('aria-invalid')).toBe('true')
+ }
+ // Typing clears the error; Enter applies the trimmed URL and never submits.
+ input().value = ' https://example.test/a '
+ input().dispatchEvent(new Event('input'))
+ await flushPromises()
+ expect(document.body.querySelector('#f-body-link-error')).toBeNull()
+ const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
+ input().dispatchEvent(enter)
+ await flushPromises()
+ expect(enter.defaultPrevented).toBe(true)
+ expect(wrapper.emitted('apply')).toEqual([['https://example.test/a']])
+ expect(document.body.querySelector('[data-action="richeditor-unlink"]')).toBeNull()
+ })
+
+ it('starts from the current link and offers Remove link inside one', async () => {
+ const { wrapper, input } = await popover({ href: 'mailto:a@example.test', inLink: true })
+ expect(input().value).toBe('mailto:a@example.test')
+ ;(document.body.querySelector('[data-action="richeditor-unlink"]') as HTMLElement).click()
+ await flushPromises()
+ expect(wrapper.emitted('remove')).toHaveLength(1)
+ })
+})
+
+describe('richeditor notices callout', () => {
+ it('lists every notice of the field in one warning callout after a save', async () => {
+ const { wrapper } = await mountApp(
+ '/acme/companions/links/1',
+ routes({
+ [`PUT ${RECORD}`]: {
+ body: {
+ data: { id: 1, title: 'Docs', description: 'x
' },
+ meta: {
+ notices: [
+ { field: 'description', message: 'Usunięto 2 elementy: script, iframe' },
+ { field: 'description', message: 'Usunięto 1 atrybut: onclick' },
+ { field: 'title', message: 'other field' },
+ ],
+ },
+ },
+ },
+ }),
+ { attach: true },
+ )
+ await editorReady(wrapper)
+ await wrapper.find('[data-action="save"]').trigger('click')
+ await flushPromises()
+ const callout = wrapper.find('[data-field="description"] [data-field-notice]')
+ expect(callout.attributes('role')).toBe('status')
+ expect(callout.classes()).toContain('summer-callout--warning')
+ expect(callout.findAll('p').map((line) => line.text())).toEqual(['Usunięto 2 elementy: script, iframe', 'Usunięto 1 atrybut: onclick'])
+ })
+})
+
+// Last in the file: it replaces the editor chunk for the rest of the run.
+describe('RichEditorField chunk failure', () => {
+ afterEach(() => {
+ vi.doUnmock('../../src/components/form/richeditor/editor')
+ })
+
+ it('falls back to the HTML view with the load_failed note, and saving still works', async () => {
+ vi.resetModules()
+ vi.doMock('../../src/components/form/richeditor/editor', () => {
+ throw new Error('chunk failed')
+ })
+ // The fresh module graph has its own i18n state: load the strings there.
+ ;(await import('../../src/app/i18n')).setBundle(langFixture.data, 'pl')
+ const Field = (await import('../../src/components/form/fields/RichEditorField.vue')).default
+ const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
+ const wrapper = mount(Field, { props: { field, modelValue: 'Stored x
', controlId: 'f-body' }, attachTo: document.body })
+ await vi.waitFor(() => {
+ expect(wrapper.find('[data-richeditor-load-failed]').exists()).toBe(true)
+ })
+ expect(wrapper.find('[data-richeditor-load-failed]').text()).toBe('Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio.')
+ const textarea = wrapper.find('[data-richeditor-source]')
+ expect(textarea.element.value).toBe('Stored x
')
+ expect(textarea.attributes('id')).toBe('f-body')
+ expect(wrapper.find('[role="toolbar"]').exists()).toBe(false)
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
+ await textarea.setValue('Edited
')
+ expect(wrapper.emitted('update:modelValue')).toEqual([['Edited
']])
+ })
+})
diff --git a/admin/tests/form/SingletonForm.test.ts b/admin/tests/form/SingletonForm.test.ts
new file mode 100644
index 0000000..2bc991e
--- /dev/null
+++ b/admin/tests/form/SingletonForm.test.ts
@@ -0,0 +1,126 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
+import { setSettings } from '../../src/state/useSettings'
+import { clone, companionsFormSchemaFixture } from '../fixtures/typed'
+import { API, mountApp, notFound, requestsTo, resetState, type Route } from '../helpers'
+
+// Phase 17 plan 06 specs for the singleton form (D-11, UI-SPEC "Singleton
+// form"): a settings link to a pact.AdminSingleton controller opens its one
+// record through GET …/singleton; the form has no Delete, and back, Cancel
+// and Save and close return to the settings index.
+const PROFILES = `${API}/acme/companions/profiles`
+
+function singletonSchema() {
+ const schema = clone(companionsFormSchemaFixture)
+ schema.data.singleton = true
+ schema.data.fields = schema.data.fields.filter((field) => field.name === 'title')
+ return schema
+}
+
+function routes(overrides: Record = {}): Record {
+ return {
+ [`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } },
+ [`GET ${PROFILES}/schema/form`]: { body: singletonSchema() },
+ [`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } },
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ resetState()
+ setSettings([
+ {
+ code: 'profile',
+ label: 'Site profile',
+ description: 'The one site profile',
+ icon: 'settings',
+ category: 'CMS',
+ keywords: [],
+ model: '',
+ controller: 'acme.companions.profiles',
+ order: 1,
+ },
+ ])
+})
+
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+enableAutoUnmount(afterEach)
+
+describe('SingletonForm', () => {
+ it('opens through /singleton from settings, has no Delete, and Save and close returns to settings', async () => {
+ let body: Record | null = null
+ const { wrapper, router, calls } = await mountApp(
+ '/settings',
+ routes({
+ [`PUT ${PROFILES}/9`]: async (request) => {
+ body = (await request.json()) as Record
+ return { body: { data: { id: 9, ...body }, meta: {} } }
+ },
+ }),
+ { attach: true },
+ )
+ const link = wrapper.find('[data-settings="profile"]')
+ expect(link.attributes('href')).toBe('/acme/companions/profiles')
+ await link.trigger('click', { button: 0 })
+ await flushPromises()
+ expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
+ expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9')
+ expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
+ expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
+ expect(wrapper.find('#field-title').element.value).toBe('Main site')
+
+ // Save stays on the form.
+ await wrapper.find('#field-title').setValue('Renamed')
+ await wrapper.find('[data-action="save"]').trigger('click')
+ await flushPromises()
+ expect(body).toEqual({ title: 'Renamed' })
+ expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9')
+
+ // Save and close returns to the settings index.
+ await wrapper.find('[data-action="save-close"]').trigger('click')
+ await flushPromises()
+ expect(requestsTo(calls, 'PUT', `${PROFILES}/9`)).toHaveLength(2)
+ expect(router.currentRoute.value.path).toBe('/settings')
+ })
+
+ it('back returns to the settings index', async () => {
+ const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes(), { attach: true })
+ expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
+ await wrapper.find('[data-action="back"]').trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.path).toBe('/settings')
+ })
+
+ it('a link to a controller that is not a singleton opens its list', async () => {
+ const { wrapper, router, calls } = await mountApp(
+ '/settings',
+ routes({ [`GET ${PROFILES}/singleton`]: { status: 404, body: notFound } }),
+ { attach: true },
+ )
+ await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 })
+ await flushPromises()
+ expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1)
+ expect(router.currentRoute.value.path).toBe('/acme/companions/profiles')
+ })
+
+ it('a modified click opens the link as it is, without asking for the record', async () => {
+ const { wrapper, router, calls } = await mountApp('/settings', routes(), { attach: true })
+ await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0, ctrlKey: true })
+ await flushPromises()
+ expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(0)
+ expect(router.currentRoute.value.path).toBe('/settings')
+ })
+
+ it('a form without the singleton flag keeps Delete and returns to its list', async () => {
+ const schema = singletonSchema()
+ schema.data.singleton = false
+ const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes({ [`GET ${PROFILES}/schema/form`]: { body: schema } }), { attach: true })
+ expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
+ await wrapper.find('[data-action="back"]').trigger('click')
+ await flushPromises()
+ expect(router.currentRoute.value.path).toBe('/acme/companions/profiles')
+ })
+})
diff --git a/admin/tests/views/ReorderView.test.ts b/admin/tests/views/ReorderView.test.ts
index ce4ff87..9474794 100644
--- a/admin/tests/views/ReorderView.test.ts
+++ b/admin/tests/views/ReorderView.test.ts
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
+import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree'
import { setSettings } from '../../src/state/useSettings'
import { toasts } from '../../src/state/useToasts'
import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
-import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
+import { API, keydown, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
// Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's
// Reorder button and the singleton form (D-11). Plan 06 completes them.
@@ -219,3 +220,155 @@ describe('singleton form', () => {
expect(router.currentRoute.value.path).toBe('/settings')
})
})
+
+// Plan 06 coverage: announcements per keyboard move, children moving with
+// their parent, loading and load failure, the partition switch flushing a
+// pending save, and the tree rules (no drop into the own subtree).
+describe('ReorderView moves', () => {
+ it('announces every keyboard move and moves children with their parent', async () => {
+ let body: { nodes: unknown[] } | null = null
+ const { wrapper, calls } = await mountApp(
+ '/acme/companions/links/reorder',
+ {
+ [`GET ${REORDER}`]: tree(),
+ [`POST ${REORDER}`]: async (request) => {
+ body = (await request.json()) as { nodes: unknown[] }
+ return tree()
+ },
+ },
+ { attach: true },
+ )
+ const live = () => wrapper.find('[data-reorder-live]').text()
+
+ keydown(handle(wrapper, 2).element, 'ArrowUp')
+ await flushPromises()
+ expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)')
+ await vi.waitFor(() => expect(live()).toBe('Przeniesiono Blog na pozycję 1 z 3.'))
+
+ keydown(handle(wrapper, 1).element, 'ArrowRight')
+ await flushPromises()
+ expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)')
+ await vi.waitFor(() => expect(live()).toBe('Home jest teraz wewnątrz Blog.'))
+
+ keydown(handle(wrapper, 3).element, 'ArrowLeft')
+ await flushPromises()
+ expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
+ await vi.waitFor(() => expect(live()).toBe('Przeniesiono News na poziom 1.'))
+ expect(document.activeElement).toBe(handle(wrapper, 3).element)
+
+ // A move that cannot run changes and announces nothing.
+ const before = live()
+ keydown(handle(wrapper, 2).element, 'ArrowUp')
+ await flushPromises()
+ expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)')
+ expect(live()).toBe(before)
+
+ await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1))
+ expect(body!.nodes).toEqual([
+ { id: 2, parent_id: null, sort_order: 1 },
+ { id: 1, parent_id: 2, sort_order: 2 },
+ { id: 3, parent_id: null, sort_order: 3 },
+ { id: 4, parent_id: null, sort_order: 4 },
+ ])
+ })
+
+ it('shows the loading skeleton, then the load failure alert', async () => {
+ let release: (reply: Reply) => void = () => undefined
+ const pending = new Promise((resolve) => {
+ release = resolve
+ })
+ const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: () => pending })
+ expect(wrapper.find('[data-reorder-card]').attributes('aria-busy')).toBe('true')
+ expect(wrapper.findAll('[data-reorder-skeleton]')).toHaveLength(5)
+ release({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } })
+ await flushPromises()
+ expect(wrapper.find('[role="alert"]').text()).toContain('Nie udało się wczytać rekordów.')
+ expect(wrapper.find('[data-reorder-card]').exists()).toBe(false)
+ })
+
+ it('a partition switch sends the pending save first, then loads the chosen partition', async () => {
+ const order: string[] = []
+ const { wrapper, router, calls } = await mountApp(
+ '/acme/companions/links/reorder',
+ {
+ [`GET ${REORDER}`]: (request) => {
+ order.push(`GET ${queryOf(request).get('partition') ?? '-'}`)
+ return tree()
+ },
+ [`POST ${REORDER}`]: async (request) => {
+ const sent = (await request.json()) as { partition: number }
+ order.push(`POST ${sent.partition}`)
+ return tree()
+ },
+ },
+ { attach: true },
+ )
+ keydown(handle(wrapper, 1).element, 'ArrowDown')
+ await flushPromises()
+ await wrapper.find('[data-reorder-partition]').setValue('6')
+ await flushPromises()
+ expect(router.currentRoute.value.query.partition).toBe('6')
+ expect(order).toEqual(['GET -', 'POST 5', 'GET 6'])
+ await wait(500)
+ expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)
+ })
+})
+
+describe('reorder tree rules', () => {
+ // The OpenAPI document types parent_id as a number although the wire
+ // carries null at the top (17-02 deferred item 3), hence the cast.
+ type Wire = Parameters[0]
+ const nodes = [
+ { id: 1, name: 'Home', parent_id: null, sort_order: 2 },
+ { id: 2, name: 'Blog', parent_id: null, sort_order: 1 },
+ { id: 3, name: 'News', parent_id: 2, sort_order: 1 },
+ { id: 4, name: 'Deep', parent_id: 3, sort_order: 1 },
+ { id: 5, name: 'Orphan', parent_id: 99, sort_order: 3 },
+ ] as unknown as Wire
+ const names = (list: TreeNode[]): string => list.map((node) => (node.children.length ? `${node.name}(${names(node.children)})` : node.name)).join(' ')
+
+ it('builds the tree by sort order and puts unknown parents and cycles at the top', () => {
+ expect(names(buildTree(nodes))).toBe('Blog(News(Deep)) Home Orphan')
+ const cycle = buildTree([
+ { id: 1, name: 'A', parent_id: 2, sort_order: 1 },
+ { id: 2, name: 'B', parent_id: 1, sort_order: 2 },
+ { id: 3, name: 'C', parent_id: null, sort_order: 3 },
+ ] as unknown as Wire)
+ expect(names(cycle).split(' ').sort()).toEqual(['A(B)', 'C'].sort())
+ })
+
+ it('refuses a drop into the node itself or its own subtree and leaves the tree as it was', () => {
+ const tree = buildTree(nodes)
+ for (const target of [2, 3, 4]) {
+ expect(canDrop(tree, 2, target)).toBe(false)
+ for (const zone of ['before', 'after', 'inside'] as const) {
+ expect(applyDrop(tree, 2, target, zone)).toBe(false)
+ }
+ }
+ expect(names(tree)).toBe('Blog(News(Deep)) Home Orphan')
+ expect(canDrop(tree, 4, 2)).toBe(true)
+ expect(applyDrop(tree, 4, 1, 'inside')).toBe(true)
+ expect(applyDrop(tree, 5, 2, 'before')).toBe(true)
+ expect(applyDrop(tree, 3, 1, 'after')).toBe(true)
+ expect(names(tree)).toBe('Orphan Blog Home(Deep) News')
+ })
+
+ it('moves only where a sibling or parent exists, and flattens pre-order from 1', () => {
+ const tree = buildTree(nodes)
+ expect(canMove(tree, 2, 'up')).toBe(false)
+ expect(canMove(tree, 2, 'indent')).toBe(false)
+ expect(canMove(tree, 2, 'outdent')).toBe(false)
+ expect(canMove(tree, 5, 'down')).toBe(false)
+ expect(canMove(tree, 99, 'down')).toBe(false)
+ expect(applyMove(tree, 4, 'outdent')).toBe(true)
+ expect(applyMove(tree, 1, 'indent')).toBe(true)
+ expect(names(tree)).toBe('Blog(News Deep Home) Orphan')
+ expect(flatten(tree)).toEqual([
+ { id: 2, parent_id: null, sort_order: 1 },
+ { id: 3, parent_id: 2, sort_order: 2 },
+ { id: 4, parent_id: 2, sort_order: 3 },
+ { id: 1, parent_id: 2, sort_order: 4 },
+ { id: 5, parent_id: null, sort_order: 5 },
+ ])
+ })
+})
diff --git a/modules/cabana/companions_postgres_test.go b/modules/cabana/companions_postgres_test.go
new file mode 100644
index 0000000..d18fc7a
--- /dev/null
+++ b/modules/cabana/companions_postgres_test.go
@@ -0,0 +1,435 @@
+package cabana_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "git.golem15.com/golem15/summercms/modules/backpack"
+ "git.golem15.com/golem15/summercms/modules/cabana"
+ "git.golem15.com/golem15/summercms/modules/compass"
+ "git.golem15.com/golem15/summercms/modules/pact"
+ "git.golem15.com/golem15/summercms/modules/party"
+)
+
+// Phase 17 plan 06 tests of the companion form features through the admin
+// routes on Postgres (the acme.companions fixture of companions_smoke_test.go)
+// and their boot refusals through cabana.Activate (Docker-free).
+
+// companionsOverlay is the acme.companions fixture tree with files replaced;
+// an empty value removes the file.
+func companionsOverlay(t *testing.T, files map[string]string) fstest.MapFS {
+ t.Helper()
+ out := fstest.MapFS{}
+ err := fs.WalkDir(os.DirFS(companionsDir), ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ raw, err := os.ReadFile(filepath.Join(companionsDir, path))
+ if err != nil {
+ return err
+ }
+ out[path] = &fstest.MapFile{Data: raw}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for path, data := range files {
+ if data == "" {
+ delete(out, path)
+ continue
+ }
+ out[path] = &fstest.MapFile{Data: []byte(data)}
+ }
+ return out
+}
+
+// companionsFile is one file of the fixture tree.
+func companionsFile(t *testing.T, path string) string {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join(companionsDir, path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
+
+// companionsFSPlugin is the acme.companions fixture served from another
+// tree, optionally with other controllers or settings.
+type companionsFSPlugin struct {
+ companionsPlugin
+ fsys fs.FS
+ controllers []pact.AdminController
+ settings []pact.SettingsItem
+}
+
+func (p companionsFSPlugin) AdminFS() fs.FS { return p.fsys }
+func (p companionsFSPlugin) AdminControllers() []pact.AdminController {
+ if p.controllers != nil {
+ return p.controllers
+ }
+ return p.companionsPlugin.AdminControllers()
+}
+func (p companionsFSPlugin) Settings() []pact.SettingsItem {
+ if p.settings != nil {
+ return p.settings
+ }
+ return p.companionsPlugin.Settings()
+}
+
+// activateCompanions boots the admin of p without a database.
+func activateCompanions(t *testing.T, p party.Plugin) error {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions-boot\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
+ return err
+}
+
+// companionsLinkRows reads every link row by id.
+func companionsLinkRows(t *testing.T, e *conformEnv) map[uint]companionsLink {
+ t.Helper()
+ var rows []companionsLink
+ if err := e.db.Order("id").Find(&rows).Error; err != nil {
+ t.Fatal(err)
+ }
+ out := make(map[uint]companionsLink, len(rows))
+ for _, row := range rows {
+ out[row.ID] = row
+ }
+ return out
+}
+
+// sameLinkRows fails unless the link table is exactly before.
+func sameLinkRows(t *testing.T, e *conformEnv, before map[uint]companionsLink, what string) {
+ t.Helper()
+ a, err := json.Marshal(companionsLinkRows(t, e))
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := json.Marshal(before)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(a) != string(b) {
+ t.Fatalf("%s wrote rows:\nbefore %s\nafter %s", what, b, a)
+ }
+}
+
+// wantStatus fails unless rec answered status, and returns the body.
+func wantStatus(t *testing.T, what string, rec interface {
+ Result() *http.Response
+}, status int, body string) {
+ t.Helper()
+ if code := rec.Result().StatusCode; code != status {
+ t.Fatalf("%s: status=%d want %d body=%s", what, code, status, body)
+ }
+}
+
+// companionsError decodes the code and details of an error envelope.
+func companionsError(t *testing.T, raw []byte) (string, map[string][]string) {
+ t.Helper()
+ var body struct {
+ Error struct {
+ Code string `json:"code"`
+ Details map[string][]string `json:"details"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(raw, &body); err != nil {
+ t.Fatalf("error body %s: %v", raw, err)
+ }
+ return body.Error.Code, body.Error.Details
+}
+
+// TestRepeaterSave pins the D-07 save through the links routes: rows keep
+// only their nested keys with scalars coerced, required applies per row
+// with row-path errors, maxItems is enforced, nested richeditors are
+// sanitized with row-path notices, nested dependsOn is rechecked, a refused
+// save writes nothing, and the record response and show hydrate the rows.
+func TestRepeaterSave(t *testing.T) {
+ e := newCompanionsEnv(t)
+ rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "rows": []any{
+ map[string]any{"title": 5, "body": "clean
", "type": "acme-page", "reference": 2, "extra": "dropped"},
+ map[string]any{"title": true, "body": `dirty
`, "type": "acme-list", "reference": "1"},
+ map[string]any{"title": "third"},
+ }}, true)
+ wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String())
+ created := decodeCompanionsRecord(t, rec.Body.Bytes())
+ id := dataID(t, rec.Body.Bytes())
+ var stored companionsLink
+ if err := e.db.First(&stored, id).Error; err != nil {
+ t.Fatal(err)
+ }
+ rows := stored.Rows.Get()
+ want := []map[string]any{
+ {"title": "5", "body": "clean
", "type": "acme-page", "reference": "2"},
+ {"title": "true", "body": "dirty
", "type": "acme-list", "reference": nil},
+ {"title": "third", "body": nil, "type": nil, "reference": nil},
+ }
+ if fmt.Sprint(rows) != fmt.Sprint(want) {
+ t.Fatalf("stored rows = %v\nwant %v", rows, want)
+ }
+ var noticed []string
+ for _, n := range created.Meta.Notices {
+ noticed = append(noticed, n.Field+": "+n.Message)
+ }
+ if len(noticed) != 2 || !strings.HasPrefix(noticed[0], "rows.1.body: Usunięto 1 atrybut: onclick") || !strings.HasPrefix(noticed[1], "rows.1.body: Usunięto 1 komentarz HTML") {
+ t.Fatalf("notices = %v", noticed)
+ }
+ if list, ok := created.Data["rows"].([]any); !ok || len(list) != 3 {
+ t.Fatalf("response rows = %#v", created.Data["rows"])
+ }
+
+ path := fmt.Sprintf("/acme/companions/links/%d", id)
+ before := companionsLinkRows(t, e)
+ for _, tc := range []struct {
+ name string
+ rows any
+ keys []string
+ }{
+ {"per-row required", []any{map[string]any{"title": "ok"}, map[string]any{"body": "x
"}, map[string]any{"title": " "}}, []string{"rows.1.title", "rows.2.title"}},
+ {"nested dependent value", []any{map[string]any{"title": "a", "type": "acme-page", "reference": "99"}}, []string{"rows.0.reference"}},
+ {"coercion", []any{map[string]any{"title": map[string]any{"x": 1}}}, []string{"rows.0.title"}},
+ {"not a list", "rows", []string{"rows"}},
+ {"a row that is not an object", []any{"x"}, []string{"rows"}},
+ {"above maxItems", []any{map[string]any{"title": "1"}, map[string]any{"title": "2"}, map[string]any{"title": "3"}, map[string]any{"title": "4"}, map[string]any{"title": "5"}, map[string]any{"title": "6"}}, []string{"rows"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Rows", "rows": tc.rows}, true)
+ wantStatus(t, tc.name, rec, http.StatusUnprocessableEntity, rec.Body.String())
+ _, details := companionsError(t, rec.Body.Bytes())
+ for _, key := range tc.keys {
+ if len(details[key]) != 1 {
+ t.Fatalf("details = %v, want %s", details, key)
+ }
+ }
+ if len(details) != len(tc.keys) {
+ t.Fatalf("details = %v, want exactly %v", details, tc.keys)
+ }
+ if tc.name == "above maxItems" && details["rows"][0] != "Usuń pozycje, aby zostało ich najwyżej 5." {
+ t.Fatalf("max message = %q", details["rows"][0])
+ }
+ if tc.name == "nested dependent value" && details["rows.0.reference"][0] != "Wybierz jedną z dostępnych opcji." {
+ t.Fatalf("dependent message = %q", details["rows.0.reference"][0])
+ }
+ sameLinkRows(t, e, before, tc.name)
+ })
+ }
+
+ t.Run("an update without rows keeps them, null stores none", func(t *testing.T) {
+ rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true)
+ wantStatus(t, "update", rec, http.StatusOK, rec.Body.String())
+ var again companionsLink
+ if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 3 || again.Title != "Renamed" {
+ t.Fatalf("rows after an update without rows = %+v (%v)", again, err)
+ }
+ rec = e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed", "rows": nil}, true)
+ wantStatus(t, "null rows", rec, http.StatusOK, rec.Body.String())
+ if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 0 {
+ t.Fatalf("rows after null = %+v (%v)", again.Rows, err)
+ }
+ shown := e.send(t, http.MethodGet, path, nil, true)
+ if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[]`) {
+ t.Fatalf("show = %d %s", shown.Code, shown.Body.String())
+ }
+ })
+}
+
+// TestDependentRecheck pins D-08 through the routes: the options route per
+// sibling value (tree depths, Applies false, unknown and empty values), its
+// 404s and 401, and the save recheck at the top level, with the stored
+// sibling or value used when only the other is sent; a refused save writes
+// nothing.
+func TestDependentRecheck(t *testing.T) {
+ e := newCompanionsEnv(t)
+ options := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult {
+ t.Helper()
+ rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true)
+ wantStatus(t, field+" "+typ, rec, http.StatusOK, rec.Body.String())
+ var body cabana.Envelope[cabana.AdminDependentOptionsResult]
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ return body.Data
+ }
+ for _, field := range []string{"reference", "rows.reference"} {
+ page := options(t, field, "acme-page")
+ if !page.Applies || fmt.Sprint(page.Options) != "[{1 Home 0} {2 About 1}]" {
+ t.Fatalf("%s acme-page = %+v", field, page)
+ }
+ for _, typ := range []string{"acme-list", "acme-unknown", "url", ""} {
+ if none := options(t, field, typ); none.Applies || none.Options == nil || len(none.Options) != 0 {
+ t.Fatalf("%s %q = %+v", field, typ, none)
+ }
+ }
+ }
+ for _, path := range []string{dependentOptionsPath("title", "acme-page"), dependentOptionsPath("type", "acme-page"), dependentOptionsPath("rows.title", "acme-page"), dependentOptionsPath("missing", "acme-page"), dependentOptionsPath("rows.missing", "acme-page")} {
+ if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusNotFound {
+ t.Fatalf("%s status=%d", path, rec.Code)
+ }
+ }
+ if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("anonymous status=%d", rec.Code)
+ }
+
+ rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, true)
+ wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String())
+ id := dataID(t, rec.Body.Bytes())
+ path := fmt.Sprintf("/acme/companions/links/%d", id)
+ load := func(t *testing.T) companionsLink {
+ t.Helper()
+ var link companionsLink
+ if err := e.db.First(&link, id).Error; err != nil {
+ t.Fatal(err)
+ }
+ return link
+ }
+ str := func(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+ }
+ if link := load(t); str(link.Type) != "acme-page" || str(link.Reference) != "2" {
+ t.Fatalf("created = %+v", link)
+ }
+ refused := func(t *testing.T, body map[string]any) {
+ t.Helper()
+ before := companionsLinkRows(t, e)
+ rec := e.send(t, http.MethodPut, path, body, true)
+ wantStatus(t, "refused", rec, http.StatusUnprocessableEntity, rec.Body.String())
+ code, details := companionsError(t, rec.Body.Bytes())
+ if code != "validation_failed" || len(details) != 1 || len(details["reference"]) != 1 || details["reference"][0] != "Wybierz jedną z dostępnych opcji." {
+ t.Fatalf("details = %s %v", code, details)
+ }
+ sameLinkRows(t, e, before, "a refused dependent save")
+ }
+ refused(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "99"})
+ refused(t, map[string]any{"title": "Dep", "reference": "99"}) // stored type acme-page decides
+ refused(t, map[string]any{"title": "Dep", "reference": 7})
+
+ accepted := func(t *testing.T, body map[string]any, wantType, wantRef string) {
+ t.Helper()
+ rec := e.send(t, http.MethodPut, path, body, true)
+ wantStatus(t, "accepted", rec, http.StatusOK, rec.Body.String())
+ if link := load(t); str(link.Type) != wantType || str(link.Reference) != wantRef {
+ t.Fatalf("after %v: type=%s reference=%s, want %s/%s", body, str(link.Type), str(link.Reference), wantType, wantRef)
+ }
+ }
+ accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1")
+ accepted(t, map[string]any{"title": "Dep", "reference": 2}, "acme-page", "2")
+ accepted(t, map[string]any{"title": "Dep", "reference": nil}, "acme-page", "")
+ accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1")
+ // Only the sibling: the stored value is checked against the new type,
+ // and a type without references clears it.
+ accepted(t, map[string]any{"title": "Dep", "type": "acme-list"}, "acme-list", "")
+ accepted(t, map[string]any{"title": "Dep", "type": "acme-list", "reference": "1"}, "acme-list", "")
+ accepted(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, "acme-page", "2")
+ accepted(t, map[string]any{"title": "Dep", "type": "url", "reference": "2"}, "url", "")
+}
+
+// TestRichEditor pins D-10 through the routes: a save stores the sanitized
+// value with notices in the request locale, clean HTML byte for byte with
+// none, null stays null, a value that is not a string is a 422 that writes
+// nothing; and settings and relation forms refuse the type at boot.
+func TestRichEditor(t *testing.T) {
+ t.Run("boot refusals", func(t *testing.T) {
+ for name, field := range map[string]string{
+ "richeditor": " body:\n type: richeditor\n",
+ "repeater": " body:\n type: repeater\n form:\n fields:\n a:\n type: text\n",
+ } {
+ err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + field})}, nil)
+ if err == nil || !strings.Contains(err.Error(), "field body: "+name+" is supported in controller forms only") {
+ t.Fatalf("relation form with %s: err = %v", name, err)
+ }
+ fsys := companionsOverlay(t, map[string]string{"models/settings/fields.yaml": "fields:\n title:\n type: text\n body:\n" + strings.TrimPrefix(field, " body:\n")})
+ err = activateCompanions(t, companionsFSPlugin{fsys: fsys, settings: []pact.SettingsItem{{
+ Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile",
+ NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"},
+ }}})
+ if err == nil || !strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") {
+ t.Fatalf("settings form with %s: err = %v", name, err)
+ }
+ }
+ })
+
+ e := newCompanionsEnv(t)
+ type noticeView struct{ field, message string }
+ save := func(t *testing.T, method, path string, body map[string]any, status int) (uint, []noticeView) {
+ t.Helper()
+ rec := e.send(t, method, path, body, true)
+ wantStatus(t, method+" "+path, rec, status, rec.Body.String())
+ record := decodeCompanionsRecord(t, rec.Body.Bytes())
+ var out []noticeView
+ for _, n := range record.Meta.Notices {
+ out = append(out, noticeView{n.Field, n.Message})
+ }
+ return dataID(t, rec.Body.Bytes()), out
+ }
+ stored := func(t *testing.T, id uint) *string {
+ t.Helper()
+ var link companionsLink
+ if err := e.db.First(&link, id).Error; err != nil {
+ t.Fatal(err)
+ }
+ return link.Description
+ }
+
+ id, notices := save(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rich", "description": `x
`}, http.StatusCreated)
+ if got := stored(t, id); got == nil || *got != "x
" {
+ t.Fatalf("stored = %v", got)
+ }
+ want := []noticeView{
+ {"description", "Usunięto 2 elementy: script, iframe"},
+ {"description", "Usunięto 2 atrybuty: onclick, onmouseover"},
+ {"description", "Usunięto 1 komentarz HTML"},
+ }
+ if fmt.Sprint(notices) != fmt.Sprint(want) {
+ t.Fatalf("notices = %v\nwant %v", notices, want)
+ }
+
+ path := fmt.Sprintf("/acme/companions/links/%d", id)
+ for _, clean := range []string{
+ "It's fine & \"quoted\" —
\n",
+ `link
`,
+ "",
+ } {
+ if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": clean}, http.StatusOK); len(notices) != 0 {
+ t.Fatalf("clean %q reported %v", clean, notices)
+ }
+ if got := stored(t, id); got == nil || *got != clean {
+ t.Fatalf("clean value not stored byte for byte: %v, want %q", got, clean)
+ }
+ }
+ if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": nil}, http.StatusOK); len(notices) != 0 || stored(t, id) != nil {
+ t.Fatalf("null description: %v %v", notices, stored(t, id))
+ }
+ keep := "kept
"
+ save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": keep}, http.StatusOK)
+ save(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, http.StatusOK)
+ if got := stored(t, id); got == nil || *got != keep {
+ t.Fatalf("an update without the field changed it: %v", got)
+ }
+ before := companionsLinkRows(t, e)
+ for _, bad := range []any{12, true, []any{"x
"}, map[string]any{"pl": "x
"}} {
+ rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Bad", "description": bad}, true)
+ wantStatus(t, fmt.Sprintf("description %v", bad), rec, http.StatusUnprocessableEntity, rec.Body.String())
+ if _, details := companionsError(t, rec.Body.Bytes()); len(details["description"]) != 1 {
+ t.Fatalf("details = %v", details)
+ }
+ sameLinkRows(t, e, before, "a non-string description")
+ }
+}
diff --git a/modules/cabana/dependent_test.go b/modules/cabana/dependent_test.go
new file mode 100644
index 0000000..5c9540c
--- /dev/null
+++ b/modules/cabana/dependent_test.go
@@ -0,0 +1,222 @@
+package cabana
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// TestDependentDropdownCompile pins D-08's compile rules for dependsOn: a
+// dropdown only, no static options, a sibling of the same form (or repeater
+// row) with a scalar value, never itself, and a controller that implements
+// pact.DependentOptionsProvider.
+func TestDependentDropdownCompile(t *testing.T) {
+ t.Run("a valid dependent dropdown compiles", func(t *testing.T) {
+ cc := ucMustCompile(t, ucFields+ucRepeater(""), nil)
+ if ref := ucField(t, cc, "reference"); ref.DependsOn != "type" || len(ref.Options) != 0 {
+ t.Fatalf("reference = %+v", ref)
+ }
+ if !usesDependsOn(cc.Form.Fields) {
+ t.Fatal("usesDependsOn = false")
+ }
+ })
+
+ base := "fields:\n title:\n type: text\n type:\n type: dropdown\n options: typeOptions\n"
+ for _, tc := range []struct {
+ name, fields string
+ ctl pact.AdminController
+ want string
+ }{
+ {"on a text field", base + " reference:\n type: text\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"},
+ {"on a textarea", base + " reference:\n type: textarea\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"},
+ {"with static options", base + " reference:\n type: dropdown\n dependsOn: type\n options: typeOptions\n", nil, "a dropdown with dependsOn takes no options"},
+ {"not a field name", base + " reference:\n type: dropdown\n dependsOn: \"a b\"\n", nil, `dependsOn "a b" is not a field name`},
+ {"names itself", base + " reference:\n type: dropdown\n dependsOn: reference\n", nil, "field reference: dependsOn names the field itself"},
+ {"unknown sibling", base + " reference:\n type: dropdown\n dependsOn: kind\n", nil, "field reference: dependsOn kind is not a field of this form"},
+ {"sibling without a scalar value", base + ucRepeater("") + " pick:\n type: dropdown\n dependsOn: rows\n", nil, "field pick: dependsOn rows must be a field with a scalar value, not type repeater"},
+ {"no provider", ucFields, ucPlainLinks{}, "field reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"},
+ {"no provider for a nested dropdown", base + ucRepeater(""), ucPlainLinks{}, "field rows.reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"},
+ {"nested unknown sibling", base + " rows:\n type: repeater\n form:\n fields:\n reference:\n type: dropdown\n dependsOn: title\n", nil, "field rows.reference: dependsOn title is not a field of the same repeater row"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := ucCompile(tc.fields, tc.ctl)
+ wantErr(t, err, tc.want)
+ })
+ }
+
+ t.Run("a controller without dependsOn needs no provider", func(t *testing.T) {
+ if _, err := ucCompile(base, ucPlainLinks{}); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("dependent fields are found by path", func(t *testing.T) {
+ cc := ucMustCompile(t, ucFields+ucRepeater(""), nil)
+ for _, tc := range []struct {
+ path, want string
+ ok bool
+ }{
+ {"reference", "reference", true},
+ {"rows.reference", "rows.reference", true},
+ {"title", "", false},
+ {"type", "", false},
+ {"rows.title", "", false},
+ {"rows.missing", "", false},
+ {"rows", "", false},
+ {"title.reference", "", false},
+ {"missing", "", false},
+ } {
+ field, ok := dependentField(cc, tc.path)
+ if ok != tc.ok || (ok && (field.Name != tc.want || field.DependsOn != "type")) {
+ t.Fatalf("dependentField(%q) = %+v, %v", tc.path, field, ok)
+ }
+ }
+ if _, ok := dependentField(nil, "reference"); ok {
+ t.Fatal("nil controller answered")
+ }
+ })
+}
+
+// TestDependentValues pins the Docker-free half of the D-08 save recheck:
+// sibling values are normalized the same way for the options route and a
+// save, null is always accepted, a value outside the options is a 422 on the
+// field (field.index.nested in a row), a type that takes no reference stores
+// null, the stored sibling or value is used when only the other is written,
+// and the provider is asked once per path and value.
+func TestDependentValues(t *testing.T) {
+ for _, tc := range []struct {
+ in any
+ want any
+ }{
+ {nil, nil}, {"", nil}, {"x", "x"}, {true, "true"}, {false, "false"},
+ {json.Number("12"), "12"}, {float64(2), "2"}, {2.5, "2.5"}, {3, "3"}, {int64(4), "4"}, {uint(5), "5"},
+ {[]any{1}, "[1]"},
+ } {
+ if got := dependentValue(tc.in); got != tc.want {
+ t.Fatalf("dependentValue(%#v) = %#v, want %#v", tc.in, got, tc.want)
+ }
+ }
+ set := pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1"}, {Value: "2"}}}
+ for _, tc := range []struct {
+ value any
+ ok bool
+ }{{nil, true}, {"", true}, {"1", true}, {float64(2), true}, {json.Number("2"), true}, {"3", false}, {"01", false}, {true, false}} {
+ if got := amongOptions(set, tc.value); got != tc.ok {
+ t.Fatalf("amongOptions(%#v) = %v", tc.value, got)
+ }
+ }
+
+ page, list := "acme-page", "acme-list"
+ stale := "99"
+ two := "2"
+ check := func(t *testing.T, target *ucLink, projected map[string]any) (map[string]any, []string, error) {
+ t.Helper()
+ var calls []string
+ cc := ucMustCompile(t, ucFields+ucRepeater(""), ucLinks{calls: &calls})
+ err := checkDependentValues(context.Background(), cc, nil, target, projected, "update")
+ var invalid *ValidationError
+ if errors.As(err, &invalid) {
+ return invalid.Details, calls, nil
+ }
+ return nil, calls, err
+ }
+
+ t.Run("top level", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ target ucLink
+ projected map[string]any
+ wantInvalid bool
+ wantReference any
+ wantCalls string
+ }{
+ {"a value among the options", ucLink{}, map[string]any{"type": page, "reference": "2"}, false, "2", "[reference=map[type:acme-page]]"},
+ {"a number value is compared as text", ucLink{}, map[string]any{"type": page, "reference": float64(1)}, false, float64(1), "[reference=map[type:acme-page]]"},
+ {"null is accepted", ucLink{}, map[string]any{"type": page, "reference": nil}, false, nil, "[reference=map[type:acme-page]]"},
+ {"a value outside the options", ucLink{}, map[string]any{"type": page, "reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
+ {"a type without references stores null", ucLink{}, map[string]any{"type": list, "reference": "1"}, false, nil, "[reference=map[type:acme-list]]"},
+ {"an empty type stores null", ucLink{}, map[string]any{"type": "", "reference": "1"}, false, nil, "[reference=map[type:]]"},
+ {"only the value: the stored sibling decides", ucLink{Type: &page}, map[string]any{"reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
+ {"only the value, stored sibling accepts it", ucLink{Type: &page}, map[string]any{"reference": "1"}, false, "1", "[reference=map[type:acme-page]]"},
+ {"only the sibling: the stored value is checked", ucLink{Reference: &stale}, map[string]any{"type": page}, true, nil, "[reference=map[type:acme-page]]"},
+ {"only the sibling, stored value still valid", ucLink{Reference: &two}, map[string]any{"type": page}, false, nil, "[reference=map[type:acme-page]]"},
+ {"only the sibling to a type without references clears the value", ucLink{Reference: &two}, map[string]any{"type": list}, false, nil, "[reference=map[type:acme-list]]"},
+ {"neither written asks nothing", ucLink{Type: &page, Reference: &stale}, map[string]any{"title": "x"}, false, nil, "[]"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ target := tc.target
+ details, calls, err := check(t, &target, tc.projected)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if (details != nil) != tc.wantInvalid {
+ t.Fatalf("details = %v, want invalid %v", details, tc.wantInvalid)
+ }
+ if tc.wantInvalid {
+ if msgs, _ := details["reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
+ t.Fatalf("details = %v", details)
+ }
+ }
+ if got, present := tc.projected["reference"]; present || tc.wantReference != nil {
+ if fmt.Sprint(got) != fmt.Sprint(tc.wantReference) {
+ t.Fatalf("projected reference = %#v, want %#v", got, tc.wantReference)
+ }
+ }
+ if strings.Contains(tc.name, "stores null") || strings.Contains(tc.name, "clears the value") {
+ if v, present := tc.projected["reference"]; !present || v != nil {
+ t.Fatalf("reference not written as null: %#v (present %v)", v, present)
+ }
+ }
+ if fmt.Sprint(calls) != tc.wantCalls {
+ t.Fatalf("provider calls = %v, want %s", calls, tc.wantCalls)
+ }
+ })
+ }
+ })
+
+ t.Run("repeater rows", func(t *testing.T) {
+ projected := map[string]any{"rows": []any{
+ map[string]any{"title": "a", "type": page, "reference": "1"},
+ map[string]any{"title": "b", "type": page, "reference": "99"},
+ map[string]any{"title": "c", "type": list, "reference": "1"},
+ map[string]any{"title": "d", "type": page, "reference": "2"},
+ "not a row",
+ }}
+ details, calls, err := check(t, &ucLink{}, projected)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if msgs, _ := details["rows.1.reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
+ t.Fatalf("details = %v", details)
+ }
+ rows := projected["rows"].([]any)
+ if rows[2].(map[string]any)["reference"] != nil || rows[0].(map[string]any)["reference"] != "1" {
+ t.Fatalf("rows = %v", rows)
+ }
+ // One call per distinct path and value: the second acme-page row
+ // and the fourth reuse the first answer.
+ if fmt.Sprint(calls) != "[rows.reference=map[type:acme-page] rows.reference=map[type:acme-list]]" {
+ t.Fatalf("provider calls = %v", calls)
+ }
+ })
+
+ t.Run("a provider error is returned, not a 422", func(t *testing.T) {
+ broken := "acme-broken"
+ _, _, err := check(t, &ucLink{}, map[string]any{"type": broken, "reference": "1"})
+ if err == nil || !strings.Contains(err.Error(), "references failed") {
+ t.Fatalf("err = %v", err)
+ }
+ })
+
+ t.Run("a form without dependsOn asks nothing", func(t *testing.T) {
+ cc := ucMustCompile(t, "fields:\n title:\n type: text\n", ucPlainLinks{})
+ if err := checkDependentValues(context.Background(), cc, nil, &ucLink{}, map[string]any{"title": "x"}, "update"); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go
index fb9e33e..087843c 100644
--- a/modules/cabana/openapi_conformance_test.go
+++ b/modules/cabana/openapi_conformance_test.go
@@ -223,6 +223,105 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
t.FailNow()
}
}
+
+ // The documented error answers of the Phase 17 routes, after a fresh
+ // login (the last case logged out).
+ if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": env.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
+ t.Fatalf("re-login status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ for _, tc := range companionsErrorCases() {
+ t.Run(fmt.Sprintf("%s %d %s", tc.key, tc.status, tc.name), func(t *testing.T) {
+ rec := tc.call(t, env)
+ if rec.Code != tc.status {
+ t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
+ }
+ dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
+ dec.DisallowUnknownFields()
+ var body cabana.ErrorEnvelope
+ if err := dec.Decode(&body); err != nil || body.Error.Code == "" {
+ t.Fatalf("body is not an ErrorEnvelope: %v\n%s", err, rec.Body.String())
+ }
+ if tc.code != "" && body.Error.Code != tc.code {
+ t.Fatalf("error code = %q, want %q", body.Error.Code, tc.code)
+ }
+ method, path, _ := strings.Cut(tc.key, " ")
+ if got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status)); got != "cabana.ErrorEnvelope" {
+ t.Fatalf("admin.json documents %q for %s %d, the handler writes cabana.ErrorEnvelope", got, tc.key, tc.status)
+ }
+ })
+ }
+}
+
+// companionsErrorCase is one documented error answer of a Phase 17 route.
+type companionsErrorCase struct {
+ key string
+ status int
+ name string
+ code string
+ call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder
+}
+
+// companionsErrorCases are the 404, 409 and 422 answers the dependent
+// options, reorder and singleton routes document, and the 404 of the list,
+// create and delete routes on a singleton controller.
+func companionsErrorCases() []companionsErrorCase {
+ partitionLinks := func(t *testing.T, e *conformEnv) (uint, []uint) {
+ t.Helper()
+ group := companionsGroup{Title: "Errors " + e.stamp}
+ if err := e.db.Create(&group).Error; err != nil {
+ t.Fatal(err)
+ }
+ var ids []uint
+ for i := range 2 {
+ link := companionsLink{Title: fmt.Sprintf("err-%d-%s", i, e.stamp), GroupID: &group.ID, SortOrder: i + 1}
+ if err := e.db.Create(&link).Error; err != nil {
+ t.Fatal(err)
+ }
+ ids = append(ids, link.ID)
+ }
+ return group.ID, ids
+ }
+ profileID := func(t *testing.T, e *conformEnv) uint {
+ t.Helper()
+ return dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes())
+ }
+ return []companionsErrorCase{
+ {"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 404, "field without dependsOn", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, dependentOptionsPath("title", "acme-page"), nil, true)
+ }},
+ {"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 401, "anonymous", "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false)
+ }},
+ {"GET /{vendor}/{plugin}/{controller}/reorder", 404, "unknown partition", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, "/acme/companions/links/reorder?partition=999999", nil, true)
+ }},
+ {"GET /{vendor}/{plugin}/{controller}/reorder", 422, "partition not an id", "validation_failed", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, "/acme/companions/links/reorder?partition=abc", nil, true)
+ }},
+ {"POST /{vendor}/{plugin}/{controller}/reorder", 409, "stale set", "reorder_stale", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ group, ids := partitionLinks(t, e)
+ return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": group, "nodes": []any{reorderNode(ids[0], nil, 1)}}, true)
+ }},
+ {"POST /{vendor}/{plugin}/{controller}/reorder", 422, "cycle", "validation_failed", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ group, ids := partitionLinks(t, e)
+ return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": group, "nodes": []any{reorderNode(ids[0], &ids[1], 1), reorderNode(ids[1], &ids[0], 1)}}, true)
+ }},
+ {"POST /{vendor}/{plugin}/{controller}/reorder", 404, "unknown partition", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": 999999, "nodes": []any{}}, true)
+ }},
+ {"GET /{vendor}/{plugin}/{controller}/singleton", 404, "not a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, "/acme/companions/links/singleton", nil, true)
+ }},
+ {"GET /{vendor}/{plugin}/{controller}", 404, "list of a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodGet, "/acme/companions/profiles", nil, true)
+ }},
+ {"POST /{vendor}/{plugin}/{controller}", 404, "create on a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true)
+ }},
+ {"DELETE /{vendor}/{plugin}/{controller}/{id}", 404, "delete on a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
+ return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/companions/profiles/%d", profileID(t, e)), nil, true)
+ }},
+ }
}
// conformCases is the conformance case of every inventoried admin route, in
diff --git a/modules/cabana/reorder_test.go b/modules/cabana/reorder_test.go
new file mode 100644
index 0000000..f0381f5
--- /dev/null
+++ b/modules/cabana/reorder_test.go
@@ -0,0 +1,265 @@
+package cabana_test
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// TestReorderConfigCompile pins the boot checks of config_reorder.yaml
+// (D-09), Docker-free through cabana.Activate: unknown keys, a modelClass
+// mismatch, columns that are not columns of the record, a negative maxDepth,
+// a partition over a column without a belongsTo relation field, a partition
+// label column the related record lacks, and the reorder toolbar button
+// without the file.
+func TestReorderConfigCompile(t *testing.T) {
+ const cfg = "controllers/links/config_reorder.yaml"
+ reorder := func(t *testing.T, old, new string) map[string]string {
+ t.Helper()
+ src := companionsFile(t, cfg)
+ if !strings.Contains(src, old) {
+ t.Fatalf("fixture lacks %q", old)
+ }
+ return map[string]string{cfg: strings.Replace(src, old, new, 1)}
+ }
+ t.Run("the fixture compiles, and defaults fill parentColumn and sortColumn", func(t *testing.T) {
+ if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil {
+ t.Fatal(err)
+ }
+ files := reorder(t, "parentColumn: parent_id\nsortColumn: sort_order\n", "")
+ if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
+ t.Fatalf("defaults: %v", err)
+ }
+ files = reorder(t, "maxDepth: 3\n", "maxDepth: 0\n")
+ if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
+ t.Fatalf("unlimited depth: %v", err)
+ }
+ files = reorder(t, "partition:\n column: group_id\n relation: group\n label: Group\n", "")
+ if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
+ t.Fatalf("without a partition: %v", err)
+ }
+ files = reorder(t, " label: Group\n", " label: Group\n nameFrom: title\n")
+ if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil {
+ t.Fatalf("partition nameFrom: %v", err)
+ }
+ })
+ for _, tc := range []struct {
+ name string
+ files func(t *testing.T) map[string]string
+ want []string
+ }{
+ {"unknown key", func(t *testing.T) map[string]string {
+ return reorder(t, "title: Reorder links\n", "title: Reorder links\nshowTree: true\n")
+ }, []string{cfg, "showTree"}},
+ {"unknown partition key", func(t *testing.T) map[string]string {
+ return reorder(t, " label: Group\n", " label: Group\n scope: x\n")
+ }, []string{cfg, "scope"}},
+ {"modelClass mismatch", func(t *testing.T) map[string]string { return reorder(t, "modelClass: Link\n", "modelClass: Other\n") }, []string{cfg, `modelClass "Other" does not match "Link"`}},
+ {"nameFrom not a column", func(t *testing.T) map[string]string { return reorder(t, "nameFrom: title\n", "nameFrom: label\n") }, []string{"nameFrom label is not a column of the record"}},
+ {"nameFrom not an identifier", func(t *testing.T) map[string]string {
+ return reorder(t, "nameFrom: title\n", "nameFrom: \"title; drop\"\n")
+ }, []string{"nameFrom", "is not a column name"}},
+ {"parentColumn not a column", func(t *testing.T) map[string]string {
+ return reorder(t, "parentColumn: parent_id\n", "parentColumn: parent\n")
+ }, []string{"parentColumn parent is not a column of the record"}},
+ {"sortColumn not a column", func(t *testing.T) map[string]string {
+ return reorder(t, "sortColumn: sort_order\n", "sortColumn: position\n")
+ }, []string{"sortColumn position is not a column of the record"}},
+ {"negative maxDepth", func(t *testing.T) map[string]string { return reorder(t, "maxDepth: 3\n", "maxDepth: -1\n") }, []string{"maxDepth must be 0 (unlimited) or more"}},
+ {"partition column not a column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: menu_id\n") }, []string{"partition.column menu_id is not a column of the record"}},
+ {"partition relation unknown", func(t *testing.T) map[string]string { return reorder(t, "relation: group\n", "relation: menu\n") }, []string{"partition.relation menu must be a belongsTo relation field of the form over group_id"}},
+ {"partition relation over another column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: parent_id\n") }, []string{"partition.relation group must be a belongsTo relation field of the form over parent_id"}},
+ {"partition nameFrom not a related column", func(t *testing.T) map[string]string {
+ return reorder(t, " label: Group\n", " label: Group\n nameFrom: name\n")
+ }, []string{"partition.nameFrom name is not a column of the related record"}},
+ {"reorder button without the file", func(t *testing.T) map[string]string { return map[string]string{cfg: ""} }, []string{"config_list.yaml", "toolbar.buttons: reorder needs config_reorder.yaml"}},
+ {"a reorder page needs a list", func(t *testing.T) map[string]string {
+ return map[string]string{"controllers/profiles/config_reorder.yaml": "title: Profiles\nnameFrom: title\nparentColumn: id\nsortColumn: id\n"}
+ }, []string{"a reorder page needs config_list.yaml"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, tc.files(t))})
+ if err == nil {
+ t.Fatal("boot accepted the reorder config")
+ }
+ for _, part := range tc.want {
+ if !strings.Contains(err.Error(), part) {
+ t.Fatalf("err = %v, want %q", err, part)
+ }
+ }
+ })
+ }
+}
+
+// TestReorderPostgres pins the D-09 routes on Postgres: the GET scopes and
+// orders the partition's records, the POST writes both columns, and every
+// refused request (stale set, foreign partition node, unknown id, duplicate,
+// self parent, parent outside the set, cycle, depth above maxDepth, missing
+// or unknown partition) answers its status and writes nothing.
+func TestReorderPostgres(t *testing.T) {
+ e := newCompanionsEnv(t)
+ alpha, beta := companionsGroup{Title: "Beta"}, companionsGroup{Title: "Alpha"}
+ for _, g := range []*companionsGroup{&alpha, &beta} {
+ if err := e.db.Create(g).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Records of alpha ("Beta"): sort order ties break by id; one record of
+ // beta ("Alpha") is the foreign node.
+ links := []companionsLink{
+ {Title: "A", GroupID: &alpha.ID, SortOrder: 2},
+ {Title: "B", GroupID: &alpha.ID, SortOrder: 1},
+ {Title: "C", GroupID: &alpha.ID, SortOrder: 2},
+ {Title: "D", GroupID: &alpha.ID, SortOrder: 3},
+ {Title: "E", GroupID: &alpha.ID, SortOrder: 4},
+ {Title: "X", GroupID: &beta.ID, SortOrder: 1},
+ {Title: "Free"},
+ }
+ for i := range links {
+ if err := e.db.Create(&links[i]).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ a, b, c, d, x := links[0].ID, links[1].ID, links[2].ID, links[3].ID, links[5].ID
+ ee := links[4].ID
+
+ t.Run("GET", func(t *testing.T) {
+ tree := reorderTree(t, e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/reorder?partition=%d", alpha.ID), nil, true))
+ var names []string
+ for _, n := range tree.Nodes {
+ names = append(names, n.Name)
+ }
+ if strings.Join(names, ",") != "B,A,C,D,E" || tree.Title != "Reorder links" || tree.Partition.Label != "Group" {
+ t.Fatalf("tree = %+v (names %v)", tree, names)
+ }
+ // Without a partition the first one by label ("Alpha") is shown.
+ first := reorderTree(t, e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, true))
+ if first.Partition == nil || first.Partition.Value == nil || *first.Partition.Value != beta.ID || len(first.Nodes) != 1 || first.Nodes[0].ID != x {
+ t.Fatalf("default partition tree = %+v", first)
+ }
+ if opts := first.Partition.Options; len(opts) != 2 || opts[0].Label != "Alpha" || opts[1].Label != "Beta" {
+ t.Fatalf("partition options = %+v", opts)
+ }
+ for path, status := range map[string]int{
+ "/acme/companions/links/reorder?partition=999999": http.StatusNotFound,
+ "/acme/companions/links/reorder?partition=abc": http.StatusUnprocessableEntity,
+ "/acme/companions/links/reorder?partition=0": http.StatusUnprocessableEntity,
+ "/acme/companions/profiles/reorder": http.StatusNotFound,
+ } {
+ if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != status {
+ t.Fatalf("%s status=%d want %d body=%s", path, rec.Code, status, rec.Body.String())
+ }
+ }
+ if rec := e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, false); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("anonymous status=%d", rec.Code)
+ }
+ })
+
+ post := func(partition any, nodes ...map[string]any) *httptest.ResponseRecorder {
+ body := map[string]any{"nodes": nodes}
+ if partition != nil {
+ body["partition"] = partition
+ }
+ return e.send(t, http.MethodPost, "/acme/companions/links/reorder", body, true)
+ }
+ n := reorderNode
+
+ t.Run("POST writes both columns", func(t *testing.T) {
+ tree := reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, &a, 2)))
+ got := companionsLinkRows(t, e)
+ check := func(id uint, parent *uint, sort int) {
+ t.Helper()
+ row := got[id]
+ if (parent == nil) != (row.ParentID == nil) || (parent != nil && *row.ParentID != *parent) || row.SortOrder != sort {
+ t.Fatalf("row %d = parent %v sort %d, want %v %d", id, row.ParentID, row.SortOrder, parent, sort)
+ }
+ }
+ check(a, nil, 1)
+ check(b, &a, 1)
+ check(c, &b, 1)
+ check(d, nil, 2)
+ check(ee, &a, 2)
+ check(x, nil, 1)
+ if len(tree.Nodes) != 5 || tree.Nodes[0].ID != a {
+ t.Fatalf("answered tree = %+v", tree)
+ }
+ })
+
+ before := companionsLinkRows(t, e)
+ for _, tc := range []struct {
+ name string
+ rec func() *httptest.ResponseRecorder
+ status int
+ code string
+ detail string
+ }{
+ {"a node left out", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4))
+ }, http.StatusConflict, "reorder_stale", ""},
+ {"a node of another partition", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(x, nil, 6))
+ }, http.StatusConflict, "reorder_stale", ""},
+ {"a foreign node in place of one", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(x, nil, 5))
+ }, http.StatusConflict, "reorder_stale", ""},
+ {"an unknown id", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(999999, nil, 5))
+ }, http.StatusConflict, "reorder_stale", ""},
+ {"a duplicate id", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(a, nil, 6))
+ }, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
+ {"id zero", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(0, nil, 1), n(b, nil, 2))
+ }, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
+ {"its own parent", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, &a, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5))
+ }, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
+ {"a parent outside the set", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, &x, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5))
+ }, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
+ {"a cycle", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, &c, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3))
+ }, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
+ {"deeper than maxDepth", func() *httptest.ResponseRecorder {
+ return post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, &c, 1), n(ee, nil, 2))
+ }, http.StatusUnprocessableEntity, "validation_failed", "nodes"},
+ {"no partition", func() *httptest.ResponseRecorder {
+ return post(nil, n(a, nil, 1))
+ }, http.StatusUnprocessableEntity, "validation_failed", "partition"},
+ {"an unknown partition", func() *httptest.ResponseRecorder {
+ return post(999999, n(a, nil, 1))
+ }, http.StatusNotFound, "", ""},
+ {"a singleton controller", func() *httptest.ResponseRecorder {
+ return e.send(t, http.MethodPost, "/acme/companions/profiles/reorder", map[string]any{"nodes": []any{}}, true)
+ }, http.StatusNotFound, "", ""},
+ {"anonymous", func() *httptest.ResponseRecorder {
+ return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": alpha.ID, "nodes": []any{}}, false)
+ }, http.StatusUnauthorized, "", ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := tc.rec()
+ if rec.Code != tc.status {
+ t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
+ }
+ if tc.code != "" {
+ code, details := companionsError(t, rec.Body.Bytes())
+ if code != tc.code || (tc.detail != "" && len(details[tc.detail]) != 1) {
+ t.Fatalf("error = %s %v, want %s on %q", code, details, tc.code, tc.detail)
+ }
+ if tc.code == "reorder_stale" && !strings.Contains(rec.Body.String(), "Rekordy zmieniły się w międzyczasie") {
+ t.Fatalf("stale message = %s", rec.Body.String())
+ }
+ }
+ sameLinkRows(t, e, before, tc.name)
+ })
+ }
+
+ t.Run("exactly maxDepth is accepted", func(t *testing.T) {
+ reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3)))
+ if row := companionsLinkRows(t, e)[c]; row.ParentID == nil || *row.ParentID != b {
+ t.Fatalf("c = %+v", row)
+ }
+ })
+}
diff --git a/modules/cabana/repeater_test.go b/modules/cabana/repeater_test.go
new file mode 100644
index 0000000..d459db2
--- /dev/null
+++ b/modules/cabana/repeater_test.go
@@ -0,0 +1,426 @@
+package cabana
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "git.golem15.com/golem15/summercms/modules/backpack"
+ "git.golem15.com/golem15/summercms/modules/lagoon"
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// Docker-free unit tests of the Phase 17 form features (plan 06): they
+// compile the acme.unit links controller from an fstest tree and run the
+// decode, recheck and sanitize steps of a save without a database. The
+// Postgres tests through the HTTP routes are in companions_postgres_test.go,
+// reorder_test.go and singleton_test.go.
+
+// ucLink is the links model of the unit fixture.
+type ucLink struct {
+ ID uint `gorm:"column:id;primaryKey"`
+ Title string `gorm:"column:title"`
+ Type *string `gorm:"column:type"`
+ Reference *string `gorm:"column:reference"`
+ Description *string `gorm:"column:description"`
+ Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"`
+}
+
+func (*ucLink) TableName() string { return "uc_links" }
+func (*ucLink) Fillable() []string {
+ return []string{"title", "type", "reference", "description", "rows"}
+}
+func (*ucLink) Rules() map[string]string { return map[string]string{} }
+
+// ucDependent is the fake dependent options binding: acme-page offers a
+// two-level tree, acme-list and every other value take no reference.
+func ucDependent(_ context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
+ for _, v := range values {
+ switch v {
+ case "acme-page":
+ return pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1", Label: "Home"}, {Value: "2", Label: "About", Depth: 1}}}, nil
+ case "acme-broken":
+ return pact.DependentOptionSet{}, errors.New("references failed")
+ }
+ }
+ return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil
+}
+
+// ucLinks is the links controller of the unit fixture.
+type ucLinks struct {
+ calls *[]string
+}
+
+func (ucLinks) ID() string { return "acme.unit.links" }
+func (ucLinks) ModelName() string { return "Link" }
+func (ucLinks) ConfigDir() string { return "controllers/links" }
+func (ucLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} }
+func (ucLinks) NewRecord() any { return &ucLink{} }
+func (ucLinks) DropdownOptions(string) []pact.Option {
+ return []pact.Option{{Value: "url", Label: "URL"}, {Value: "acme-page", Label: "Page"}, {Value: "acme-list", Label: "All pages"}}
+}
+func (c ucLinks) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
+ if c.calls != nil {
+ *c.calls = append(*c.calls, fmt.Sprintf("%s=%v", field, values))
+ }
+ return ucDependent(ctx, field, values)
+}
+
+// ucPlainLinks is the same controller without a DependentOptionsProvider.
+type ucPlainLinks struct{}
+
+func (ucPlainLinks) ID() string { return "acme.unit.links" }
+func (ucPlainLinks) ModelName() string { return "Link" }
+func (ucPlainLinks) ConfigDir() string { return "controllers/links" }
+func (ucPlainLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} }
+func (ucPlainLinks) NewRecord() any { return &ucLink{} }
+func (ucPlainLinks) DropdownOptions(string) []pact.Option {
+ return []pact.Option{{Value: "url", Label: "URL"}}
+}
+
+// ucPlugin serves the unit fixture tree with one controller.
+type ucPlugin struct {
+ fsys fs.FS
+ ctl pact.AdminController
+}
+
+func (ucPlugin) ID() string { return "acme.unit" }
+func (ucPlugin) Requires() []string { return nil }
+func (ucPlugin) Register(*backpack.App) error { return nil }
+func (ucPlugin) Boot(*backpack.App) error { return nil }
+func (p ucPlugin) AdminFS() fs.FS { return p.fsys }
+func (p ucPlugin) AdminControllers() []pact.AdminController {
+ return []pact.AdminController{p.ctl}
+}
+
+// ucFields is the start of the unit fixture's fields.yaml: a title, a type
+// dropdown and a reference dropdown that depends on it.
+const ucFields = "fields:\n" +
+ " title:\n type: text\n label: Title\n" +
+ " type:\n type: dropdown\n label: Type\n options: typeOptions\n" +
+ " reference:\n type: dropdown\n label: Reference\n dependsOn: type\n"
+
+// ucRepeater is a valid repeater named rows with every nested type it
+// takes; extra is appended to the repeater's own keys.
+func ucRepeater(extra string) string {
+ return " rows:\n type: repeater\n label: Rows\n" + extra +
+ " form:\n fields:\n" +
+ " title:\n type: text\n required: true\n" +
+ " body:\n type: richeditor\n" +
+ " count:\n type: number\n" +
+ " on:\n type: checkbox\n" +
+ " flag:\n type: switch\n" +
+ " note:\n type: textarea\n" +
+ " type:\n type: dropdown\n options: typeOptions\n" +
+ " reference:\n type: dropdown\n dependsOn: type\n"
+}
+
+// ucFS is the unit fixture tree with the given fields.yaml.
+func ucFS(fields string) fstest.MapFS {
+ return fstest.MapFS{
+ "controllers/links/config_form.yaml": &fstest.MapFile{Data: []byte("name: Link\nform: ~/plugins/acme/unit/models/link/fields.yaml\nmodelClass: Link\n")},
+ "controllers/links/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/unit/models/link/columns.yaml\nmodelClass: Link\nrecordsPerPage: 20\n")},
+ "models/link/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
+ "models/link/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
+ }
+}
+
+// ucCompile compiles the links controller of fields with ctl (ucLinks when
+// nil).
+func ucCompile(fields string, ctl pact.AdminController) (*CompiledController, error) {
+ if ctl == nil {
+ ctl = ucLinks{}
+ }
+ reg, err := ftCompile(ucPlugin{fsys: ucFS(fields), ctl: ctl})
+ if err != nil {
+ return nil, err
+ }
+ cc, ok := reg.Get("acme.unit.links")
+ if !ok {
+ return nil, errors.New("no links controller")
+ }
+ return cc, nil
+}
+
+func ucMustCompile(t *testing.T, fields string, ctl pact.AdminController) *CompiledController {
+ t.Helper()
+ cc, err := ucCompile(fields, ctl)
+ if err != nil {
+ t.Fatalf("compile: %v", err)
+ }
+ return cc
+}
+
+// ucField returns the compiled form field name of cc.
+func ucField(t *testing.T, cc *CompiledController, name string) FormField {
+ t.Helper()
+ for _, field := range cc.Form.Fields {
+ if field.Name == name {
+ return field
+ }
+ }
+ t.Fatalf("no field %s", name)
+ return FormField{}
+}
+
+// TestRepeaterCompile pins D-07's compile rules: every key a repeater
+// refuses, every nested type it refuses, the nested tab/context/preset and
+// dependsOn checks, the row bounds and titleFrom, and the repeater-only keys
+// on other types.
+func TestRepeaterCompile(t *testing.T) {
+ t.Run("a valid repeater compiles with its nested form", func(t *testing.T) {
+ cc := ucMustCompile(t, ucFields+ucRepeater(" prompt: Add row\n titleFrom: title\n minItems: 1\n maxItems: 3\n tab: Content\n span: left\n required: true\n comment: Rows\n"), nil)
+ rows := ucField(t, cc, "rows")
+ if rows.Span != "full" || rows.Prompt != "Add row" || rows.TitleFrom != "title" || rows.MinItems == nil || *rows.MinItems != 1 || rows.MaxItems == nil || *rows.MaxItems != 3 || !rows.Required || rows.Tab != "Content" {
+ t.Fatalf("rows = %+v", rows)
+ }
+ if rows.Form == nil || len(rows.Form.Fields) != 8 {
+ t.Fatalf("nested form = %+v", rows.Form)
+ }
+ var names []string
+ for _, inner := range rows.Form.Fields {
+ names = append(names, inner.Name+":"+inner.Type)
+ }
+ if strings.Join(names, ",") != "title:text,body:richeditor,count:number,on:checkbox,flag:switch,note:textarea,type:dropdown,reference:dropdown" {
+ t.Fatalf("nested fields = %v", names)
+ }
+ if !rows.Form.Fields[0].Required || rows.Form.Fields[7].DependsOn != "type" {
+ t.Fatalf("nested keys lost: %+v", rows.Form.Fields)
+ }
+ if names := repeaterNames(cc); !names["rows"] || len(names) != 1 {
+ t.Fatalf("repeaterNames = %v", names)
+ }
+ })
+
+ nested := func(field string) string {
+ return " rows:\n type: repeater\n form:\n fields:\n" + field
+ }
+ for _, tc := range []struct {
+ name, fields, want string
+ }{
+ {"groups", ucFields + ucRepeater(" groups: ~/groups.yaml\n"), "repeater groups are not supported"},
+ {"style", ucFields + ucRepeater(" style: accordion\n"), "repeater style is not supported"},
+ {"options key", ucFields + ucRepeater(" options: typeOptions\n"), "options is not valid on type: repeater"},
+ {"size key", ucFields + ucRepeater(" size: large\n"), "size is not valid on type: repeater"},
+ {"default key", ucFields + ucRepeater(" default: []\n"), "default is not valid on type: repeater"},
+ {"attributes key", ucFields + ucRepeater(" attributes: {}\n"), "attributes is not valid on type: repeater"},
+ {"no form", ucFields + " rows:\n type: repeater\n", "a repeater needs form: with fields"},
+ {"form without fields", ucFields + " rows:\n type: repeater\n form:\n label: x\n", "a repeater form takes fields only"},
+ {"form groups", ucFields + " rows:\n type: repeater\n form:\n groups: {}\n", "repeater groups are not supported"},
+ {"empty fields", ucFields + " rows:\n type: repeater\n form:\n fields: {}\n", "form.fields is empty"},
+ {"nested tab", ucFields + nested(" title:\n type: text\n tab: Main\n"), "tab, context and preset are not supported in a repeater row"},
+ {"nested context", ucFields + nested(" title:\n type: text\n context: update\n"), "tab, context and preset are not supported in a repeater row"},
+ {"nested dependsOn to a missing sibling", ucFields + nested(" reference:\n type: dropdown\n dependsOn: type\n"), "is not a field of the same repeater row"},
+ {"nested dependsOn to itself", ucFields + nested(" reference:\n type: dropdown\n dependsOn: reference\n"), "dependsOn names the field itself"},
+ {"negative maxItems", ucFields + ucRepeater(" maxItems: -1\n"), "maxItems: want a non-negative integer"},
+ {"text maxItems", ucFields + ucRepeater(" maxItems: many\n"), "maxItems: want a non-negative integer"},
+ {"negative minItems", ucFields + ucRepeater(" minItems: -2\n"), "minItems: want a non-negative integer"},
+ {"minItems above maxItems", ucFields + ucRepeater(" minItems: 4\n maxItems: 2\n"), "minItems 4 is greater than maxItems 2"},
+ {"titleFrom outside the form", ucFields + ucRepeater(" titleFrom: missing\n"), "titleFrom missing is not a field of the repeater form"},
+ {"maxItems on text", ucFields + " extra:\n type: text\n maxItems: 2\n", "maxItems is only valid on type: repeater"},
+ {"minItems on textarea", ucFields + " extra:\n type: textarea\n minItems: 2\n", "minItems is only valid on type: repeater"},
+ {"titleFrom on dropdown", ucFields + " extra:\n type: dropdown\n options: typeOptions\n titleFrom: x\n", "titleFrom is only valid on type: repeater"},
+ {"form on text", ucFields + " extra:\n type: text\n form:\n fields: {}\n", "form is only valid on type: repeater"},
+ {"groups on text", ucFields + " extra:\n type: text\n groups: x\n", "repeater groups are not supported"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := ucCompile(tc.fields, nil)
+ wantErr(t, err, tc.want)
+ })
+ }
+
+ // Every core type outside text, textarea, number, checkbox, switch,
+ // dropdown and richeditor is refused in a row.
+ // Each type gets the keys it needs on its own, so the refusal reached is
+ // the repeater's.
+ typeKeys := map[string]string{
+ "relation-manager": " relation: parts\n",
+ "widget": " widget: acme-x\n action: lookup\n",
+ "partial": " path: summary\n",
+ "permissioneditor": " mode: checkbox\n",
+ "repeater": " form:\n fields:\n a:\n type: text\n",
+ }
+ for typ := range formFieldTypes {
+ if repeaterNestedTypes[typ] {
+ continue
+ }
+ t.Run("nested "+typ, func(t *testing.T) {
+ _, err := ucCompile(ucFields+nested(" inner:\n type: "+typ+"\n"+typeKeys[typ]), nil)
+ wantErr(t, err, "field inner: type "+typ+" is not supported in a repeater")
+ })
+ }
+}
+
+// ucDecode runs liftRepeaterValues on a JSON body.
+func ucDecode(t *testing.T, cc *CompiledController, raw, op string) (map[string]any, map[string]any) {
+ t.Helper()
+ body := utJSON(t, raw).(map[string]any)
+ err := liftRepeaterValues(context.Background(), cc, body, op, nil)
+ if err == nil {
+ return body, nil
+ }
+ var invalid *ValidationError
+ if !errors.As(err, &invalid) {
+ t.Fatalf("err = %v, want a ValidationError", err)
+ }
+ return body, invalid.Details
+}
+
+// TestRepeaterDecode pins the D-07 decode of a submitted repeater value:
+// rows keep only the nested keys, scalars are coerced per nested type,
+// required nested values fail under field.index.nested, the row bounds and
+// the list shape are checked, and stored values hydrate back to rows.
+func TestRepeaterDecode(t *testing.T) {
+ cc := ucMustCompile(t, ucFields+ucRepeater(" minItems: 1\n maxItems: 3\n"), nil)
+
+ t.Run("rows keep only nested keys, coerced per type", func(t *testing.T) {
+ body, details := ucDecode(t, cc, `{"rows":[{"title":"One","body":"x
","count":"12","on":"yes","flag":1,"note":7,"type":"acme-page","reference":2,"extra":"dropped"},{"title":5,"count":3.5,"on":"off","flag":false,"note":true}]}`, "update")
+ if details != nil {
+ t.Fatalf("details = %v", details)
+ }
+ rows := body["rows"].([]any)
+ first, second := rows[0].(map[string]any), rows[1].(map[string]any)
+ if _, extra := first["extra"]; extra || len(first) != 8 || len(second) != 8 {
+ t.Fatalf("row keys = %v / %v", first, second)
+ }
+ want := map[string]any{"title": "One", "body": "x
", "count": json.Number("12"), "on": true, "flag": true, "note": "7", "type": "acme-page", "reference": "2"}
+ if fmt.Sprint(first) != fmt.Sprint(want) {
+ t.Fatalf("first row = %v\nwant %v", first, want)
+ }
+ want = map[string]any{"title": "5", "body": nil, "count": json.Number("3.5"), "on": false, "flag": false, "note": "true", "type": nil, "reference": nil}
+ if fmt.Sprint(second) != fmt.Sprint(want) {
+ t.Fatalf("second row = %v\nwant %v", second, want)
+ }
+ })
+
+ t.Run("coercion refusals and required values are keyed per row", func(t *testing.T) {
+ _, details := ucDecode(t, cc, `{"rows":[{"title":"ok","count":"abc"},{"title":" ","on":"maybe"},{"title":{"x":1},"flag":[1]}]}`, "update")
+ for key, part := range map[string]string{
+ "rows.0.count": "must be a number",
+ "rows.1.title": "is required",
+ "rows.1.on": "must be true or false",
+ "rows.2.title": "must be text",
+ "rows.2.flag": "must be true or false",
+ } {
+ msgs, _ := details[key].([]string)
+ if len(msgs) != 1 || !strings.Contains(msgs[0], key) || !strings.Contains(msgs[0], part) {
+ t.Fatalf("%s = %v, want %q", key, details[key], part)
+ }
+ }
+ if len(details) != 5 {
+ t.Fatalf("details = %v", details)
+ }
+ })
+
+ t.Run("a missing required nested value fails", func(t *testing.T) {
+ _, details := ucDecode(t, cc, `{"rows":[{"title":"ok"},{"body":"x
"}]}`, "update")
+ if msgs, _ := details["rows.1.title"].([]string); len(msgs) != 1 || len(details) != 1 {
+ t.Fatalf("details = %v", details)
+ }
+ })
+
+ t.Run("the list shape is checked", func(t *testing.T) {
+ for _, raw := range []string{`{"rows":"x"}`, `{"rows":{"a":1}}`, `{"rows":3}`, `{"rows":[{"title":"ok"},"x"]}`, `{"rows":[[1]]}`} {
+ _, details := ucDecode(t, cc, raw, "update")
+ msgs, _ := details["rows"].([]string)
+ if len(msgs) != 1 || !strings.Contains(msgs[0], "must be a list of rows") {
+ t.Fatalf("%s: details = %v", raw, details)
+ }
+ }
+ })
+
+ t.Run("row bounds", func(t *testing.T) {
+ _, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"},{"title":"4"}]}`, "update")
+ if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMaxItems {
+ t.Fatalf("max details = %v", details)
+ }
+ _, details = ucDecode(t, cc, `{"rows":[]}`, "update")
+ if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMinItems {
+ t.Fatalf("min details = %v", details)
+ }
+ body, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"}]}`, "update")
+ if details != nil || len(body["rows"].([]any)) != 3 {
+ t.Fatalf("three rows: %v %v", body, details)
+ }
+ })
+
+ t.Run("null is an empty list and an absent repeater is left alone", func(t *testing.T) {
+ free := ucMustCompile(t, ucFields+ucRepeater(""), nil)
+ body, details := ucDecode(t, free, `{"rows":null}`, "update")
+ if details != nil || fmt.Sprint(body["rows"]) != "[]" {
+ t.Fatalf("null rows: %v %v", body, details)
+ }
+ body, details = ucDecode(t, free, `{"title":"x"}`, "create")
+ if _, present := body["rows"]; present || details != nil {
+ t.Fatalf("absent rows: %v %v", body, details)
+ }
+ })
+
+ t.Run("a required repeater needs a row", func(t *testing.T) {
+ required := ucMustCompile(t, ucFields+ucRepeater(" required: true\n"), nil)
+ for _, tc := range []struct{ raw, op string }{{`{"rows":[]}`, "update"}, {`{"rows":null}`, "create"}, {`{"title":"x"}`, "create"}} {
+ _, details := ucDecode(t, required, tc.raw, tc.op)
+ if msgs, _ := details["rows"].([]string); len(msgs) != 1 || !strings.Contains(msgs[0], "is required") {
+ t.Fatalf("%s on %s: details = %v", tc.raw, tc.op, details)
+ }
+ }
+ if _, details := ucDecode(t, required, `{"title":"x"}`, "update"); details != nil {
+ t.Fatalf("absent on update: %v", details)
+ }
+ })
+
+ t.Run("nested richeditors are sanitized with row-path notices", func(t *testing.T) {
+ projected := map[string]any{"rows": []any{
+ map[string]any{"title": "a", "body": "clean
"},
+ map[string]any{"title": "b", "body": `dirty
`},
+ map[string]any{"title": "c", "body": nil},
+ }}
+ notices := sanitizeRepeaterRichEditors(cc, projected)
+ rows := projected["rows"].([]any)
+ if rows[0].(map[string]any)["body"] != "clean
" || rows[1].(map[string]any)["body"] != "dirty
" || rows[2].(map[string]any)["body"] != nil {
+ t.Fatalf("rows = %v", rows)
+ }
+ var fields []string
+ for _, n := range notices {
+ fields = append(fields, n.field+":"+n.notice.Message)
+ }
+ if strings.Join(fields, ",") != "rows.1.body:"+msgRichRemovedElements+",rows.1.body:"+msgRichRemovedAttributes {
+ t.Fatalf("notices = %v", fields)
+ }
+ })
+
+ t.Run("stored rows hydrate to a list", func(t *testing.T) {
+ stored := lagoon.Jsonable[[]map[string]any]{Data: []map[string]any{{"title": "a"}}, Valid: true}
+ text := `[{"title":"b"}]`
+ var nilText *string
+ for _, tc := range []struct {
+ value any
+ want string
+ }{
+ {stored, "[map[title:a]]"},
+ {text, "[map[title:b]]"},
+ {&text, "[map[title:b]]"},
+ {nilText, "[]"},
+ {nil, "[]"},
+ {"not json", "[]"},
+ {"null", "[]"},
+ {[]map[string]any{{"title": "c"}}, "[map[title:c]]"},
+ } {
+ data := map[string]any{"rows": tc.value, "title": "kept"}
+ hydrateRepeaterFields(cc, data)
+ if fmt.Sprint(data["rows"]) != tc.want || data["title"] != "kept" {
+ t.Fatalf("hydrate(%#v) = %v, want %s", tc.value, data["rows"], tc.want)
+ }
+ }
+ data := map[string]any{"title": "no rows key"}
+ hydrateRepeaterFields(cc, data)
+ if _, present := data["rows"]; present {
+ t.Fatal("hydrate added a missing rows key")
+ }
+ })
+}
diff --git a/modules/cabana/richeditor_test.go b/modules/cabana/richeditor_test.go
new file mode 100644
index 0000000..a17a044
--- /dev/null
+++ b/modules/cabana/richeditor_test.go
@@ -0,0 +1,136 @@
+package cabana
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "git.golem15.com/golem15/summercms/modules/pact"
+ "git.golem15.com/golem15/summercms/modules/seaglass"
+)
+
+// TestRichEditorCompile pins D-10's Docker-free rules: the keys a richeditor
+// takes, the notices a removal report becomes, the value check before a
+// save, the projected sanitize step, and the boot refusal of the
+// controller-only features on settings forms.
+func TestRichEditorCompile(t *testing.T) {
+ t.Run("keys", func(t *testing.T) {
+ cc := ucMustCompile(t, ucFields+" description:\n type: richeditor\n label: Description\n size: large\n span: left\n comment: HTML\n required: true\n tab: Content\n default: \"x
\"\n", nil)
+ if d := ucField(t, cc, "description"); d.Type != richEditorType || d.Size != "large" || d.Tab != "Content" {
+ t.Fatalf("description = %+v", d)
+ }
+ for _, key := range []string{"options: typeOptions", "attributes: {}", "placeholder: x", "toolbarButtons: bold", "prompt: x", "maxItems: 2"} {
+ _, err := ucCompile(ucFields+" description:\n type: richeditor\n "+key+"\n", nil)
+ if err == nil {
+ t.Fatalf("richeditor accepted %s", key)
+ }
+ }
+ _, err := ucCompile(ucFields+" description:\n type: richeditor\n options: typeOptions\n", nil)
+ wantErr(t, err, "options is not valid on type: richeditor")
+ })
+
+ t.Run("notices", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ in seaglass.Removals
+ want string
+ }{
+ {"nothing removed", seaglass.Removals{}, "[]"},
+ {"elements", seaglass.Removals{Elements: []string{"script", "iframe"}, ElementCount: 3, Changed: true},
+ "[" + msgRichRemovedElements + " names=script, iframe count=3]"},
+ {"attributes", seaglass.Removals{Attributes: []string{"onclick"}, AttributeCount: 1, Changed: true},
+ "[" + msgRichRemovedAttributes + " names=onclick count=1]"},
+ {"comments", seaglass.Removals{Comments: 2, Changed: true}, "[" + msgRichRemovedComments + " names= count=2]"},
+ {"all three in order", seaglass.Removals{Elements: []string{"style"}, ElementCount: 1, Attributes: []string{"style", "rel"}, AttributeCount: 2, Comments: 1, Changed: true},
+ "[" + msgRichRemovedElements + " names=style count=1 " + msgRichRemovedAttributes + " names=style, rel count=2 " + msgRichRemovedComments + " names= count=1]"},
+ {"markup changed only", seaglass.Removals{Changed: true}, "[" + msgRichMarkupChanged + " names= count=0]"},
+ } {
+ var parts []string
+ for _, n := range richEditorNotices(tc.in) {
+ parts = append(parts, fmt.Sprintf("%s names=%s count=%d", n.Message, n.Params["names"], n.Count))
+ }
+ if got := "[" + strings.Join(parts, " ") + "]"; got != tc.want {
+ t.Fatalf("%s: notices = %s\nwant %s", tc.name, got, tc.want)
+ }
+ }
+ })
+
+ cc := ucMustCompile(t, ucFields+" description:\n type: richeditor\n"+ucRepeater(""), nil)
+
+ t.Run("a value that is not a string or null is a 422 on the field", func(t *testing.T) {
+ for _, raw := range []string{`{"description":"x
"}`, `{"description":null}`, `{"title":"no description"}`} {
+ if err := checkRichEditorValues(cc, utJSON(t, raw).(map[string]any), "update"); err != nil {
+ t.Fatalf("%s: %v", raw, err)
+ }
+ }
+ for _, raw := range []string{`{"description":1}`, `{"description":true}`, `{"description":["x
"]}`, `{"description":{"pl":"x
"}}`} {
+ err := checkRichEditorValues(cc, utJSON(t, raw).(map[string]any), "create")
+ var invalid *ValidationError
+ if !errors.As(err, &invalid) || invalid.Details["description"] == nil || len(invalid.Details) != 1 {
+ t.Fatalf("%s: err = %v", raw, err)
+ }
+ }
+ })
+
+ t.Run("projected values are sanitized, clean ones byte for byte", func(t *testing.T) {
+ clean := "It's fine & \"quoted\"
"
+ projected := map[string]any{"description": clean}
+ if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != clean {
+ t.Fatalf("clean: %v %v", projected, notices)
+ }
+ projected = map[string]any{"description": `a
`}
+ notices := sanitizeRichEditors(cc, projected)
+ if projected["description"] != "a
" || len(notices) != 2 || notices[0].field != "description" ||
+ notices[0].notice.Message != msgRichRemovedAttributes || notices[1].notice.Message != msgRichRemovedComments {
+ t.Fatalf("dirty: %v %+v", projected, notices)
+ }
+ projected = map[string]any{"description": nil, "title": ``}
+ if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != nil || projected["title"] != `` {
+ t.Fatalf("null and other fields: %v %v", projected, notices)
+ }
+ if notices := sanitizeRichEditors(cc, map[string]any{}); len(notices) != 0 {
+ t.Fatal("absent value reported")
+ }
+ })
+
+ t.Run("settings forms refuse the controller-only features", func(t *testing.T) {
+ for name, field := range map[string]string{
+ "richeditor": " type: richeditor\n",
+ "repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n",
+ "dependsOn": " type: dropdown\n dependsOn: enabled\n",
+ } {
+ fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}}
+ item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}
+ _, err := compileSetting("acme.unit", item, fsys)
+ wantErr(t, err, "setting demo field extra", "supported in controller forms only")
+ if name != "dependsOn" {
+ wantErr(t, err, name)
+ }
+ }
+ })
+
+ t.Run("controllerFormOnly", func(t *testing.T) {
+ for _, field := range []FormField{{Type: richEditorType}, {Type: repeaterType}, {Type: "dropdown", DependsOn: "type"}} {
+ if err := controllerFormOnly(field); err == nil {
+ t.Fatalf("%+v accepted", field)
+ }
+ }
+ for _, field := range []FormField{{Type: "text"}, {Type: "dropdown"}, {Type: "markdown"}} {
+ if err := controllerFormOnly(field); err != nil {
+ t.Fatalf("%+v refused: %v", field, err)
+ }
+ }
+ })
+}
+
+// ucSettings is the settings model of the settings refusal case.
+type ucSettings struct {
+ ID uint `gorm:"column:id;primaryKey"`
+ Enabled bool `gorm:"column:enabled"`
+ Extra string `gorm:"column:extra"`
+}
+
+func (*ucSettings) Fillable() []string { return []string{"enabled", "extra"} }
+func (*ucSettings) Rules() map[string]string { return map[string]string{} }
diff --git a/modules/cabana/singleton_test.go b/modules/cabana/singleton_test.go
new file mode 100644
index 0000000..f06df73
--- /dev/null
+++ b/modules/cabana/singleton_test.go
@@ -0,0 +1,113 @@
+package cabana_test
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// TestSingletonCompile pins the D-11 boot rules, Docker-free: a singleton
+// controller needs config_form.yaml and no config_list.yaml, and may keep
+// one (its create button is dropped then).
+func TestSingletonCompile(t *testing.T) {
+ if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil {
+ t.Fatalf("fixture without a list: %v", err)
+ }
+ withList := companionsOverlay(t, map[string]string{
+ "controllers/profiles/config_list.yaml": "list: ~/plugins/acme/companions/models/profile/columns.yaml\nmodelClass: Profile\ntoolbar:\n buttons: [create]\n",
+ "models/profile/columns.yaml": "columns:\n title:\n label: Title\n",
+ })
+ if err := activateCompanions(t, companionsFSPlugin{fsys: withList}); err != nil {
+ t.Fatalf("singleton with a list: %v", err)
+ }
+ err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, map[string]string{"controllers/profiles/config_form.yaml": ""})})
+ if err == nil || !strings.Contains(err.Error(), "acme.companions.profiles: a singleton controller (pact.AdminSingleton) needs config_form.yaml") {
+ t.Fatalf("singleton without a form: err = %v", err)
+ }
+ // A controller that is not a singleton still needs its list.
+ err = activateCompanions(t, companionsFSPlugin{
+ fsys: companionsOverlay(t, nil),
+ controllers: []pact.AdminController{plainProfiles{}},
+ })
+ if err == nil || !strings.Contains(err.Error(), "config_list.yaml") {
+ t.Fatalf("plain controller without a list: err = %v", err)
+ }
+}
+
+// plainProfiles is the profiles controller without pact.AdminSingleton.
+type plainProfiles struct{}
+
+func (plainProfiles) ID() string { return "acme.companions.profiles" }
+func (plainProfiles) ModelName() string { return "Profile" }
+func (plainProfiles) ConfigDir() string { return "controllers/profiles" }
+func (plainProfiles) RequiredPermissions() []string { return []string{"acme.companions.access"} }
+func (plainProfiles) NewRecord() any { return &companionsProfile{} }
+
+// TestSingleton pins the D-11 routes on Postgres: the singleton route
+// creates the record once and answers its id; the record shows and updates; list, create, delete, bulk delete
+// and reorder answer 404; the form schema says singleton; a controller that
+// is not a singleton answers 404 on the route; anonymous requests 401.
+func TestSingleton(t *testing.T) {
+ e := newCompanionsEnv(t)
+ first := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true)
+ id := dataID(t, first.Body.Bytes())
+ for range 2 {
+ if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id {
+ t.Fatalf("id = %d, want %d", again, id)
+ }
+ }
+ var count int64
+ if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
+ t.Fatalf("profiles = %d (%v)", count, err)
+ }
+ var profile companionsProfile
+ if err := e.db.First(&profile, id).Error; err != nil || profile.Key != "default" || profile.Title != "Default" {
+ t.Fatalf("profile = %+v (%v)", profile, err)
+ }
+
+ path := fmt.Sprintf("/acme/companions/profiles/%d", id)
+ if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"title":"Default"`) {
+ t.Fatalf("show = %d %s", rec.Code, rec.Body.String())
+ }
+ if rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true); rec.Code != http.StatusOK {
+ t.Fatalf("update = %d %s", rec.Code, rec.Body.String())
+ }
+ if err := e.db.First(&profile, id).Error; err != nil || profile.Title != "Renamed" || profile.Key != "default" {
+ t.Fatalf("updated profile = %+v (%v)", profile, err)
+ }
+ if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id {
+ t.Fatalf("id after update = %d", again)
+ }
+
+ for _, tc := range []struct {
+ method, path string
+ body any
+ auth bool
+ status int
+ }{
+ {http.MethodGet, "/acme/companions/profiles", nil, true, http.StatusNotFound},
+ {http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true, http.StatusNotFound},
+ {http.MethodDelete, path, nil, true, http.StatusNotFound},
+ {http.MethodPost, "/acme/companions/profiles/bulk-delete", map[string]any{"ids": []uint{id}}, true, http.StatusNotFound},
+ {http.MethodGet, "/acme/companions/profiles/reorder", nil, true, http.StatusNotFound},
+ {http.MethodGet, "/acme/companions/links/singleton", nil, true, http.StatusNotFound},
+ {http.MethodGet, "/acme/companions/profiles/singleton", nil, false, http.StatusUnauthorized},
+ } {
+ if rec := e.send(t, tc.method, tc.path, tc.body, tc.auth); rec.Code != tc.status {
+ t.Fatalf("%s %s status=%d want %d body=%s", tc.method, tc.path, rec.Code, tc.status, rec.Body.String())
+ }
+ }
+ if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 {
+ t.Fatalf("profiles after refused requests = %d (%v)", count, err)
+ }
+ schema := e.send(t, http.MethodGet, "/acme/companions/profiles/schema/form", nil, true)
+ if !strings.Contains(schema.Body.String(), `"singleton":true`) {
+ t.Fatalf("form schema = %s", schema.Body.String())
+ }
+ if links := e.send(t, http.MethodGet, "/acme/companions/links/schema/form", nil, true); strings.Contains(links.Body.String(), `"singleton":true`) {
+ t.Fatalf("a plain form schema says singleton: %s", links.Body.String())
+ }
+}
diff --git a/modules/pact/menu_item_types_test.go b/modules/pact/menu_item_types_test.go
new file mode 100644
index 0000000..e494ec7
--- /dev/null
+++ b/modules/pact/menu_item_types_test.go
@@ -0,0 +1,117 @@
+package pact
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// TestOptionDepthOmitEmpty pins that Option.Depth leaves every flat option
+// payload unchanged: depth 0 has no JSON key, a nested option carries it.
+func TestOptionDepthOmitEmpty(t *testing.T) {
+ for _, tc := range []struct {
+ opt Option
+ want string
+ }{
+ {Option{Value: "a", Label: "A"}, `{"value":"a","label":"A"}`},
+ {Option{Value: "", Label: ""}, `{"value":"","label":""}`},
+ {Option{Value: "b", Label: "B", Depth: 1}, `{"value":"b","label":"B","depth":1}`},
+ {Option{Value: "c", Label: "C", Depth: 3}, `{"value":"c","label":"C","depth":3}`},
+ } {
+ raw, err := json.Marshal(tc.opt)
+ if err != nil || string(raw) != tc.want {
+ t.Fatalf("Marshal(%+v) = %s, %v; want %s", tc.opt, raw, err, tc.want)
+ }
+ var back Option
+ if err := json.Unmarshal(raw, &back); err != nil || back != tc.opt {
+ t.Fatalf("round trip of %s = %+v, %v", raw, back, err)
+ }
+ }
+}
+
+// menuTypesPlugin is a plugin-shaped value with menu item types.
+type menuTypesPlugin struct{ types []MenuItemType }
+
+func (p menuTypesPlugin) MenuItemTypes() []MenuItemType { return p.types }
+
+// singletonController is a controller-shaped value that edits one record.
+type singletonController struct{}
+
+func (singletonController) SingletonRecord(context.Context, *gorm.DB) (any, error) { return 1, nil }
+
+// dependentController answers the options of a dependent dropdown.
+type dependentController struct{}
+
+func (dependentController) DependentOptions(_ context.Context, field string, values map[string]any) (DependentOptionSet, error) {
+ if values["type"] == nil {
+ return DependentOptionSet{Applies: false}, nil
+ }
+ return DependentOptionSet{Applies: true, Options: []Option{{Value: field, Label: "x"}}}, nil
+}
+
+var (
+ _ HasMenuItemTypes = menuTypesPlugin{}
+ _ AdminSingleton = singletonController{}
+ _ DependentOptionsProvider = dependentController{}
+)
+
+// TestMenuItemTypeContracts pins the shape of the menu item type contracts:
+// a type marshals without its functions, a resolved item keeps its
+// alternates in the order given (never a map), list types carry their
+// children in Items, and the capability interfaces are satisfied by plain
+// values.
+func TestMenuItemTypeContracts(t *testing.T) {
+ typ := MenuItemType{
+ Key: "acme-post", Label: "acme.blog::lang.post", DefaultPattern: "/blog/:slug", PatternFrom: "",
+ Placeholders: []string{":slug", ":id"}, Nesting: true, DynamicItems: false,
+ References: func(context.Context) ([]MenuItemReference, error) { return nil, nil },
+ Resolve: func(_ context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) {
+ return &ResolvedMenuItem{Title: in.Reference, URL: in.Pattern}, nil
+ },
+ }
+ raw, err := json.Marshal(typ)
+ if err != nil {
+ t.Fatalf("Marshal(MenuItemType) = %v", err)
+ }
+ if strings.Contains(string(raw), "Resolve") || strings.Contains(string(raw), "References") {
+ t.Fatalf("functions leaked into JSON: %s", raw)
+ }
+ got, err := typ.Resolve(context.Background(), MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/blog/:slug"})
+ if err != nil || got.Title != "7" || got.URL != "/blog/:slug" {
+ t.Fatalf("Resolve = %+v, %v", got, err)
+ }
+
+ when := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)
+ item := ResolvedMenuItem{
+ Title: "All posts", URL: "", MTime: &when,
+ Alternates: []MenuItemAlternate{{Locale: "pl", URL: "/a"}, {Locale: "en", URL: "/en/a"}, {Locale: "de", URL: "/de/a"}},
+ Items: []ResolvedMenuItem{{Title: "One", URL: "/blog/one"}, {Title: "Two", URL: "/blog/two"}},
+ }
+ var locales []string
+ for _, alt := range item.Alternates {
+ locales = append(locales, alt.Locale)
+ }
+ if strings.Join(locales, ",") != "pl,en,de" || item.URL != "" || len(item.Items) != 2 {
+ t.Fatalf("resolved list item = %+v", item)
+ }
+
+ var source any = menuTypesPlugin{types: []MenuItemType{typ}}
+ if p, ok := source.(HasMenuItemTypes); !ok || len(p.MenuItemTypes()) != 1 {
+ t.Fatal("HasMenuItemTypes not satisfied")
+ }
+ set, err := dependentController{}.DependentOptions(context.Background(), "reference", map[string]any{"type": nil})
+ if err != nil || set.Applies || set.Options != nil {
+ t.Fatalf("not-applying set = %+v, %v", set, err)
+ }
+ set, err = dependentController{}.DependentOptions(context.Background(), "rows.reference", map[string]any{"type": "acme-post"})
+ if err != nil || !set.Applies || set.Options[0].Value != "rows.reference" {
+ t.Fatalf("applying set = %+v, %v", set, err)
+ }
+ if rec, err := (singletonController{}).SingletonRecord(context.Background(), nil); err != nil || rec != 1 {
+ t.Fatalf("SingletonRecord = %v, %v", rec, err)
+ }
+}
diff --git a/modules/party/registry_test.go b/modules/party/registry_test.go
index db71b15..489b19f 100644
--- a/modules/party/registry_test.go
+++ b/modules/party/registry_test.go
@@ -1,6 +1,7 @@
package party
import (
+ "context"
"fmt"
"io/fs"
"os"
@@ -15,6 +16,7 @@ import (
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/postcard"
+ "git.golem15.com/golem15/summercms/modules/signpost"
)
type tracePlugin struct {
@@ -346,7 +348,6 @@ func TestActivatePublishesActivatedOrderAndRefusesSecond(t *testing.T) {
}
}
-
func TestRegisterNilIsNoop(t *testing.T) {
Register(nil)
}
@@ -527,12 +528,12 @@ type mailPlugin struct {
sawMailer *bool
}
-func (p *mailPlugin) ID() string { return p.id }
-func (p *mailPlugin) Requires() []string { return nil }
-func (p *mailPlugin) Register(*backpack.App) error { return nil }
-func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys }
-func (p *mailPlugin) MailTemplates() []string { return p.templates }
-func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts }
+func (p *mailPlugin) ID() string { return p.id }
+func (p *mailPlugin) Requires() []string { return nil }
+func (p *mailPlugin) Register(*backpack.App) error { return nil }
+func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys }
+func (p *mailPlugin) MailTemplates() []string { return p.templates }
+func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts }
func (p *mailPlugin) Boot(app *backpack.App) error {
if p.sawMailer == nil {
@@ -551,7 +552,7 @@ func TestActivatePublishesMailerBeforeBoot(t *testing.T) {
templates: []string{"golem15.hello::mail.hello"},
layouts: map[string]string{"hello": "golem15.hello::mail.layouts.hello"},
fsys: fstest.MapFS{
- "views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")},
+ "views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")},
"views/mail/layouts/hello.htm": {Data: []byte("name = \"Hello\"\n==\n{{ .Content }}\n==\n{{ .Content }}
\n")},
},
}
@@ -580,3 +581,80 @@ func TestActivateMissingMailTemplateFailsBootWithName(t *testing.T) {
t.Fatalf("error %q should name the boot plugin and missing template", msg)
}
}
+
+// menuTypesPlugin registers one menu item type and records whether the
+// signpost registry was published at Register and at Boot.
+type menuTypesPlugin struct {
+ id string
+ key string
+ log *[]string
+ atRegister *bool
+ atBoot *bool
+ typesAtBoot *int
+}
+
+func (p *menuTypesPlugin) ID() string { return p.id }
+func (p *menuTypesPlugin) Requires() []string { return nil }
+
+func (p *menuTypesPlugin) MenuItemTypes() []pact.MenuItemType {
+ *p.log = append(*p.log, p.id+":types")
+ return []pact.MenuItemType{{Key: p.key, DefaultPattern: "/:slug", Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
+ return &pact.ResolvedMenuItem{}, nil
+ }}}
+}
+
+func (p *menuTypesPlugin) Register(app *backpack.App) error {
+ *p.log = append(*p.log, p.id+":register")
+ _, ok := app.Lookup[*signpost.Registry]()
+ *p.atRegister = ok
+ return nil
+}
+
+func (p *menuTypesPlugin) Boot(app *backpack.App) error {
+ *p.log = append(*p.log, p.id+":boot")
+ reg, ok := app.Lookup[*signpost.Registry]()
+ *p.atBoot = ok
+ if ok {
+ *p.typesAtBoot = len(reg.Types())
+ }
+ return nil
+}
+
+// TestActivateMenuItemTypesOrder pins the party hook: menu item types are
+// collected after every Register and before the first Boot, so a Boot sees
+// the full registry; a duplicate key fails activation naming both plugins and
+// no plugin boots.
+func TestActivateMenuItemTypesOrder(t *testing.T) {
+ var log []string
+ var reg1, boot1, reg2, boot2 bool
+ var n1, n2 int
+ a := &menuTypesPlugin{id: "acme.a", key: "acme-a", log: &log, atRegister: ®1, atBoot: &boot1, typesAtBoot: &n1}
+ b := &menuTypesPlugin{id: "acme.b", key: "acme-b", log: &log, atRegister: ®2, atBoot: &boot2, typesAtBoot: &n2}
+ app := backpack.New(&compass.Config{})
+ if _, err := activate([]Plugin{a, b}, app, []string{"acme.a", "acme.b"}); err != nil {
+ t.Fatalf("activate: %v", err)
+ }
+ if reg1 || reg2 {
+ t.Fatal("the registry existed during Register")
+ }
+ if !boot1 || !boot2 || n1 != 2 || n2 != 2 {
+ t.Fatalf("Boot saw registry %v/%v with %d/%d types, want both and 2", boot1, boot2, n1, n2)
+ }
+ want := "acme.a:register,acme.b:register,acme.a:types,acme.b:types,acme.a:boot,acme.b:boot"
+ if got := strings.Join(log, ","); got != want {
+ t.Fatalf("order = %s\nwant %s", got, want)
+ }
+
+ log = nil
+ dupA := &menuTypesPlugin{id: "acme.a", key: "acme-same", log: &log, atRegister: ®1, atBoot: &boot1, typesAtBoot: &n1}
+ dupB := &menuTypesPlugin{id: "acme.b", key: "acme-same", log: &log, atRegister: ®2, atBoot: &boot2, typesAtBoot: &n2}
+ _, err := activate([]Plugin{dupA, dupB}, backpack.New(&compass.Config{}), []string{"acme.a", "acme.b"})
+ if err == nil || !strings.Contains(err.Error(), "party: menu item types") || !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") {
+ t.Fatalf("duplicate error = %v", err)
+ }
+ for _, entry := range log {
+ if strings.HasSuffix(entry, ":boot") {
+ t.Fatalf("a plugin booted after the duplicate: %v", log)
+ }
+ }
+}
diff --git a/modules/seaglass/seaglass_test.go b/modules/seaglass/seaglass_test.go
index 4c09194..7468983 100644
--- a/modules/seaglass/seaglass_test.go
+++ b/modules/seaglass/seaglass_test.go
@@ -1,6 +1,8 @@
package seaglass_test
import (
+ "slices"
+ "strings"
"testing"
"git.golem15.com/golem15/summercms/modules/seaglass"
@@ -48,3 +50,161 @@ func TestSanitizeByteStable(t *testing.T) {
}
}
}
+
+// TestSanitizeReport pins the removal report: element, attribute and comment
+// names and counts, the ten-name cap with its exact counts, a repeated
+// attribute, a style that lost a property, and the sanitized output itself.
+func TestSanitizeReport(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ in, out string
+ elements []string
+ elemCount int
+ attributes []string
+ attrCount int
+ comments int
+ }{
+ {
+ name: "element, attribute and comments",
+ in: `Hi
`,
+ out: `Hi
`, elements: []string{"script"}, elemCount: 1, attributes: []string{"onclick"}, attrCount: 1, comments: 2,
+ },
+ {
+ name: "names are distinct in first-seen order, counts are exact",
+ in: `1
2
`,
+ out: `1
2
`, elements: []string{"script"}, elemCount: 2, attributes: []string{"onclick", "onmouseover"}, attrCount: 3,
+ },
+ {
+ name: "ten names then an ellipsis, the count stays exact",
+ in: `
`, []string{"