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
This commit is contained in:
2026-10-12 19:29:37 +02:00
parent 490a4e3e6a
commit 91593da1f7
17 changed files with 3136 additions and 17 deletions

View File

@@ -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<Reply>((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<HTMLSelectElement>('#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<HTMLSelectElement>('#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<string, unknown> | null = null
const { wrapper } = await mountApp(
'/acme/companions/links/1',
routes('post', '99', {
[`PUT ${RECORD}`]: async (request) => {
body = (await request.json()) as Record<string, unknown>
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<HTMLSelectElement>('#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<HTMLSelectElement>('[data-repeater-row="0"] [data-field="target"] select')
expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post')
})
})

View File

@@ -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: '<p>text</p>', 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: '<p>one</p>' },
{ title: 'B', body: '<p>two</p>' },
])
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: '<p>one</p>' },
{ title: 'B', body: '<p>two</p>' },
])
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: '<p>one</p>' }], { 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)
})
})

View File

@@ -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('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
})
})
// 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: '<p></p>', attributes: {}, onChange: () => changes++, onTransaction: () => undefined })
try {
expect(editorValue(editor)).toBe('')
loadContent(editor, '<p>Hello</p>')
expect(changes).toBe(0)
expect(editorValue(editor)).toBe('<p>Hello</p>')
editor.commands.clearContent(true)
expect(changes).toBe(1)
expect(editorValue(editor)).toBe('')
editor.commands.setContent('<h3>Head</h3><ul><li><p>one</p></li></ul>', { emitUpdate: true })
expect(editorValue(editor)).toBe('<h3>Head</h3><ul><li><p>one</p></li></ul>')
// Headings other than level 3 and the dropped nodes do not survive the editor.
loadContent(editor, '<h1>Big</h1><pre><code>x</code></pre><hr>')
expect(editorValue(editor)).not.toContain('<h1')
expect(editorValue(editor)).not.toContain('<pre')
expect(editorValue(editor)).not.toContain('<hr')
} finally {
editor.destroy()
}
})
it('a value from outside replaces the document and leaves the field untouched', async () => {
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
const wrapper = mount(RichEditorField, { props: { field, modelValue: '<p>One</p>', controlId: 'f-body' }, attachTo: document.body })
await editorReady(wrapper)
await wrapper.setProps({ modelValue: '<P>Two <b>b</b></P>' })
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: '<p>a <strong>b</strong></p><ul><li>c</li></ul>', 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: '<p>a</p><iframe src="x"></iframe>', 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: '<button type="button" data-anchor>Link</button>' },
attachTo: document.body,
})
await wrapper.setProps({ open: true })
await flushPromises()
const input = () => document.body.querySelector<HTMLInputElement>('#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: '<p>x</p>' },
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: '<p>Stored <b>x</b></p>', 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<HTMLTextAreaElement>('[data-richeditor-source]')
expect(textarea.element.value).toBe('<p>Stored <b>x</b></p>')
expect(textarea.attributes('id')).toBe('f-body')
expect(wrapper.find('[role="toolbar"]').exists()).toBe(false)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await textarea.setValue('<p>Edited</p>')
expect(wrapper.emitted('update:modelValue')).toEqual([['<p>Edited</p>']])
})
})

View File

@@ -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<string, Route> = {}): Record<string, Route> {
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<string, unknown> | null = null
const { wrapper, router, calls } = await mountApp(
'/settings',
routes({
[`PUT ${PROFILES}/9`]: async (request) => {
body = (await request.json()) as Record<string, unknown>
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<HTMLInputElement>('#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')
})
})

View File

@@ -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<Reply>((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<typeof buildTree>[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 },
])
})
})

View File

@@ -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": "<p>clean</p>", "type": "acme-page", "reference": 2, "extra": "dropped"},
map[string]any{"title": true, "body": `<p onclick="x()">dirty</p><!-- c -->`, "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": "<p>clean</p>", "type": "acme-page", "reference": "2"},
{"title": "true", "body": "<p>dirty</p>", "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": "<p>x</p>"}, 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 "<nil>"
}
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", "<nil>")
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", "<nil>")
accepted(t, map[string]any{"title": "Dep", "type": "acme-list", "reference": "1"}, "acme-list", "<nil>")
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", "<nil>")
}
// 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": `<p onclick="a()" onmouseover="b()">x</p><script>1</script><iframe src="https://evil.example"></iframe><!-- c -->`}, http.StatusCreated)
if got := stored(t, id); got == nil || *got != "<p>x</p>" {
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{
"<p>It's <strong>fine</strong> &amp; \"quoted\" &nbsp;&#8212;</p>\n<ul>\n <li>one</li>\n</ul>",
`<p class='a b'><a title="t" href="https://example.com/?a=1&amp;b=2">link</a></p>`,
"",
} {
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 := "<p>kept</p>"
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{"<p>x</p>"}, map[string]any{"pl": "<p>x</p>"}} {
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")
}
}

View File

@@ -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:<nil>]]"},
{"only the value: the stored sibling decides", ucLink{Type: &page}, map[string]any{"reference": "99"}, true, "99", "[reference=map[type:acme-page]]"},
{"only the value, stored sibling accepts it", ucLink{Type: &page}, map[string]any{"reference": "1"}, false, "1", "[reference=map[type:acme-page]]"},
{"only the sibling: the stored value is checked", ucLink{Reference: &stale}, map[string]any{"type": page}, true, nil, "[reference=map[type:acme-page]]"},
{"only the sibling, stored value still valid", ucLink{Reference: &two}, map[string]any{"type": page}, false, nil, "[reference=map[type:acme-page]]"},
{"only the sibling to a type without references clears the value", ucLink{Reference: &two}, map[string]any{"type": list}, false, nil, "[reference=map[type:acme-list]]"},
{"neither written asks nothing", ucLink{Type: &page, Reference: &stale}, map[string]any{"title": "x"}, false, nil, "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
target := tc.target
details, calls, err := check(t, &target, tc.projected)
if err != nil {
t.Fatal(err)
}
if (details != nil) != tc.wantInvalid {
t.Fatalf("details = %v, want invalid %v", details, tc.wantInvalid)
}
if tc.wantInvalid {
if msgs, _ := details["reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
t.Fatalf("details = %v", details)
}
}
if got, present := tc.projected["reference"]; present || tc.wantReference != nil {
if fmt.Sprint(got) != fmt.Sprint(tc.wantReference) {
t.Fatalf("projected reference = %#v, want %#v", got, tc.wantReference)
}
}
if strings.Contains(tc.name, "stores null") || strings.Contains(tc.name, "clears the value") {
if v, present := tc.projected["reference"]; !present || v != nil {
t.Fatalf("reference not written as null: %#v (present %v)", v, present)
}
}
if fmt.Sprint(calls) != tc.wantCalls {
t.Fatalf("provider calls = %v, want %s", calls, tc.wantCalls)
}
})
}
})
t.Run("repeater rows", func(t *testing.T) {
projected := map[string]any{"rows": []any{
map[string]any{"title": "a", "type": page, "reference": "1"},
map[string]any{"title": "b", "type": page, "reference": "99"},
map[string]any{"title": "c", "type": list, "reference": "1"},
map[string]any{"title": "d", "type": page, "reference": "2"},
"not a row",
}}
details, calls, err := check(t, &ucLink{}, projected)
if err != nil {
t.Fatal(err)
}
if msgs, _ := details["rows.1.reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 {
t.Fatalf("details = %v", details)
}
rows := projected["rows"].([]any)
if rows[2].(map[string]any)["reference"] != nil || rows[0].(map[string]any)["reference"] != "1" {
t.Fatalf("rows = %v", rows)
}
// One call per distinct path and value: the second acme-page row
// and the fourth reuse the first answer.
if fmt.Sprint(calls) != "[rows.reference=map[type:acme-page] rows.reference=map[type:acme-list]]" {
t.Fatalf("provider calls = %v", calls)
}
})
t.Run("a provider error is returned, not a 422", func(t *testing.T) {
broken := "acme-broken"
_, _, err := check(t, &ucLink{}, map[string]any{"type": broken, "reference": "1"})
if err == nil || !strings.Contains(err.Error(), "references failed") {
t.Fatalf("err = %v", err)
}
})
t.Run("a form without dependsOn asks nothing", func(t *testing.T) {
cc := ucMustCompile(t, "fields:\n title:\n type: text\n", ucPlainLinks{})
if err := checkDependentValues(context.Background(), cc, nil, &ucLink{}, map[string]any{"title": "x"}, "update"); err != nil {
t.Fatal(err)
}
})
}

View File

@@ -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

View File

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

View File

@@ -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":"<p>x</p>","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": "<p>x</p>", "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":"<p>x</p>"}]}`, "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": "<p>clean</p>"},
map[string]any{"title": "b", "body": `<p onclick="x()">dirty</p><script>1</script>`},
map[string]any{"title": "c", "body": nil},
}}
notices := sanitizeRepeaterRichEditors(cc, projected)
rows := projected["rows"].([]any)
if rows[0].(map[string]any)["body"] != "<p>clean</p>" || rows[1].(map[string]any)["body"] != "<p>dirty</p>" || 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")
}
})
}

View File

@@ -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: \"<p>x</p>\"\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":"<p>x</p>"}`, `{"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":["<p>x</p>"]}`, `{"description":{"pl":"<p>x</p>"}}`} {
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 := "<p>It's <strong>fine</strong> &amp; \"quoted\"</p>"
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": `<p onclick="x()">a</p><!-- c -->`}
notices := sanitizeRichEditors(cc, projected)
if projected["description"] != "<p>a</p>" || 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": `<script>x</script>`}
if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != nil || projected["title"] != `<script>x</script>` {
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{} }

View File

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

View File

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

View File

@@ -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<div>{{ .Content }}</div>\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: &reg1, atBoot: &boot1, typesAtBoot: &n1}
b := &menuTypesPlugin{id: "acme.b", key: "acme-b", log: &log, atRegister: &reg2, 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: &reg1, atBoot: &boot1, typesAtBoot: &n1}
dupB := &menuTypesPlugin{id: "acme.b", key: "acme-same", log: &log, atRegister: &reg2, 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)
}
}
}

View File

@@ -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: `<p onclick="a()" class="x">Hi</p><script>x</script><!-- c --><!-- d -->`,
out: `<p class="x">Hi</p>`, elements: []string{"script"}, elemCount: 1, attributes: []string{"onclick"}, attrCount: 1, comments: 2,
},
{
name: "names are distinct in first-seen order, counts are exact",
in: `<p onclick="a()">1</p><p onclick="b()" onmouseover="c()">2</p><script>1</script><script>2</script>`,
out: `<p>1</p><p>2</p>`, elements: []string{"script"}, elemCount: 2, attributes: []string{"onclick", "onmouseover"}, attrCount: 3,
},
{
name: "ten names then an ellipsis, the count stays exact",
in: `<iframe></iframe><object></object><embed><form></form><input><base><meta><link><svg></svg><math></math><button></button><select></select>`,
out: "", elements: []string{"iframe", "object", "embed", "form", "input", "base", "meta", "link", "svg", "math", "…"}, elemCount: 12,
},
{
name: "a repeated attribute counts as removed",
in: `<a href="/a" href="/b">x</a>`, out: `<a href="/a">x</a>`, attributes: []string{"href"}, attrCount: 1,
},
{
name: "a style that lost a property counts under style",
in: `<p style="color: red; position: fixed">x</p>`, out: `<p style="color: red">x</p>`, attributes: []string{"style"}, attrCount: 1,
},
{
name: "target and rel are removed",
in: `<a href="https://ok" target="_blank" rel="opener">x</a>`, out: `<a href="https://ok">x</a>`, attributes: []string{"target", "rel"}, attrCount: 2,
},
{
name: "a link without an allowed href loses its tag, not its text",
in: `<p><a href="javascript:alert(1)">x</a> y</p>`, out: `<p>x y</p>`, elements: []string{"a"}, elemCount: 1,
},
{
name: "only comments",
in: `<p>a</p><!-- one -->`, out: `<p>a</p>`, comments: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
got, r := seaglass.SanitizeReport(tc.in)
if got != tc.out {
t.Fatalf("output = %q, want %q", got, tc.out)
}
if !r.Changed {
t.Fatal("Changed = false")
}
if !slices.Equal(r.Elements, tc.elements) || r.ElementCount != tc.elemCount {
t.Fatalf("elements = %v (%d), want %v (%d)", r.Elements, r.ElementCount, tc.elements, tc.elemCount)
}
if !slices.Equal(r.Attributes, tc.attributes) || r.AttributeCount != tc.attrCount {
t.Fatalf("attributes = %v (%d), want %v (%d)", r.Attributes, r.AttributeCount, tc.attributes, tc.attrCount)
}
if r.Comments != tc.comments {
t.Fatalf("comments = %d, want %d", r.Comments, tc.comments)
}
if seaglass.Sanitize(tc.in) != tc.out {
t.Fatal("Sanitize differs from SanitizeReport")
}
// The sanitized output is itself clean and stable.
again, r2 := seaglass.SanitizeReport(got)
if again != got || r2.Changed {
t.Fatalf("sanitizing the output changed it: %q %+v", again, r2)
}
})
}
}
// TestSanitizeBypassCorpus runs the payloads the bluemonday decision notes
// list as bypasses of the regex gate it replaced (no space before an event
// handler, entity-encoded and whitespace-split javascript schemes, svg
// onload, MathML xlink:href, base, meta refresh, form, data and vbscript
// URLs, a repeated href) plus the ordinary unsafe elements: each comes back
// without the dangerous construct, with Changed set, and Check refuses it.
func TestSanitizeBypassCorpus(t *testing.T) {
for _, tc := range []struct {
payload string
banned []string
}{
{`<img src="x"onerror="alert(1)">`, []string{"onerror", "alert"}},
{`<img src=x onerror=alert(1)>`, []string{"onerror", "alert"}},
{`<p onmouseover=alert(1)>x</p>`, []string{"onmouseover", "alert"}},
{`<svg/onload=alert(1)>`, []string{"<svg", "onload", "alert"}},
{`<a href="javascript&#58;alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<a href="javascript&colon;alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<a href="&#106;avascript:alert(1)">x</a>`, []string{"href", "avascript", "alert"}},
{"<a href=\"java\tscript:alert(1)\">x</a>", []string{"href", "script", "alert"}},
{`<a HREF="JaVaScRiPt:alert(1)">x</a>`, []string{"href", "javascript", "alert"}},
{`<math><mi xlink:href="javascript:alert(1)">x</mi></math>`, []string{"<math", "<mi", "xlink", "javascript"}},
{`<base href="https://evil.example/">`, []string{"<base", "evil.example"}},
{`<meta http-equiv="refresh" content="0;url=https://evil.example">`, []string{"<meta", "refresh", "evil.example"}},
{`<form action="https://evil.example"><input name="q"></form>`, []string{"<form", "<input", "evil.example"}},
{`<a href="data:text/html;base64,PHNjcmlwdD4=">x</a>`, []string{"href", "data:"}},
{`<img src="data:image/svg+xml;base64,PHN2Zz4=">`, []string{"<img", "data:"}},
{`<a href="vbscript:msgbox(1)">x</a>`, []string{"href", "vbscript"}},
{`<a href="https://a" href="javascript:alert(1)">x</a>`, []string{"javascript", "alert"}},
{`<div style="background:url(javascript:alert(1))">x</div>`, []string{"style", "javascript"}},
{`<iframe src="https://evil.example"></iframe>`, []string{"<iframe", "evil.example"}},
{`<p>x</p><script>alert(1)</script>`, []string{"<script", "alert"}},
{`<p>x</p><style>p{display:none}</style>`, []string{"<style", "display"}},
{`<object data="x.swf"></object><embed src="x.swf">`, []string{"<object", "<embed", "swf"}},
} {
t.Run(tc.payload, func(t *testing.T) {
got, r := seaglass.SanitizeReport(tc.payload)
lower := strings.ToLower(got)
for _, bad := range tc.banned {
if strings.Contains(lower, strings.ToLower(bad)) {
t.Fatalf("%q survived: %q", bad, got)
}
}
if !r.Changed {
t.Fatalf("Changed = false for %q -> %q", tc.payload, got)
}
if err := seaglass.Check(tc.payload); err == nil {
t.Fatal("Check accepted the payload")
}
})
}
}
// TestCheck pins Check: nil when the policy keeps the HTML whole (including
// text that only looks like a scheme, the WR-01 false refusals), an error
// prefixed "seaglass: " when it would change anything.
func TestCheck(t *testing.T) {
for _, html := range []string{
"",
"<p>Data: 12 maja 2024, metadata: none</p>",
"<p>javascript: is just a word here</p>",
`<p>It's "fine"</p>`,
`<a href="tel:+48123">call</a>`,
} {
if err := seaglass.Check(html); err != nil {
t.Errorf("Check(%q) = %v", html, err)
}
}
for _, html := range []string{
"<!-- a comment -->",
`<a href="https://a" href="https://b">dup</a>`,
`<p style="position: fixed">x</p>`,
`<a href="ftp://example.com/x">ftp</a>`,
} {
err := seaglass.Check(html)
if err == nil || !strings.HasPrefix(err.Error(), "seaglass: ") {
t.Errorf("Check(%q) = %v, want a seaglass error", html, err)
}
}
}

View File

@@ -1,6 +1,9 @@
package signpost_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
@@ -44,3 +47,290 @@ func TestRegistryDuplicateKey(t *testing.T) {
t.Fatalf("duplicate inside one plugin: error = %v", err)
}
}
// TestRegistryActivate pins every boot refusal of Activate and what a
// successful activation publishes.
func TestRegistryActivate(t *testing.T) {
ok := func(key, pattern string) pact.MenuItemType {
return pact.MenuItemType{Key: key, DefaultPattern: pattern, Resolve: echo}
}
for _, tc := range []struct {
name string
host string
types []pact.MenuItemType
want []string
}{
{"empty key", "", []pact.MenuItemType{ok("", "/a")}, []string{"acme.blog", "empty key"}},
{"blank key", "", []pact.MenuItemType{ok(" ", "/a")}, []string{"empty key"}},
{"nil Resolve", "", []pact.MenuItemType{{Key: "acme-a", DefaultPattern: "/a"}}, []string{`"acme-a"`, "acme.blog", "no Resolve"}},
{"empty DefaultPattern", "", []pact.MenuItemType{ok("acme-a", "")}, []string{`"acme-a"`, "DefaultPattern"}},
{"relative DefaultPattern", "", []pact.MenuItemType{ok("acme-a", "blog/:slug")}, []string{`"acme-a"`, `"blog/:slug"`}},
{"PatternFrom names an unregistered type", "", []pact.MenuItemType{
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-missing", Resolve: echo},
}, []string{`"acme-posts"`, `"acme-missing"`, "no plugin registers"}},
{"PatternFrom names the type itself", "", []pact.MenuItemType{
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-posts", Resolve: echo},
}, []string{`"acme-posts"`, "names itself"}},
{"PatternFrom names a type with its own PatternFrom", "", []pact.MenuItemType{
ok("acme-post", "/a"),
{Key: "acme-posts", DefaultPattern: "/a", PatternFrom: "acme-post", Resolve: echo},
{Key: "acme-all", DefaultPattern: "/a", PatternFrom: "acme-posts", Resolve: echo},
}, []string{`"acme-all"`, `"acme-posts"`, "one level only"}},
{"host override of an unknown type", "patterns:\n acme-missing: /x\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns", `"acme-missing"`}},
{"relative host override", "patterns:\n acme-post: news/:slug\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns.acme-post", `"news/:slug"`}},
{"host patterns not a map", "patterns:\n - /x\n", []pact.MenuItemType{ok("acme-post", "/a")}, []string{"menu_items.patterns", "map"}},
} {
t.Run(tc.name, func(t *testing.T) {
app := smokeApp(t, tc.host)
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: tc.types}})
if err == nil {
t.Fatal("Activate accepted the types")
}
if !strings.HasPrefix(err.Error(), "signpost: ") {
t.Fatalf("error %q lacks the signpost prefix", err)
}
for _, part := range tc.want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error %q does not contain %q", err, part)
}
}
if _, published := app.Lookup[*signpost.Registry](); published {
t.Fatal("a registry was published after a refusal")
}
})
}
t.Run("nil app", func(t *testing.T) {
if err := signpost.Activate[typesPlugin](nil, nil); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
t.Fatalf("nil app error = %v", err)
}
})
t.Run("publishes types in plugin then declaration order", func(t *testing.T) {
app := smokeApp(t, "")
err := signpost.Activate(app, []typesPlugin{
{id: "acme.blog", types: []pact.MenuItemType{ok("acme-b", "/b"), ok("acme-a", "/a")}},
{id: "acme.none"},
{id: "acme.pages", types: []pact.MenuItemType{ok("acme-c", "/c")}},
})
if err != nil {
t.Fatal(err)
}
reg, found := app.Lookup[*signpost.Registry]()
if !found {
t.Fatal("no registry published")
}
var keys []string
for _, typ := range reg.Types() {
keys = append(keys, typ.Key)
}
if strings.Join(keys, ",") != "acme-b,acme-a,acme-c" {
t.Fatalf("types = %v", keys)
}
types := reg.Types()
types[0].Key = "changed"
if again := reg.Types(); again[0].Key != "acme-b" {
t.Fatal("Types returned the registry's own slice")
}
if typ, ok := reg.Type("acme-c"); !ok || typ.DefaultPattern != "/c" {
t.Fatalf("Type(acme-c) = %+v, %v", typ, ok)
}
if _, ok := reg.Type("acme-missing"); ok {
t.Fatal("Type found an unregistered key")
}
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{ok("acme-x", "/x")}}}); err == nil {
t.Fatal("a second Activate on the same app was accepted")
}
})
t.Run("a plugin without menu item types publishes an empty registry", func(t *testing.T) {
app := smokeApp(t, "")
if err := signpost.Activate(app, []typesPlugin{{id: "acme.none"}}); err != nil {
t.Fatal(err)
}
reg, found := app.Lookup[*signpost.Registry]()
if !found || len(reg.Types()) != 0 {
t.Fatalf("registry = %v, %v", reg, found)
}
})
t.Run("a nil registry answers empty", func(t *testing.T) {
var reg *signpost.Registry
if len(reg.Types()) != 0 || reg.Pattern("acme-post", "") != "" || reg.Pattern("acme-post", "/x") != "/x" {
t.Fatal("nil registry answered")
}
if _, ok := reg.Type("acme-post"); ok {
t.Fatal("nil registry found a type")
}
if _, err := reg.Resolve(context.Background(), "acme-post", "", "", false, ""); err == nil {
t.Fatal("nil registry resolved")
}
})
}
// TestRegistryHostOverrideUnknown pins the host override checks: an override
// of an unregistered key fails the boot with the key named, while an absent
// menu_items file or an empty patterns map is no override at all.
func TestRegistryHostOverrideUnknown(t *testing.T) {
err := signpost.Activate(smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-typo: /x/:slug\n"), []typesPlugin{{id: "acme.blog", types: acmeTypes()}})
if err == nil || !strings.Contains(err.Error(), `"acme-typo"`) || !strings.Contains(err.Error(), "no plugin registers") {
t.Fatalf("unknown override error = %v", err)
}
for name, cfg := range map[string]string{"no file": "", "empty patterns": "patterns: {}\n", "other key only": "other: 1\n"} {
app := smokeApp(t, cfg)
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
t.Fatalf("%s: %v", name, err)
}
reg, _ := app.Lookup[*signpost.Registry]()
if got := reg.Pattern("acme-post", ""); got != "/blog/:slug" {
t.Fatalf("%s: Pattern = %q, want the default", name, got)
}
}
}
// TestRegistryPatternLayers pins D-02 over the record type acme-post and the
// list type acme-posts (PatternFrom acme-post): for the record type, the item
// override beats the host override, which beats the default; for the list
// type, the item override beats the list key's host override, which beats the
// record type's host override, which beats the list's DefaultPattern.
func TestRegistryPatternLayers(t *testing.T) {
types := func() []pact.MenuItemType {
return []pact.MenuItemType{
{Key: "acme-post", DefaultPattern: "/post-default/:slug", Resolve: echo},
{Key: "acme-posts", DefaultPattern: "/posts-default/:slug", PatternFrom: "acme-post", Resolve: echo},
}
}
const (
item = "/item/:slug"
listHost = "/list-host/:slug"
recHost = "/record-host/:slug"
)
for _, tc := range []struct {
item, listHost, recHost string
wantList, wantRecord string
}{
{"", "", "", "/posts-default/:slug", "/post-default/:slug"},
{"", "", recHost, recHost, recHost},
{"", listHost, "", listHost, "/post-default/:slug"},
{"", listHost, recHost, listHost, recHost},
{item, "", "", item, item},
{item, "", recHost, item, item},
{item, listHost, "", item, item},
{item, listHost, recHost, item, item},
{" ", "", recHost, recHost, recHost},
} {
name := fmt.Sprintf("item=%q list=%q record=%q", tc.item, tc.listHost, tc.recHost)
t.Run(name, func(t *testing.T) {
cfg := ""
if tc.listHost != "" || tc.recHost != "" {
cfg = "patterns:\n"
if tc.listHost != "" {
cfg += " acme-posts: " + tc.listHost + "\n"
}
if tc.recHost != "" {
cfg += " acme-post: " + tc.recHost + "\n"
}
}
app := smokeApp(t, cfg)
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: types()}}); err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
if got := reg.Pattern("acme-posts", tc.item); got != tc.wantList {
t.Fatalf("list Pattern = %q, want %q", got, tc.wantList)
}
if got := reg.Pattern("acme-post", tc.item); got != tc.wantRecord {
t.Fatalf("record Pattern = %q, want %q", got, tc.wantRecord)
}
// Resolve hands the type exactly the layered pattern.
resolved, err := reg.Resolve(context.Background(), "acme-posts", "ref-1", tc.item, true, "pl")
if err != nil || resolved.URL != tc.wantList {
t.Fatalf("Resolve = %+v, %v", resolved, err)
}
})
}
t.Run("Resolve passes reference, nesting and locale", func(t *testing.T) {
var got pact.MenuItemResolve
capture := func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
got = in
return &pact.ResolvedMenuItem{URL: in.Pattern}, nil
}
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n")
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
{Key: "acme-post", DefaultPattern: "/blog/:slug", Resolve: capture},
}}})
if err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
var noCtx context.Context // a nil context is accepted and replaced
if _, err := reg.Resolve(noCtx, "acme-post", "7", "", true, "pl"); err != nil {
t.Fatal(err)
}
want := pact.MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/news/:slug", Nesting: true, UnprefixedLocale: "pl"}
if got != want {
t.Fatalf("Resolve input = %+v, want %+v", got, want)
}
if _, err := reg.Resolve(context.Background(), "acme-missing", "", "", false, ""); err == nil || !strings.Contains(err.Error(), `"acme-missing"`) {
t.Fatalf("unknown type error = %v", err)
}
})
}
// TestReferenceOptions pins the one admin binding: a reference tree is
// flattened depth first with Depth the nesting level, a type without
// References or an unknown type answers Applies false with no options, and a
// References error is returned with the type named.
func TestReferenceOptions(t *testing.T) {
tree := []pact.MenuItemReference{
{Value: "1", Label: "Root", Items: []pact.MenuItemReference{
{Value: "2", Label: "Child", Items: []pact.MenuItemReference{{Value: "3", Label: "Grandchild"}}},
{Value: "4", Label: "Second child"},
}},
{Value: "5", Label: "acme.blog::lang.other"},
}
app := smokeApp(t, "")
err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: []pact.MenuItemType{
{Key: "acme-tree", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return tree, nil }},
{Key: "acme-empty", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return nil, nil }},
{Key: "acme-broken", DefaultPattern: "/:slug", Resolve: echo, References: func(context.Context) ([]pact.MenuItemReference, error) { return nil, errors.New("db down") }},
{Key: "acme-none", DefaultPattern: "/:slug", Resolve: echo},
}}})
if err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
set, err := signpost.ReferenceOptions(context.Background(), reg, "acme-tree")
if err != nil || !set.Applies {
t.Fatalf("tree = %+v, %v", set, err)
}
want := []pact.Option{
{Value: "1", Label: "Root", Depth: 0},
{Value: "2", Label: "Child", Depth: 1},
{Value: "3", Label: "Grandchild", Depth: 2},
{Value: "4", Label: "Second child", Depth: 1},
{Value: "5", Label: "acme.blog::lang.other", Depth: 0},
}
if fmt.Sprint(set.Options) != fmt.Sprint(want) {
t.Fatalf("options = %+v, want %+v", set.Options, want)
}
empty, err := signpost.ReferenceOptions(context.Background(), reg, "acme-empty")
if err != nil || !empty.Applies || empty.Options == nil || len(empty.Options) != 0 {
t.Fatalf("type with no references yet = %+v, %v (applies, empty non-nil options)", empty, err)
}
for _, key := range []string{"acme-none", "acme-missing", ""} {
none, err := signpost.ReferenceOptions(context.Background(), reg, key)
if err != nil || none.Applies || none.Options == nil || len(none.Options) != 0 {
t.Fatalf("%q = %+v, %v (applies false, empty non-nil options)", key, none, err)
}
}
if none, err := signpost.ReferenceOptions(context.Background(), nil, "acme-tree"); err != nil || none.Applies {
t.Fatalf("nil registry = %+v, %v", none, err)
}
if _, err := signpost.ReferenceOptions(context.Background(), reg, "acme-broken"); err == nil || !strings.Contains(err.Error(), `"acme-broken"`) || !strings.Contains(err.Error(), "db down") {
t.Fatalf("references error = %v", err)
}
}

View File

@@ -60,14 +60,17 @@ FAQ_LAYER="${FAQ_LAYER:-$ROOT/../vue-faq-layer}"
# must pass; a name that skips or does not run fails the stage.
# go stage: Docker-free framework suites (-short).
SIGNPOST_TESTS=(TestSignpostSmoke TestRegistryDuplicateKey)
SEAGLASS_TESTS=(TestSeaglassSmoke TestSanitizeByteStable)
SIGNPOST_TESTS=(TestSignpostSmoke TestRegistryDuplicateKey TestRegistryActivate TestRegistryHostOverrideUnknown TestRegistryPatternLayers TestReferenceOptions)
SEAGLASS_TESTS=(TestSeaglassSmoke TestSanitizeByteStable TestSanitizeReport TestSanitizeBypassCorpus TestCheck)
PACT_TESTS=(TestOptionDepthOmitEmpty TestMenuItemTypeContracts)
PARTY_TESTS=(TestActivateMenuItemTypesOrder)
CABANA_SHORT_TESTS=(TestRepeaterCompile TestRepeaterDecode TestDependentDropdownCompile TestDependentValues TestRichEditorCompile TestReorderConfigCompile TestSingletonCompile TestPhase10ConformanceInventory)
# plugins stage: Docker-free plugin and host suites (-short).
SITEMAP_SHORT_TESTS=(TestSitemapGolden TestSitemapTiersConfig)
# postgres stage: testcontainers suites.
PG_CABANA_TESTS=(TestCompanionsDependentSmoke TestCompanionsRichEditorSmoke TestCompanionsRepeaterSmoke TestCompanionsReorderSmoke TestCompanionsSingletonSmoke TestPhase10OpenAPIConformance)
PG_CABANA_TESTS=(TestCompanionsDependentSmoke TestCompanionsRichEditorSmoke TestCompanionsRepeaterSmoke TestCompanionsReorderSmoke TestCompanionsSingletonSmoke TestRepeaterSave TestDependentRecheck TestRichEditor TestReorderPostgres TestSingleton TestPhase10OpenAPIConformance)
PG_JOURNAL_TESTS=(TestMenuItemTypesSmoke)
PG_SITEMAP_TESTS=(TestSitemapTracer TestSitemapImportSmoke TestSitemapAdminSmoke)
PG_MENU_TESTS=(TestMenuAdminSmoke TestMenuImportSmoke TestMenuAPISmoke)
@@ -80,14 +83,55 @@ ADMIN_FILES=(
tests/form/RepeaterField.test.ts
tests/form/DependentDropdown.test.ts
tests/views/ReorderView.test.ts
tests/form/SingletonForm.test.ts
)
ADMIN_REQUIRE=(
"RichEditorField RichEditorField tracer: untouched value is submitted byte-identical"
"RichEditorField RichEditorField edit emits editor HTML"
"RepeaterField RepeaterField tracer: add, edit nested, reorder and remove change form state only"
"DependentDropdown DependentDropdown loads options for the sibling value and clears a stale value"
"DependentDropdown waits for the sibling, and offers a retry when loading fails"
"DependentDropdown states shows loading while the options load, then the empty state naming the type"
"DependentDropdown states drops a late answer for an old sibling value: the last request wins"
"DependentDropdown states shows the placeholder for a stored value no option offers, without clearing it"
"DependentDropdown states a dropdown inside a repeater row asks with the row path and its row sibling"
"RepeaterField RepeaterField tracer: add, edit nested, reorder and remove change form state only"
"RepeaterField expands rows with path errors, shows the chip and focuses the first invalid nested control"
"RepeaterField shows the empty state with the prompt"
"RepeaterField rows enforces minItems and maxItems on remove, add and the counter"
"RepeaterField rows asks before removing a row with a value, never for an empty or default row, and keeps it on cancel"
"RepeaterField rows reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends"
"RepeaterField rows collapsed rows do not mount their richeditors and keep their values"
"RepeaterField rows read-only shows every row expanded with no controls"
"RichEditorField RichEditorField tracer: untouched value is submitted byte-identical"
"RichEditorField shows the lossy note for markup outside the editor schema and keeps the HTML view lossless"
"RichEditorField sends the editor HTML after an edit and shows what the server removed"
"RichEditorField RichEditorField edit emits editor HTML"
"RichEditorField renders read-only values through the display policy, never as stored"
"richeditor policy helpers accepts policy links only"
"richeditor policy helpers compares tag names case-insensitively for the lossy note"
"richeditor policy helpers unwraps unknown elements and drops scripts for display"
"RichEditorField value contract an empty paragraph is the empty string, any content its HTML"
"RichEditorField value contract a value from outside replaces the document and leaves the field untouched"
"RichEditorField value contract shows the lossy note only for markup outside the editor schema"
"RichEditorField value contract reads empty and read-only values"
"richeditor link popover refuses a URL outside the allowlist and applies a policy URL on Enter"
"richeditor link popover starts from the current link and offers Remove link inside one"
"richeditor notices callout lists every notice of the field in one warning callout after a save"
"RichEditorField chunk failure falls back to the HTML view with the load_failed note, and saving still works"
"SingletonForm opens through /singleton from settings, has no Delete, and Save and close returns to settings"
"SingletonForm back returns to the settings index"
"SingletonForm a link to a controller that is not a singleton opens its list"
"SingletonForm a modified click opens the link as it is, without asking for the record"
"SingletonForm a form without the singleton flag keeps Delete and returns to its list"
"ReorderView ReorderView tracer: a keyboard move sends one debounced POST with the full node set"
"ReorderView restores the saved tree when a save fails and reloads on a stale set"
"ReorderView shows the empty state and the 403 alert"
"list Reorder button links to the reorder page with the filtered partition"
"singleton form opens the one record from settings, without Delete, and returns to the settings index"
"ReorderView moves announces every keyboard move and moves children with their parent"
"ReorderView moves shows the loading skeleton, then the load failure alert"
"ReorderView moves a partition switch sends the pending save first, then loads the chosen partition"
"reorder tree rules builds the tree by sort order and puts unknown parents and cycles at the top"
"reorder tree rules refuses a drop into the node itself or its own subtree and leaves the tree as it was"
"reorder tree rules moves only where a sibling or parent exists, and flattens pre-order from 1"
)
# layers stage: test files (must exist and match the test script's glob) and
@@ -467,6 +511,9 @@ run_go() {
(cd "$ROOT" && go vet ./...)
go_named "$ROOT" ./modules/signpost -short "${SIGNPOST_TESTS[@]}"
go_named "$ROOT" ./modules/seaglass -short "${SEAGLASS_TESTS[@]}"
go_named "$ROOT" ./modules/pact -short "${PACT_TESTS[@]}"
go_named "$ROOT" ./modules/party -short "${PARTY_TESTS[@]}"
go_named "$ROOT" ./modules/cabana -short "${CABANA_SHORT_TESTS[@]}"
echo "phase17 go passed"
}