diff --git a/admin/tests/form/DependentDropdown.test.ts b/admin/tests/form/DependentDropdown.test.ts index 3be9aff..6922707 100644 --- a/admin/tests/form/DependentDropdown.test.ts +++ b/admin/tests/form/DependentDropdown.test.ts @@ -126,3 +126,110 @@ describe('DependentDropdown', () => { expect(wrapper.find('#field-reference').text()).toContain('Hello world') }) }) + +// Plan 06 coverage: every UI-SPEC state, last request wins, stale stored +// values, the save recheck message and a dropdown inside a repeater row. +function deferred() { + let resolve: (reply: Reply) => void = () => undefined + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('DependentDropdown states', () => { + it('shows loading while the options load, then the empty state naming the type', async () => { + const pending = deferred() + const { wrapper } = await mountApp( + '/acme/companions/links/1', + routes('category', null, { [`GET ${REFERENCES}`]: () => pending.promise }), + { attach: true }, + ) + const select = () => wrapper.find('#field-reference') + expect(wrapper.find('[data-field="reference"] [data-dependent]').attributes('data-dependent')).toBe('loading') + expect(select().attributes('disabled')).toBeDefined() + expect(select().attributes('aria-busy')).toBe('true') + expect(select().text()).toBe('Wczytywanie…') + + pending.resolve({ body: { data: { applies: true, options: [] }, meta: {} } }) + await flushPromises() + expect(select().attributes('aria-busy')).toBeUndefined() + expect(select().attributes('disabled')).toBeDefined() + const hint = wrapper.find('[data-dependent-hint]') + expect(hint.text()).toBe('Brak rekordów typu „Category”. Najpierw dodaj rekord albo wybierz inny typ.') + expect(select().attributes('aria-describedby')).toContain(hint.attributes('id')) + }) + + it('drops a late answer for an old sibling value: the last request wins', async () => { + const slow = deferred() + const { wrapper, calls } = await mountApp( + '/acme/companions/links/1', + routes('post', '7', { + [`GET ${REFERENCES}`]: (request) => (queryOf(request).get('values[type]') === 'category' ? slow.promise : references(request)), + }), + { attach: true }, + ) + const select = () => wrapper.find('#field-reference') + await wrapper.find('#field-type').setValue('2') // category: pending + await flushPromises() + expect(select().text()).toBe('Wczytywanie…') + await wrapper.find('#field-type').setValue('0') // url: answered at once + await flushPromises() + expect(select().text()).toBe('Nie dotyczy tego wyboru') + + slow.resolve({ body: { data: { applies: true, options: [{ value: '3', label: 'News', depth: 0 }] }, meta: {} } }) + await flushPromises() + expect(select().text()).toBe('Nie dotyczy tego wyboru') + // The dropped answer was not cached: choosing category again asks again. + await wrapper.find('#field-type').setValue('2') + await flushPromises() + expect(requestsTo(calls, 'GET', REFERENCES).filter((request) => queryOf(request).get('values[type]') === 'category')).toHaveLength(2) + }) + + it('shows the placeholder for a stored value no option offers, without clearing it', async () => { + let body: Record | null = null + const { wrapper } = await mountApp( + '/acme/companions/links/1', + routes('post', '99', { + [`PUT ${RECORD}`]: async (request) => { + body = (await request.json()) as Record + return { + status: 422, + body: { error: { code: 'validation_failed', message: 'Validation failed', details: { reference: ['Wybierz jedną z dostępnych opcji.'] } } }, + } + }, + }), + { attach: true }, + ) + const select = wrapper.find('#field-reference') + expect(select.element.selectedOptions[0]!.text).toBe('Wybierz…') + expect(wrapper.find('[data-form-live]').text()).toBe('') + await wrapper.find('[data-action="save"]').trigger('click') + await flushPromises() + expect(body).toMatchObject({ type: 'post', reference: '99' }) + expect(wrapper.find('[data-field="reference"]').text()).toContain('Wybierz jedną z dostępnych opcji.') + expect(wrapper.find('#field-reference').attributes('aria-invalid')).toBe('true') + }) + + it('a dropdown inside a repeater row asks with the row path and its row sibling', async () => { + const TARGETS = `${BASE}/fields/rows.target/dependent-options` + const { wrapper, calls } = await mountApp( + '/acme/companions/links/1', + routes('url', null, { + [`GET ${RECORD}`]: { + body: { data: { id: 1, title: 'Docs', type: 'url', reference: null, rows: [{ question: 'Q', kind: 'post', target: '8', visible: true }] }, meta: {} }, + }, + [`GET ${TARGETS}`]: (request) => + queryOf(request).get('values[kind]') === 'post' + ? { body: { data: { applies: true, options: [{ value: '7', label: 'Hello world', depth: 0 }, { value: '8', label: 'Nested post', depth: 1 }] }, meta: {} } } + : { status: 500, body: {} }, + }), + { attach: true }, + ) + const asked = requestsTo(calls, 'GET', TARGETS) + expect(asked).toHaveLength(1) + expect(queryOf(asked[0]!).get('values[kind]')).toBe('post') + const target = wrapper.find('[data-repeater-row="0"] [data-field="target"] select') + expect(target.element.selectedOptions[0]!.text.trim()).toBe('Nested post') + }) +}) diff --git a/admin/tests/form/RepeaterField.test.ts b/admin/tests/form/RepeaterField.test.ts index 19f284d..6c6d81f 100644 --- a/admin/tests/form/RepeaterField.test.ts +++ b/admin/tests/form/RepeaterField.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { ref } from 'vue' +import type { AdminRecord, FormField } from '../../src/api/types' +// The registry first: it imports RepeaterField, whose FormGrid imports it back. +import '../../src/components/form/registry' +import RepeaterField from '../../src/components/form/fields/RepeaterField.vue' +import { FORM_FEEDBACK, type FormFeedback } from '../../src/components/form/formContext' import { companionsFormSchemaFixture } from '../fixtures/typed' import { API, keydown, mountApp, requestsTo, resetState, type Route } from '../helpers' @@ -164,3 +170,172 @@ describe('RepeaterField', () => { expect(wrapper.find('[data-action="repeater-add"]').text()).toBe('Add question') }) }) + +// Plan 06 coverage on the field alone, with a recording form feedback: +// row bounds, remove and reorder semantics, collapsed rows, read-only. +const field: FormField = { + name: 'rows', + label: 'Rows', + type: 'repeater', + span: 'full', + titleFrom: 'title', + minItems: 1, + maxItems: 3, + form: { + fields: [ + { name: 'title', type: 'text', label: 'Title', span: 'left' }, + { name: 'body', type: 'richeditor', label: 'Body', span: 'full' }, + { name: 'on', type: 'checkbox', label: 'On', span: 'right', default: true }, + ], + }, +} as FormField + +function repeater(rows: AdminRecord[], options: { confirm?: boolean; readOnly?: boolean } = {}) { + const announced: string[] = [] + const asked: string[] = [] + const feedback: FormFeedback = { + errors: ref({}), + notices: ref({}), + clearError: () => undefined, + announce: (text) => announced.push(text), + confirm: async (request) => { + asked.push(request.message) + return options.confirm ?? true + }, + } + const wrapper: VueWrapper = mount(RepeaterField, { + props: { + field: options.readOnly ? { ...field, readOnly: true } : field, + modelValue: rows, + controlId: 'field-rows', + 'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }), + }, + global: { provide: { [FORM_FEEDBACK as symbol]: feedback } }, + attachTo: document.body, + }) + const values = () => (wrapper.props() as { modelValue: AdminRecord[] }).modelValue + return { wrapper, announced, asked, values } +} + +const rowsOf = (wrapper: VueWrapper) => wrapper.findAll('[data-repeater-title]').map((node) => node.text()) +const row = (wrapper: VueWrapper, index: number) => wrapper.find(`[data-repeater-row="${index}"]`) + +describe('RepeaterField rows', () => { + it('enforces minItems and maxItems on remove, add and the counter', async () => { + const { wrapper, values } = repeater([{ title: 'Only', body: null, on: true }]) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 1 z 3') + expect(wrapper.find('[data-action="repeater-toggle-all"]').exists()).toBe(false) + + await wrapper.find('[data-action="repeater-add"]').trigger('click') + await wrapper.find('[data-action="repeater-add"]').trigger('click') + await flushPromises() + expect(values()).toEqual([ + { title: 'Only', body: null, on: true }, + { title: null, body: null, on: true }, + { title: null, body: null, on: true }, + ]) + expect(rowsOf(wrapper)).toEqual(['Only', 'Pozycja 2', 'Pozycja 3']) + expect(wrapper.find('[data-action="repeater-add"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-repeater-count]').text()).toBe('Pozycje: 3 z 3') + // A fourth add does nothing. + await wrapper.find('[data-action="repeater-add"]').trigger('click') + expect(values()).toHaveLength(3) + expect(row(wrapper, 0).find('[data-action="repeater-remove"]').attributes('disabled')).toBeUndefined() + }) + + it('asks before removing a row with a value, never for an empty or default row, and keeps it on cancel', async () => { + const { wrapper, asked, values } = repeater( + [ + { title: 'Kept', body: null, on: true }, + { title: '', body: '', on: true }, + { title: null, body: '

text

', on: true }, + ], + { confirm: false }, + ) + // Only defaults and empty values: removed at once. + await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click') + await flushPromises() + expect(asked).toEqual([]) + expect(values()).toHaveLength(2) + // A rich text value: asked, and cancel keeps the row. + await row(wrapper, 1).find('[data-action="repeater-remove"]').trigger('click') + await flushPromises() + expect(asked).toEqual(['Usunąć „Pozycja 2”? Zmiana zostanie zapisana razem z rekordem.']) + expect(values()).toHaveLength(2) + expect(rowsOf(wrapper)).toEqual(['Kept', 'Pozycja 2']) + // Removing moves focus to the handle that takes the row's place. + const confirmed = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }], { confirm: true }) + await row(confirmed.wrapper, 1).find('[data-action="repeater-remove"]').trigger('click') + await flushPromises() + expect(confirmed.asked).toHaveLength(1) + expect(rowsOf(confirmed.wrapper)).toEqual(['A', 'C']) + expect(document.activeElement).toBe(row(confirmed.wrapper, 1).find('[data-repeater-handle]').element) + }) + + it('reorders by the arrow keys and the buttons, announces repeater.moved and stops at the ends', async () => { + const { wrapper, announced, values } = repeater([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) + expect(row(wrapper, 0).find('[data-action="repeater-up"]').attributes('disabled')).toBeDefined() + expect(row(wrapper, 2).find('[data-action="repeater-down"]').attributes('disabled')).toBeDefined() + + keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowUp') + await flushPromises() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + + const down = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowDown') + await flushPromises() + expect(down.defaultPrevented).toBe(true) + expect(rowsOf(wrapper)).toEqual(['B', 'A', 'C']) + expect(document.activeElement).toBe(row(wrapper, 1).find('[data-repeater-handle]').element) + expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.']) + + await row(wrapper, 2).find('[data-action="repeater-up"]').trigger('click') + await flushPromises() + expect(rowsOf(wrapper)).toEqual(['B', 'C', 'A']) + await row(wrapper, 0).find('[data-action="repeater-down"]').trigger('click') + await flushPromises() + expect(rowsOf(wrapper)).toEqual(['C', 'B', 'A']) + expect(announced).toEqual(['Przeniesiono A na pozycję 2 z 3.', 'Przeniesiono C na pozycję 2 z 3.', 'Przeniesiono B na pozycję 2 z 3.']) + expect(values().map((item) => item.title)).toEqual(['C', 'B', 'A']) + // Other keys do nothing. + const other = keydown(row(wrapper, 0).find('[data-repeater-handle]').element, 'ArrowRight') + expect(other.defaultPrevented).toBe(false) + }) + + it('collapsed rows do not mount their richeditors and keep their values', async () => { + const { wrapper, values } = repeater([ + { title: 'A', body: '

one

' }, + { title: 'B', body: '

two

' }, + ]) + const editors = () => wrapper.findAll('[data-field="body"]').length + expect(editors()).toBe(2) + await row(wrapper, 0).find('[data-action="repeater-toggle"]').trigger('click') + expect(row(wrapper, 0).find('[data-repeater-body]').exists()).toBe(false) + expect(row(wrapper, 0).find('[data-action="repeater-toggle"]').attributes('aria-expanded')).toBe('false') + expect(editors()).toBe(1) + + const all = wrapper.find('[data-action="repeater-toggle-all"]') + expect(all.text()).toBe('Zwiń wszystkie') + await all.trigger('click') + expect(editors()).toBe(0) + expect(wrapper.find('[data-action="repeater-toggle-all"]').text()).toBe('Rozwiń wszystkie') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(values()).toEqual([ + { title: 'A', body: '

one

' }, + { title: 'B', body: '

two

' }, + ]) + + await wrapper.find('[data-action="repeater-toggle-all"]').trigger('click') + expect(editors()).toBe(2) + expect(row(wrapper, 1).find('[data-action="repeater-toggle"]').attributes('aria-controls')).toBe(row(wrapper, 1).find('[data-repeater-body]').attributes('id')) + }) + + it('read-only shows every row expanded with no controls', () => { + const { wrapper } = repeater([{ title: 'A', body: '

one

' }], { readOnly: true }) + expect(wrapper.find('[data-repeater-handle]').exists()).toBe(false) + expect(wrapper.find('[data-action="repeater-add"]').exists()).toBe(false) + expect(wrapper.find('[data-action="repeater-remove"]').exists()).toBe(false) + expect(wrapper.find('[data-repeater-body]').exists()).toBe(true) + }) +}) diff --git a/admin/tests/form/RichEditorField.test.ts b/admin/tests/form/RichEditorField.test.ts index 0920e63..53749ec 100644 --- a/admin/tests/form/RichEditorField.test.ts +++ b/admin/tests/form/RichEditorField.test.ts @@ -4,7 +4,7 @@ import type { FormField } from '../../src/api/types' import RichEditorField from '../../src/components/form/fields/RichEditorField.vue' import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html' import { isLossy } from '../../src/components/form/richeditor/lossy' -import { companionsFormSchemaFixture } from '../fixtures/typed' +import { companionsFormSchemaFixture, langFixture } from '../fixtures/typed' import { API, mountApp, requestsTo, resetState, type Route } from '../helpers' // Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06 @@ -188,3 +188,173 @@ describe('richeditor policy helpers', () => { expect(displayHtml('

a

')).toBe('

a

') }) }) + +// Plan 06 coverage: the value contract, the link popover, the notices +// callout and the chunk-failure fallback (UI-SPEC RichEditorField). +describe('RichEditorField value contract', () => { + it('an empty paragraph is the empty string, any content its HTML', async () => { + const { createRichEditor, editorValue, loadContent } = await import('../../src/components/form/richeditor/editor') + const element = document.createElement('div') + document.body.appendChild(element) + let changes = 0 + const editor = createRichEditor({ element, content: '

', attributes: {}, onChange: () => changes++, onTransaction: () => undefined }) + try { + expect(editorValue(editor)).toBe('') + loadContent(editor, '

Hello

') + expect(changes).toBe(0) + expect(editorValue(editor)).toBe('

Hello

') + editor.commands.clearContent(true) + expect(changes).toBe(1) + expect(editorValue(editor)).toBe('') + editor.commands.setContent('

Head

  • one

', { emitUpdate: true }) + expect(editorValue(editor)).toBe('

Head

  • one

') + // Headings other than level 3 and the dropped nodes do not survive the editor. + loadContent(editor, '

Big

x

') + expect(editorValue(editor)).not.toContain(' { + const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField + const wrapper = mount(RichEditorField, { props: { field, modelValue: '

One

', controlId: 'f-body' }, attachTo: document.body }) + await editorReady(wrapper) + await wrapper.setProps({ modelValue: '

Two b

' }) + await flushPromises() + expect(wrapper.find('[data-richeditor-area]').text()).toBe('Two b') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + // An empty value shows the bare editing area and emits nothing. + await wrapper.setProps({ modelValue: null }) + await flushPromises() + expect(wrapper.find('[data-richeditor-area]').text()).toBe('') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('shows the lossy note only for markup outside the editor schema', async () => { + const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField + const clean = mount(RichEditorField, { props: { field, modelValue: '

a b

  • c
', controlId: 'f-a' }, attachTo: document.body }) + await editorReady(clean) + expect(clean.find('[data-richeditor-lossy]').exists()).toBe(false) + const lossy = mount(RichEditorField, { props: { field, modelValue: '

a

', controlId: 'f-b' }, attachTo: document.body }) + await editorReady(lossy) + expect(lossy.find('[data-richeditor-lossy]').text()).toContain('Części formatowania tego tekstu nie da się pokazać w edytorze.') + }) + + it('reads empty and read-only values', () => { + const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField + const empty = mount(RichEditorField, { props: { field, modelValue: '', controlId: 'f-body' } }) + expect(empty.find('[data-richeditor-readonly]').text()).toBe('—') + expect(empty.find('[data-richeditor-loading]').exists()).toBe(false) + }) +}) + +describe('richeditor link popover', () => { + async function popover(props: { href?: string; inLink?: boolean } = {}) { + const LinkPopover = (await import('../../src/components/form/richeditor/LinkPopover.vue')).default + const wrapper = mount(LinkPopover, { + props: { open: false, href: props.href ?? '', inLink: props.inLink ?? false, idPrefix: 'f-body' }, + slots: { default: '' }, + attachTo: document.body, + }) + await wrapper.setProps({ open: true }) + await flushPromises() + const input = () => document.body.querySelector('#f-body-link-url')! + return { wrapper, input } + } + + it('refuses a URL outside the allowlist and applies a policy URL on Enter', async () => { + const { wrapper, input } = await popover() + expect(document.activeElement).toBe(input()) + for (const bad of ['javascript:alert(1)', 'data:text/html,x', 'ftp://example.test/x']) { + input().value = bad + input().dispatchEvent(new Event('input')) + ;(document.body.querySelector('[data-action="richeditor-link-apply"]') as HTMLElement).click() + await flushPromises() + expect(wrapper.emitted('apply')).toBeUndefined() + expect(document.body.querySelector('#f-body-link-error')?.textContent).toContain('Link musi używać http, https, mailto, tel albo adresu względnego.') + expect(input().getAttribute('aria-invalid')).toBe('true') + } + // Typing clears the error; Enter applies the trimmed URL and never submits. + input().value = ' https://example.test/a ' + input().dispatchEvent(new Event('input')) + await flushPromises() + expect(document.body.querySelector('#f-body-link-error')).toBeNull() + const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + input().dispatchEvent(enter) + await flushPromises() + expect(enter.defaultPrevented).toBe(true) + expect(wrapper.emitted('apply')).toEqual([['https://example.test/a']]) + expect(document.body.querySelector('[data-action="richeditor-unlink"]')).toBeNull() + }) + + it('starts from the current link and offers Remove link inside one', async () => { + const { wrapper, input } = await popover({ href: 'mailto:a@example.test', inLink: true }) + expect(input().value).toBe('mailto:a@example.test') + ;(document.body.querySelector('[data-action="richeditor-unlink"]') as HTMLElement).click() + await flushPromises() + expect(wrapper.emitted('remove')).toHaveLength(1) + }) +}) + +describe('richeditor notices callout', () => { + it('lists every notice of the field in one warning callout after a save', async () => { + const { wrapper } = await mountApp( + '/acme/companions/links/1', + routes({ + [`PUT ${RECORD}`]: { + body: { + data: { id: 1, title: 'Docs', description: '

x

' }, + meta: { + notices: [ + { field: 'description', message: 'Usunięto 2 elementy: script, iframe' }, + { field: 'description', message: 'Usunięto 1 atrybut: onclick' }, + { field: 'title', message: 'other field' }, + ], + }, + }, + }, + }), + { attach: true }, + ) + await editorReady(wrapper) + await wrapper.find('[data-action="save"]').trigger('click') + await flushPromises() + const callout = wrapper.find('[data-field="description"] [data-field-notice]') + expect(callout.attributes('role')).toBe('status') + expect(callout.classes()).toContain('summer-callout--warning') + expect(callout.findAll('p').map((line) => line.text())).toEqual(['Usunięto 2 elementy: script, iframe', 'Usunięto 1 atrybut: onclick']) + }) +}) + +// Last in the file: it replaces the editor chunk for the rest of the run. +describe('RichEditorField chunk failure', () => { + afterEach(() => { + vi.doUnmock('../../src/components/form/richeditor/editor') + }) + + it('falls back to the HTML view with the load_failed note, and saving still works', async () => { + vi.resetModules() + vi.doMock('../../src/components/form/richeditor/editor', () => { + throw new Error('chunk failed') + }) + // The fresh module graph has its own i18n state: load the strings there. + ;(await import('../../src/app/i18n')).setBundle(langFixture.data, 'pl') + const Field = (await import('../../src/components/form/fields/RichEditorField.vue')).default + const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField + const wrapper = mount(Field, { props: { field, modelValue: '

Stored x

', controlId: 'f-body' }, attachTo: document.body }) + await vi.waitFor(() => { + expect(wrapper.find('[data-richeditor-load-failed]').exists()).toBe(true) + }) + expect(wrapper.find('[data-richeditor-load-failed]').text()).toBe('Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio.') + const textarea = wrapper.find('[data-richeditor-source]') + expect(textarea.element.value).toBe('

Stored x

') + expect(textarea.attributes('id')).toBe('f-body') + expect(wrapper.find('[role="toolbar"]').exists()).toBe(false) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await textarea.setValue('

Edited

') + expect(wrapper.emitted('update:modelValue')).toEqual([['

Edited

']]) + }) +}) diff --git a/admin/tests/form/SingletonForm.test.ts b/admin/tests/form/SingletonForm.test.ts new file mode 100644 index 0000000..2bc991e --- /dev/null +++ b/admin/tests/form/SingletonForm.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises } from '@vue/test-utils' +import { setSettings } from '../../src/state/useSettings' +import { clone, companionsFormSchemaFixture } from '../fixtures/typed' +import { API, mountApp, notFound, requestsTo, resetState, type Route } from '../helpers' + +// Phase 17 plan 06 specs for the singleton form (D-11, UI-SPEC "Singleton +// form"): a settings link to a pact.AdminSingleton controller opens its one +// record through GET …/singleton; the form has no Delete, and back, Cancel +// and Save and close return to the settings index. +const PROFILES = `${API}/acme/companions/profiles` + +function singletonSchema() { + const schema = clone(companionsFormSchemaFixture) + schema.data.singleton = true + schema.data.fields = schema.data.fields.filter((field) => field.name === 'title') + return schema +} + +function routes(overrides: Record = {}): Record { + return { + [`GET ${PROFILES}/singleton`]: { body: { data: { id: 9 }, meta: {} } }, + [`GET ${PROFILES}/schema/form`]: { body: singletonSchema() }, + [`GET ${PROFILES}/9`]: { body: { data: { id: 9, title: 'Main site' }, meta: {} } }, + ...overrides, + } +} + +beforeEach(() => { + resetState() + setSettings([ + { + code: 'profile', + label: 'Site profile', + description: 'The one site profile', + icon: 'settings', + category: 'CMS', + keywords: [], + model: '', + controller: 'acme.companions.profiles', + order: 1, + }, + ]) +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('SingletonForm', () => { + it('opens through /singleton from settings, has no Delete, and Save and close returns to settings', async () => { + let body: Record | null = null + const { wrapper, router, calls } = await mountApp( + '/settings', + routes({ + [`PUT ${PROFILES}/9`]: async (request) => { + body = (await request.json()) as Record + return { body: { data: { id: 9, ...body }, meta: {} } } + }, + }), + { attach: true }, + ) + const link = wrapper.find('[data-settings="profile"]') + expect(link.attributes('href')).toBe('/acme/companions/profiles') + await link.trigger('click', { button: 0 }) + await flushPromises() + expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1) + expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9') + expect(wrapper.find('[data-action="delete"]').exists()).toBe(false) + expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień') + expect(wrapper.find('#field-title').element.value).toBe('Main site') + + // Save stays on the form. + await wrapper.find('#field-title').setValue('Renamed') + await wrapper.find('[data-action="save"]').trigger('click') + await flushPromises() + expect(body).toEqual({ title: 'Renamed' }) + expect(router.currentRoute.value.path).toBe('/acme/companions/profiles/9') + + // Save and close returns to the settings index. + await wrapper.find('[data-action="save-close"]').trigger('click') + await flushPromises() + expect(requestsTo(calls, 'PUT', `${PROFILES}/9`)).toHaveLength(2) + expect(router.currentRoute.value.path).toBe('/settings') + }) + + it('back returns to the settings index', async () => { + const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes(), { attach: true }) + expect(wrapper.find('[data-action="delete"]').exists()).toBe(false) + await wrapper.find('[data-action="back"]').trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe('/settings') + }) + + it('a link to a controller that is not a singleton opens its list', async () => { + const { wrapper, router, calls } = await mountApp( + '/settings', + routes({ [`GET ${PROFILES}/singleton`]: { status: 404, body: notFound } }), + { attach: true }, + ) + await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0 }) + await flushPromises() + expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(1) + expect(router.currentRoute.value.path).toBe('/acme/companions/profiles') + }) + + it('a modified click opens the link as it is, without asking for the record', async () => { + const { wrapper, router, calls } = await mountApp('/settings', routes(), { attach: true }) + await wrapper.find('[data-settings="profile"]').trigger('click', { button: 0, ctrlKey: true }) + await flushPromises() + expect(requestsTo(calls, 'GET', `${PROFILES}/singleton`)).toHaveLength(0) + expect(router.currentRoute.value.path).toBe('/settings') + }) + + it('a form without the singleton flag keeps Delete and returns to its list', async () => { + const schema = singletonSchema() + schema.data.singleton = false + const { wrapper, router } = await mountApp('/acme/companions/profiles/9', routes({ [`GET ${PROFILES}/schema/form`]: { body: schema } }), { attach: true }) + expect(wrapper.find('[data-action="delete"]').exists()).toBe(true) + await wrapper.find('[data-action="back"]').trigger('click') + await flushPromises() + expect(router.currentRoute.value.path).toBe('/acme/companions/profiles') + }) +}) diff --git a/admin/tests/views/ReorderView.test.ts b/admin/tests/views/ReorderView.test.ts index ce4ff87..9474794 100644 --- a/admin/tests/views/ReorderView.test.ts +++ b/admin/tests/views/ReorderView.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { applyDrop, applyMove, buildTree, canDrop, canMove, flatten, type TreeNode } from '../../src/components/reorder/tree' import { setSettings } from '../../src/state/useSettings' import { toasts } from '../../src/state/useToasts' import { clone, companionsFormSchemaFixture, listFixture, listSchemaFixture } from '../fixtures/typed' -import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' +import { API, keydown, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers' // Phase 17 plan 02 smoke specs for the tree reorder page (D-09), the list's // Reorder button and the singleton form (D-11). Plan 06 completes them. @@ -219,3 +220,155 @@ describe('singleton form', () => { expect(router.currentRoute.value.path).toBe('/settings') }) }) + +// Plan 06 coverage: announcements per keyboard move, children moving with +// their parent, loading and load failure, the partition switch flushing a +// pending save, and the tree rules (no drop into the own subtree). +describe('ReorderView moves', () => { + it('announces every keyboard move and moves children with their parent', async () => { + let body: { nodes: unknown[] } | null = null + const { wrapper, calls } = await mountApp( + '/acme/companions/links/reorder', + { + [`GET ${REORDER}`]: tree(), + [`POST ${REORDER}`]: async (request) => { + body = (await request.json()) as { nodes: unknown[] } + return tree() + }, + }, + { attach: true }, + ) + const live = () => wrapper.find('[data-reorder-live]').text() + + keydown(handle(wrapper, 2).element, 'ArrowUp') + await flushPromises() + expect(shape(wrapper)).toBe('Blog(News) Home Bez tytułu (#4)') + await vi.waitFor(() => expect(live()).toBe('Przeniesiono Blog na pozycję 1 z 3.')) + + keydown(handle(wrapper, 1).element, 'ArrowRight') + await flushPromises() + expect(shape(wrapper)).toBe('Blog(News Home) Bez tytułu (#4)') + await vi.waitFor(() => expect(live()).toBe('Home jest teraz wewnątrz Blog.')) + + keydown(handle(wrapper, 3).element, 'ArrowLeft') + await flushPromises() + expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)') + await vi.waitFor(() => expect(live()).toBe('Przeniesiono News na poziom 1.')) + expect(document.activeElement).toBe(handle(wrapper, 3).element) + + // A move that cannot run changes and announces nothing. + const before = live() + keydown(handle(wrapper, 2).element, 'ArrowUp') + await flushPromises() + expect(shape(wrapper)).toBe('Blog(Home) News Bez tytułu (#4)') + expect(live()).toBe(before) + + await vi.waitFor(() => expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1)) + expect(body!.nodes).toEqual([ + { id: 2, parent_id: null, sort_order: 1 }, + { id: 1, parent_id: 2, sort_order: 2 }, + { id: 3, parent_id: null, sort_order: 3 }, + { id: 4, parent_id: null, sort_order: 4 }, + ]) + }) + + it('shows the loading skeleton, then the load failure alert', async () => { + let release: (reply: Reply) => void = () => undefined + const pending = new Promise((resolve) => { + release = resolve + }) + const { wrapper } = await mountApp('/acme/companions/links/reorder', { [`GET ${REORDER}`]: () => pending }) + expect(wrapper.find('[data-reorder-card]').attributes('aria-busy')).toBe('true') + expect(wrapper.findAll('[data-reorder-skeleton]')).toHaveLength(5) + release({ status: 500, body: { error: { code: 'x', message: 'x', details: {} } } }) + await flushPromises() + expect(wrapper.find('[role="alert"]').text()).toContain('Nie udało się wczytać rekordów.') + expect(wrapper.find('[data-reorder-card]').exists()).toBe(false) + }) + + it('a partition switch sends the pending save first, then loads the chosen partition', async () => { + const order: string[] = [] + const { wrapper, router, calls } = await mountApp( + '/acme/companions/links/reorder', + { + [`GET ${REORDER}`]: (request) => { + order.push(`GET ${queryOf(request).get('partition') ?? '-'}`) + return tree() + }, + [`POST ${REORDER}`]: async (request) => { + const sent = (await request.json()) as { partition: number } + order.push(`POST ${sent.partition}`) + return tree() + }, + }, + { attach: true }, + ) + keydown(handle(wrapper, 1).element, 'ArrowDown') + await flushPromises() + await wrapper.find('[data-reorder-partition]').setValue('6') + await flushPromises() + expect(router.currentRoute.value.query.partition).toBe('6') + expect(order).toEqual(['GET -', 'POST 5', 'GET 6']) + await wait(500) + expect(requestsTo(calls, 'POST', REORDER)).toHaveLength(1) + }) +}) + +describe('reorder tree rules', () => { + // The OpenAPI document types parent_id as a number although the wire + // carries null at the top (17-02 deferred item 3), hence the cast. + type Wire = Parameters[0] + const nodes = [ + { id: 1, name: 'Home', parent_id: null, sort_order: 2 }, + { id: 2, name: 'Blog', parent_id: null, sort_order: 1 }, + { id: 3, name: 'News', parent_id: 2, sort_order: 1 }, + { id: 4, name: 'Deep', parent_id: 3, sort_order: 1 }, + { id: 5, name: 'Orphan', parent_id: 99, sort_order: 3 }, + ] as unknown as Wire + const names = (list: TreeNode[]): string => list.map((node) => (node.children.length ? `${node.name}(${names(node.children)})` : node.name)).join(' ') + + it('builds the tree by sort order and puts unknown parents and cycles at the top', () => { + expect(names(buildTree(nodes))).toBe('Blog(News(Deep)) Home Orphan') + const cycle = buildTree([ + { id: 1, name: 'A', parent_id: 2, sort_order: 1 }, + { id: 2, name: 'B', parent_id: 1, sort_order: 2 }, + { id: 3, name: 'C', parent_id: null, sort_order: 3 }, + ] as unknown as Wire) + expect(names(cycle).split(' ').sort()).toEqual(['A(B)', 'C'].sort()) + }) + + it('refuses a drop into the node itself or its own subtree and leaves the tree as it was', () => { + const tree = buildTree(nodes) + for (const target of [2, 3, 4]) { + expect(canDrop(tree, 2, target)).toBe(false) + for (const zone of ['before', 'after', 'inside'] as const) { + expect(applyDrop(tree, 2, target, zone)).toBe(false) + } + } + expect(names(tree)).toBe('Blog(News(Deep)) Home Orphan') + expect(canDrop(tree, 4, 2)).toBe(true) + expect(applyDrop(tree, 4, 1, 'inside')).toBe(true) + expect(applyDrop(tree, 5, 2, 'before')).toBe(true) + expect(applyDrop(tree, 3, 1, 'after')).toBe(true) + expect(names(tree)).toBe('Orphan Blog Home(Deep) News') + }) + + it('moves only where a sibling or parent exists, and flattens pre-order from 1', () => { + const tree = buildTree(nodes) + expect(canMove(tree, 2, 'up')).toBe(false) + expect(canMove(tree, 2, 'indent')).toBe(false) + expect(canMove(tree, 2, 'outdent')).toBe(false) + expect(canMove(tree, 5, 'down')).toBe(false) + expect(canMove(tree, 99, 'down')).toBe(false) + expect(applyMove(tree, 4, 'outdent')).toBe(true) + expect(applyMove(tree, 1, 'indent')).toBe(true) + expect(names(tree)).toBe('Blog(News Deep Home) Orphan') + expect(flatten(tree)).toEqual([ + { id: 2, parent_id: null, sort_order: 1 }, + { id: 3, parent_id: 2, sort_order: 2 }, + { id: 4, parent_id: 2, sort_order: 3 }, + { id: 1, parent_id: 2, sort_order: 4 }, + { id: 5, parent_id: null, sort_order: 5 }, + ]) + }) +}) diff --git a/modules/cabana/companions_postgres_test.go b/modules/cabana/companions_postgres_test.go new file mode 100644 index 0000000..d18fc7a --- /dev/null +++ b/modules/cabana/companions_postgres_test.go @@ -0,0 +1,435 @@ +package cabana_test + +import ( + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// Phase 17 plan 06 tests of the companion form features through the admin +// routes on Postgres (the acme.companions fixture of companions_smoke_test.go) +// and their boot refusals through cabana.Activate (Docker-free). + +// companionsOverlay is the acme.companions fixture tree with files replaced; +// an empty value removes the file. +func companionsOverlay(t *testing.T, files map[string]string) fstest.MapFS { + t.Helper() + out := fstest.MapFS{} + err := fs.WalkDir(os.DirFS(companionsDir), ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + raw, err := os.ReadFile(filepath.Join(companionsDir, path)) + if err != nil { + return err + } + out[path] = &fstest.MapFile{Data: raw} + return nil + }) + if err != nil { + t.Fatal(err) + } + for path, data := range files { + if data == "" { + delete(out, path) + continue + } + out[path] = &fstest.MapFile{Data: []byte(data)} + } + return out +} + +// companionsFile is one file of the fixture tree. +func companionsFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(companionsDir, path)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// companionsFSPlugin is the acme.companions fixture served from another +// tree, optionally with other controllers or settings. +type companionsFSPlugin struct { + companionsPlugin + fsys fs.FS + controllers []pact.AdminController + settings []pact.SettingsItem +} + +func (p companionsFSPlugin) AdminFS() fs.FS { return p.fsys } +func (p companionsFSPlugin) AdminControllers() []pact.AdminController { + if p.controllers != nil { + return p.controllers + } + return p.companionsPlugin.AdminControllers() +} +func (p companionsFSPlugin) Settings() []pact.SettingsItem { + if p.settings != nil { + return p.settings + } + return p.companionsPlugin.Settings() +} + +// activateCompanions boots the admin of p without a database. +func activateCompanions(t *testing.T, p party.Plugin) error { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions-boot\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) + if err != nil { + t.Fatal(err) + } + _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p}) + return err +} + +// companionsLinkRows reads every link row by id. +func companionsLinkRows(t *testing.T, e *conformEnv) map[uint]companionsLink { + t.Helper() + var rows []companionsLink + if err := e.db.Order("id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + out := make(map[uint]companionsLink, len(rows)) + for _, row := range rows { + out[row.ID] = row + } + return out +} + +// sameLinkRows fails unless the link table is exactly before. +func sameLinkRows(t *testing.T, e *conformEnv, before map[uint]companionsLink, what string) { + t.Helper() + a, err := json.Marshal(companionsLinkRows(t, e)) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(before) + if err != nil { + t.Fatal(err) + } + if string(a) != string(b) { + t.Fatalf("%s wrote rows:\nbefore %s\nafter %s", what, b, a) + } +} + +// wantStatus fails unless rec answered status, and returns the body. +func wantStatus(t *testing.T, what string, rec interface { + Result() *http.Response +}, status int, body string) { + t.Helper() + if code := rec.Result().StatusCode; code != status { + t.Fatalf("%s: status=%d want %d body=%s", what, code, status, body) + } +} + +// companionsError decodes the code and details of an error envelope. +func companionsError(t *testing.T, raw []byte) (string, map[string][]string) { + t.Helper() + var body struct { + Error struct { + Code string `json:"code"` + Details map[string][]string `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("error body %s: %v", raw, err) + } + return body.Error.Code, body.Error.Details +} + +// TestRepeaterSave pins the D-07 save through the links routes: rows keep +// only their nested keys with scalars coerced, required applies per row +// with row-path errors, maxItems is enforced, nested richeditors are +// sanitized with row-path notices, nested dependsOn is rechecked, a refused +// save writes nothing, and the record response and show hydrate the rows. +func TestRepeaterSave(t *testing.T) { + e := newCompanionsEnv(t) + rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "rows": []any{ + map[string]any{"title": 5, "body": "

clean

", "type": "acme-page", "reference": 2, "extra": "dropped"}, + map[string]any{"title": true, "body": `

dirty

`, "type": "acme-list", "reference": "1"}, + map[string]any{"title": "third"}, + }}, true) + wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String()) + created := decodeCompanionsRecord(t, rec.Body.Bytes()) + id := dataID(t, rec.Body.Bytes()) + var stored companionsLink + if err := e.db.First(&stored, id).Error; err != nil { + t.Fatal(err) + } + rows := stored.Rows.Get() + want := []map[string]any{ + {"title": "5", "body": "

clean

", "type": "acme-page", "reference": "2"}, + {"title": "true", "body": "

dirty

", "type": "acme-list", "reference": nil}, + {"title": "third", "body": nil, "type": nil, "reference": nil}, + } + if fmt.Sprint(rows) != fmt.Sprint(want) { + t.Fatalf("stored rows = %v\nwant %v", rows, want) + } + var noticed []string + for _, n := range created.Meta.Notices { + noticed = append(noticed, n.Field+": "+n.Message) + } + if len(noticed) != 2 || !strings.HasPrefix(noticed[0], "rows.1.body: Usunięto 1 atrybut: onclick") || !strings.HasPrefix(noticed[1], "rows.1.body: Usunięto 1 komentarz HTML") { + t.Fatalf("notices = %v", noticed) + } + if list, ok := created.Data["rows"].([]any); !ok || len(list) != 3 { + t.Fatalf("response rows = %#v", created.Data["rows"]) + } + + path := fmt.Sprintf("/acme/companions/links/%d", id) + before := companionsLinkRows(t, e) + for _, tc := range []struct { + name string + rows any + keys []string + }{ + {"per-row required", []any{map[string]any{"title": "ok"}, map[string]any{"body": "

x

"}, map[string]any{"title": " "}}, []string{"rows.1.title", "rows.2.title"}}, + {"nested dependent value", []any{map[string]any{"title": "a", "type": "acme-page", "reference": "99"}}, []string{"rows.0.reference"}}, + {"coercion", []any{map[string]any{"title": map[string]any{"x": 1}}}, []string{"rows.0.title"}}, + {"not a list", "rows", []string{"rows"}}, + {"a row that is not an object", []any{"x"}, []string{"rows"}}, + {"above maxItems", []any{map[string]any{"title": "1"}, map[string]any{"title": "2"}, map[string]any{"title": "3"}, map[string]any{"title": "4"}, map[string]any{"title": "5"}, map[string]any{"title": "6"}}, []string{"rows"}}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Rows", "rows": tc.rows}, true) + wantStatus(t, tc.name, rec, http.StatusUnprocessableEntity, rec.Body.String()) + _, details := companionsError(t, rec.Body.Bytes()) + for _, key := range tc.keys { + if len(details[key]) != 1 { + t.Fatalf("details = %v, want %s", details, key) + } + } + if len(details) != len(tc.keys) { + t.Fatalf("details = %v, want exactly %v", details, tc.keys) + } + if tc.name == "above maxItems" && details["rows"][0] != "Usuń pozycje, aby zostało ich najwyżej 5." { + t.Fatalf("max message = %q", details["rows"][0]) + } + if tc.name == "nested dependent value" && details["rows.0.reference"][0] != "Wybierz jedną z dostępnych opcji." { + t.Fatalf("dependent message = %q", details["rows.0.reference"][0]) + } + sameLinkRows(t, e, before, tc.name) + }) + } + + t.Run("an update without rows keeps them, null stores none", func(t *testing.T) { + rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true) + wantStatus(t, "update", rec, http.StatusOK, rec.Body.String()) + var again companionsLink + if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 3 || again.Title != "Renamed" { + t.Fatalf("rows after an update without rows = %+v (%v)", again, err) + } + rec = e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed", "rows": nil}, true) + wantStatus(t, "null rows", rec, http.StatusOK, rec.Body.String()) + if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 0 { + t.Fatalf("rows after null = %+v (%v)", again.Rows, err) + } + shown := e.send(t, http.MethodGet, path, nil, true) + if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[]`) { + t.Fatalf("show = %d %s", shown.Code, shown.Body.String()) + } + }) +} + +// TestDependentRecheck pins D-08 through the routes: the options route per +// sibling value (tree depths, Applies false, unknown and empty values), its +// 404s and 401, and the save recheck at the top level, with the stored +// sibling or value used when only the other is sent; a refused save writes +// nothing. +func TestDependentRecheck(t *testing.T) { + e := newCompanionsEnv(t) + options := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult { + t.Helper() + rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true) + wantStatus(t, field+" "+typ, rec, http.StatusOK, rec.Body.String()) + var body cabana.Envelope[cabana.AdminDependentOptionsResult] + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.Data + } + for _, field := range []string{"reference", "rows.reference"} { + page := options(t, field, "acme-page") + if !page.Applies || fmt.Sprint(page.Options) != "[{1 Home 0} {2 About 1}]" { + t.Fatalf("%s acme-page = %+v", field, page) + } + for _, typ := range []string{"acme-list", "acme-unknown", "url", ""} { + if none := options(t, field, typ); none.Applies || none.Options == nil || len(none.Options) != 0 { + t.Fatalf("%s %q = %+v", field, typ, none) + } + } + } + for _, path := range []string{dependentOptionsPath("title", "acme-page"), dependentOptionsPath("type", "acme-page"), dependentOptionsPath("rows.title", "acme-page"), dependentOptionsPath("missing", "acme-page"), dependentOptionsPath("rows.missing", "acme-page")} { + if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusNotFound { + t.Fatalf("%s status=%d", path, rec.Code) + } + } + if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous status=%d", rec.Code) + } + + rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, true) + wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String()) + id := dataID(t, rec.Body.Bytes()) + path := fmt.Sprintf("/acme/companions/links/%d", id) + load := func(t *testing.T) companionsLink { + t.Helper() + var link companionsLink + if err := e.db.First(&link, id).Error; err != nil { + t.Fatal(err) + } + return link + } + str := func(p *string) string { + if p == nil { + return "" + } + return *p + } + if link := load(t); str(link.Type) != "acme-page" || str(link.Reference) != "2" { + t.Fatalf("created = %+v", link) + } + refused := func(t *testing.T, body map[string]any) { + t.Helper() + before := companionsLinkRows(t, e) + rec := e.send(t, http.MethodPut, path, body, true) + wantStatus(t, "refused", rec, http.StatusUnprocessableEntity, rec.Body.String()) + code, details := companionsError(t, rec.Body.Bytes()) + if code != "validation_failed" || len(details) != 1 || len(details["reference"]) != 1 || details["reference"][0] != "Wybierz jedną z dostępnych opcji." { + t.Fatalf("details = %s %v", code, details) + } + sameLinkRows(t, e, before, "a refused dependent save") + } + refused(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "99"}) + refused(t, map[string]any{"title": "Dep", "reference": "99"}) // stored type acme-page decides + refused(t, map[string]any{"title": "Dep", "reference": 7}) + + accepted := func(t *testing.T, body map[string]any, wantType, wantRef string) { + t.Helper() + rec := e.send(t, http.MethodPut, path, body, true) + wantStatus(t, "accepted", rec, http.StatusOK, rec.Body.String()) + if link := load(t); str(link.Type) != wantType || str(link.Reference) != wantRef { + t.Fatalf("after %v: type=%s reference=%s, want %s/%s", body, str(link.Type), str(link.Reference), wantType, wantRef) + } + } + accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1") + accepted(t, map[string]any{"title": "Dep", "reference": 2}, "acme-page", "2") + accepted(t, map[string]any{"title": "Dep", "reference": nil}, "acme-page", "") + accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1") + // Only the sibling: the stored value is checked against the new type, + // and a type without references clears it. + accepted(t, map[string]any{"title": "Dep", "type": "acme-list"}, "acme-list", "") + accepted(t, map[string]any{"title": "Dep", "type": "acme-list", "reference": "1"}, "acme-list", "") + accepted(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, "acme-page", "2") + accepted(t, map[string]any{"title": "Dep", "type": "url", "reference": "2"}, "url", "") +} + +// TestRichEditor pins D-10 through the routes: a save stores the sanitized +// value with notices in the request locale, clean HTML byte for byte with +// none, null stays null, a value that is not a string is a 422 that writes +// nothing; and settings and relation forms refuse the type at boot. +func TestRichEditor(t *testing.T) { + t.Run("boot refusals", func(t *testing.T) { + for name, field := range map[string]string{ + "richeditor": " body:\n type: richeditor\n", + "repeater": " body:\n type: repeater\n form:\n fields:\n a:\n type: text\n", + } { + err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + field})}, nil) + if err == nil || !strings.Contains(err.Error(), "field body: "+name+" is supported in controller forms only") { + t.Fatalf("relation form with %s: err = %v", name, err) + } + fsys := companionsOverlay(t, map[string]string{"models/settings/fields.yaml": "fields:\n title:\n type: text\n body:\n" + strings.TrimPrefix(field, " body:\n")}) + err = activateCompanions(t, companionsFSPlugin{fsys: fsys, settings: []pact.SettingsItem{{ + Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile", + NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"}, + }}}) + if err == nil || !strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") { + t.Fatalf("settings form with %s: err = %v", name, err) + } + } + }) + + e := newCompanionsEnv(t) + type noticeView struct{ field, message string } + save := func(t *testing.T, method, path string, body map[string]any, status int) (uint, []noticeView) { + t.Helper() + rec := e.send(t, method, path, body, true) + wantStatus(t, method+" "+path, rec, status, rec.Body.String()) + record := decodeCompanionsRecord(t, rec.Body.Bytes()) + var out []noticeView + for _, n := range record.Meta.Notices { + out = append(out, noticeView{n.Field, n.Message}) + } + return dataID(t, rec.Body.Bytes()), out + } + stored := func(t *testing.T, id uint) *string { + t.Helper() + var link companionsLink + if err := e.db.First(&link, id).Error; err != nil { + t.Fatal(err) + } + return link.Description + } + + id, notices := save(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rich", "description": `

x

`}, http.StatusCreated) + if got := stored(t, id); got == nil || *got != "

x

" { + t.Fatalf("stored = %v", got) + } + want := []noticeView{ + {"description", "Usunięto 2 elementy: script, iframe"}, + {"description", "Usunięto 2 atrybuty: onclick, onmouseover"}, + {"description", "Usunięto 1 komentarz HTML"}, + } + if fmt.Sprint(notices) != fmt.Sprint(want) { + t.Fatalf("notices = %v\nwant %v", notices, want) + } + + path := fmt.Sprintf("/acme/companions/links/%d", id) + for _, clean := range []string{ + "

It's fine & \"quoted\"  —

\n
    \n
  • one
  • \n
", + `

link

`, + "", + } { + if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": clean}, http.StatusOK); len(notices) != 0 { + t.Fatalf("clean %q reported %v", clean, notices) + } + if got := stored(t, id); got == nil || *got != clean { + t.Fatalf("clean value not stored byte for byte: %v, want %q", got, clean) + } + } + if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": nil}, http.StatusOK); len(notices) != 0 || stored(t, id) != nil { + t.Fatalf("null description: %v %v", notices, stored(t, id)) + } + keep := "

kept

" + save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": keep}, http.StatusOK) + save(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, http.StatusOK) + if got := stored(t, id); got == nil || *got != keep { + t.Fatalf("an update without the field changed it: %v", got) + } + before := companionsLinkRows(t, e) + for _, bad := range []any{12, true, []any{"

x

"}, map[string]any{"pl": "

x

"}} { + rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Bad", "description": bad}, true) + wantStatus(t, fmt.Sprintf("description %v", bad), rec, http.StatusUnprocessableEntity, rec.Body.String()) + if _, details := companionsError(t, rec.Body.Bytes()); len(details["description"]) != 1 { + t.Fatalf("details = %v", details) + } + sameLinkRows(t, e, before, "a non-string description") + } +} diff --git a/modules/cabana/dependent_test.go b/modules/cabana/dependent_test.go new file mode 100644 index 0000000..5c9540c --- /dev/null +++ b/modules/cabana/dependent_test.go @@ -0,0 +1,222 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestDependentDropdownCompile pins D-08's compile rules for dependsOn: a +// dropdown only, no static options, a sibling of the same form (or repeater +// row) with a scalar value, never itself, and a controller that implements +// pact.DependentOptionsProvider. +func TestDependentDropdownCompile(t *testing.T) { + t.Run("a valid dependent dropdown compiles", func(t *testing.T) { + cc := ucMustCompile(t, ucFields+ucRepeater(""), nil) + if ref := ucField(t, cc, "reference"); ref.DependsOn != "type" || len(ref.Options) != 0 { + t.Fatalf("reference = %+v", ref) + } + if !usesDependsOn(cc.Form.Fields) { + t.Fatal("usesDependsOn = false") + } + }) + + base := "fields:\n title:\n type: text\n type:\n type: dropdown\n options: typeOptions\n" + for _, tc := range []struct { + name, fields string + ctl pact.AdminController + want string + }{ + {"on a text field", base + " reference:\n type: text\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"}, + {"on a textarea", base + " reference:\n type: textarea\n dependsOn: type\n", nil, "dependsOn is only valid on type: dropdown"}, + {"with static options", base + " reference:\n type: dropdown\n dependsOn: type\n options: typeOptions\n", nil, "a dropdown with dependsOn takes no options"}, + {"not a field name", base + " reference:\n type: dropdown\n dependsOn: \"a b\"\n", nil, `dependsOn "a b" is not a field name`}, + {"names itself", base + " reference:\n type: dropdown\n dependsOn: reference\n", nil, "field reference: dependsOn names the field itself"}, + {"unknown sibling", base + " reference:\n type: dropdown\n dependsOn: kind\n", nil, "field reference: dependsOn kind is not a field of this form"}, + {"sibling without a scalar value", base + ucRepeater("") + " pick:\n type: dropdown\n dependsOn: rows\n", nil, "field pick: dependsOn rows must be a field with a scalar value, not type repeater"}, + {"no provider", ucFields, ucPlainLinks{}, "field reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"}, + {"no provider for a nested dropdown", base + ucRepeater(""), ucPlainLinks{}, "field rows.reference: dependsOn needs controller acme.unit.links to implement pact.DependentOptionsProvider"}, + {"nested unknown sibling", base + " rows:\n type: repeater\n form:\n fields:\n reference:\n type: dropdown\n dependsOn: title\n", nil, "field rows.reference: dependsOn title is not a field of the same repeater row"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := ucCompile(tc.fields, tc.ctl) + wantErr(t, err, tc.want) + }) + } + + t.Run("a controller without dependsOn needs no provider", func(t *testing.T) { + if _, err := ucCompile(base, ucPlainLinks{}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dependent fields are found by path", func(t *testing.T) { + cc := ucMustCompile(t, ucFields+ucRepeater(""), nil) + for _, tc := range []struct { + path, want string + ok bool + }{ + {"reference", "reference", true}, + {"rows.reference", "rows.reference", true}, + {"title", "", false}, + {"type", "", false}, + {"rows.title", "", false}, + {"rows.missing", "", false}, + {"rows", "", false}, + {"title.reference", "", false}, + {"missing", "", false}, + } { + field, ok := dependentField(cc, tc.path) + if ok != tc.ok || (ok && (field.Name != tc.want || field.DependsOn != "type")) { + t.Fatalf("dependentField(%q) = %+v, %v", tc.path, field, ok) + } + } + if _, ok := dependentField(nil, "reference"); ok { + t.Fatal("nil controller answered") + } + }) +} + +// TestDependentValues pins the Docker-free half of the D-08 save recheck: +// sibling values are normalized the same way for the options route and a +// save, null is always accepted, a value outside the options is a 422 on the +// field (field.index.nested in a row), a type that takes no reference stores +// null, the stored sibling or value is used when only the other is written, +// and the provider is asked once per path and value. +func TestDependentValues(t *testing.T) { + for _, tc := range []struct { + in any + want any + }{ + {nil, nil}, {"", nil}, {"x", "x"}, {true, "true"}, {false, "false"}, + {json.Number("12"), "12"}, {float64(2), "2"}, {2.5, "2.5"}, {3, "3"}, {int64(4), "4"}, {uint(5), "5"}, + {[]any{1}, "[1]"}, + } { + if got := dependentValue(tc.in); got != tc.want { + t.Fatalf("dependentValue(%#v) = %#v, want %#v", tc.in, got, tc.want) + } + } + set := pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1"}, {Value: "2"}}} + for _, tc := range []struct { + value any + ok bool + }{{nil, true}, {"", true}, {"1", true}, {float64(2), true}, {json.Number("2"), true}, {"3", false}, {"01", false}, {true, false}} { + if got := amongOptions(set, tc.value); got != tc.ok { + t.Fatalf("amongOptions(%#v) = %v", tc.value, got) + } + } + + page, list := "acme-page", "acme-list" + stale := "99" + two := "2" + check := func(t *testing.T, target *ucLink, projected map[string]any) (map[string]any, []string, error) { + t.Helper() + var calls []string + cc := ucMustCompile(t, ucFields+ucRepeater(""), ucLinks{calls: &calls}) + err := checkDependentValues(context.Background(), cc, nil, target, projected, "update") + var invalid *ValidationError + if errors.As(err, &invalid) { + return invalid.Details, calls, nil + } + return nil, calls, err + } + + t.Run("top level", func(t *testing.T) { + for _, tc := range []struct { + name string + target ucLink + projected map[string]any + wantInvalid bool + wantReference any + wantCalls string + }{ + {"a value among the options", ucLink{}, map[string]any{"type": page, "reference": "2"}, false, "2", "[reference=map[type:acme-page]]"}, + {"a number value is compared as text", ucLink{}, map[string]any{"type": page, "reference": float64(1)}, false, float64(1), "[reference=map[type:acme-page]]"}, + {"null is accepted", ucLink{}, map[string]any{"type": page, "reference": nil}, false, nil, "[reference=map[type:acme-page]]"}, + {"a value outside the options", ucLink{}, map[string]any{"type": page, "reference": "99"}, true, "99", "[reference=map[type:acme-page]]"}, + {"a type without references stores null", ucLink{}, map[string]any{"type": list, "reference": "1"}, false, nil, "[reference=map[type:acme-list]]"}, + {"an empty type stores null", ucLink{}, map[string]any{"type": "", "reference": "1"}, false, nil, "[reference=map[type:]]"}, + {"only the value: the stored sibling decides", ucLink{Type: &page}, map[string]any{"reference": "99"}, true, "99", "[reference=map[type:acme-page]]"}, + {"only the value, stored sibling accepts it", ucLink{Type: &page}, map[string]any{"reference": "1"}, false, "1", "[reference=map[type:acme-page]]"}, + {"only the sibling: the stored value is checked", ucLink{Reference: &stale}, map[string]any{"type": page}, true, nil, "[reference=map[type:acme-page]]"}, + {"only the sibling, stored value still valid", ucLink{Reference: &two}, map[string]any{"type": page}, false, nil, "[reference=map[type:acme-page]]"}, + {"only the sibling to a type without references clears the value", ucLink{Reference: &two}, map[string]any{"type": list}, false, nil, "[reference=map[type:acme-list]]"}, + {"neither written asks nothing", ucLink{Type: &page, Reference: &stale}, map[string]any{"title": "x"}, false, nil, "[]"}, + } { + t.Run(tc.name, func(t *testing.T) { + target := tc.target + details, calls, err := check(t, &target, tc.projected) + if err != nil { + t.Fatal(err) + } + if (details != nil) != tc.wantInvalid { + t.Fatalf("details = %v, want invalid %v", details, tc.wantInvalid) + } + if tc.wantInvalid { + if msgs, _ := details["reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 { + t.Fatalf("details = %v", details) + } + } + if got, present := tc.projected["reference"]; present || tc.wantReference != nil { + if fmt.Sprint(got) != fmt.Sprint(tc.wantReference) { + t.Fatalf("projected reference = %#v, want %#v", got, tc.wantReference) + } + } + if strings.Contains(tc.name, "stores null") || strings.Contains(tc.name, "clears the value") { + if v, present := tc.projected["reference"]; !present || v != nil { + t.Fatalf("reference not written as null: %#v (present %v)", v, present) + } + } + if fmt.Sprint(calls) != tc.wantCalls { + t.Fatalf("provider calls = %v, want %s", calls, tc.wantCalls) + } + }) + } + }) + + t.Run("repeater rows", func(t *testing.T) { + projected := map[string]any{"rows": []any{ + map[string]any{"title": "a", "type": page, "reference": "1"}, + map[string]any{"title": "b", "type": page, "reference": "99"}, + map[string]any{"title": "c", "type": list, "reference": "1"}, + map[string]any{"title": "d", "type": page, "reference": "2"}, + "not a row", + }} + details, calls, err := check(t, &ucLink{}, projected) + if err != nil { + t.Fatal(err) + } + if msgs, _ := details["rows.1.reference"].([]string); len(msgs) != 1 || msgs[0] != msgDependentInvalid || len(details) != 1 { + t.Fatalf("details = %v", details) + } + rows := projected["rows"].([]any) + if rows[2].(map[string]any)["reference"] != nil || rows[0].(map[string]any)["reference"] != "1" { + t.Fatalf("rows = %v", rows) + } + // One call per distinct path and value: the second acme-page row + // and the fourth reuse the first answer. + if fmt.Sprint(calls) != "[rows.reference=map[type:acme-page] rows.reference=map[type:acme-list]]" { + t.Fatalf("provider calls = %v", calls) + } + }) + + t.Run("a provider error is returned, not a 422", func(t *testing.T) { + broken := "acme-broken" + _, _, err := check(t, &ucLink{}, map[string]any{"type": broken, "reference": "1"}) + if err == nil || !strings.Contains(err.Error(), "references failed") { + t.Fatalf("err = %v", err) + } + }) + + t.Run("a form without dependsOn asks nothing", func(t *testing.T) { + cc := ucMustCompile(t, "fields:\n title:\n type: text\n", ucPlainLinks{}) + if err := checkDependentValues(context.Background(), cc, nil, &ucLink{}, map[string]any{"title": "x"}, "update"); err != nil { + t.Fatal(err) + } + }) +} diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index fb9e33e..087843c 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -223,6 +223,105 @@ func TestPhase10OpenAPIConformance(t *testing.T) { t.FailNow() } } + + // The documented error answers of the Phase 17 routes, after a fresh + // login (the last case logged out). + if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": env.login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { + t.Fatalf("re-login status=%d body=%s", rec.Code, rec.Body.String()) + } + for _, tc := range companionsErrorCases() { + t.Run(fmt.Sprintf("%s %d %s", tc.key, tc.status, tc.name), func(t *testing.T) { + rec := tc.call(t, env) + if rec.Code != tc.status { + t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String()) + } + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.DisallowUnknownFields() + var body cabana.ErrorEnvelope + if err := dec.Decode(&body); err != nil || body.Error.Code == "" { + t.Fatalf("body is not an ErrorEnvelope: %v\n%s", err, rec.Body.String()) + } + if tc.code != "" && body.Error.Code != tc.code { + t.Fatalf("error code = %q, want %q", body.Error.Code, tc.code) + } + method, path, _ := strings.Cut(tc.key, " ") + if got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status)); got != "cabana.ErrorEnvelope" { + t.Fatalf("admin.json documents %q for %s %d, the handler writes cabana.ErrorEnvelope", got, tc.key, tc.status) + } + }) + } +} + +// companionsErrorCase is one documented error answer of a Phase 17 route. +type companionsErrorCase struct { + key string + status int + name string + code string + call func(t *testing.T, env *conformEnv) *httptest.ResponseRecorder +} + +// companionsErrorCases are the 404, 409 and 422 answers the dependent +// options, reorder and singleton routes document, and the 404 of the list, +// create and delete routes on a singleton controller. +func companionsErrorCases() []companionsErrorCase { + partitionLinks := func(t *testing.T, e *conformEnv) (uint, []uint) { + t.Helper() + group := companionsGroup{Title: "Errors " + e.stamp} + if err := e.db.Create(&group).Error; err != nil { + t.Fatal(err) + } + var ids []uint + for i := range 2 { + link := companionsLink{Title: fmt.Sprintf("err-%d-%s", i, e.stamp), GroupID: &group.ID, SortOrder: i + 1} + if err := e.db.Create(&link).Error; err != nil { + t.Fatal(err) + } + ids = append(ids, link.ID) + } + return group.ID, ids + } + profileID := func(t *testing.T, e *conformEnv) uint { + t.Helper() + return dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()) + } + return []companionsErrorCase{ + {"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 404, "field without dependsOn", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, dependentOptionsPath("title", "acme-page"), nil, true) + }}, + {"GET /{vendor}/{plugin}/{controller}/fields/{field}/dependent-options", 401, "anonymous", "", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false) + }}, + {"GET /{vendor}/{plugin}/{controller}/reorder", 404, "unknown partition", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/companions/links/reorder?partition=999999", nil, true) + }}, + {"GET /{vendor}/{plugin}/{controller}/reorder", 422, "partition not an id", "validation_failed", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/companions/links/reorder?partition=abc", nil, true) + }}, + {"POST /{vendor}/{plugin}/{controller}/reorder", 409, "stale set", "reorder_stale", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + group, ids := partitionLinks(t, e) + return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": group, "nodes": []any{reorderNode(ids[0], nil, 1)}}, true) + }}, + {"POST /{vendor}/{plugin}/{controller}/reorder", 422, "cycle", "validation_failed", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + group, ids := partitionLinks(t, e) + return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": group, "nodes": []any{reorderNode(ids[0], &ids[1], 1), reorderNode(ids[1], &ids[0], 1)}}, true) + }}, + {"POST /{vendor}/{plugin}/{controller}/reorder", 404, "unknown partition", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": 999999, "nodes": []any{}}, true) + }}, + {"GET /{vendor}/{plugin}/{controller}/singleton", 404, "not a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/companions/links/singleton", nil, true) + }}, + {"GET /{vendor}/{plugin}/{controller}", 404, "list of a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodGet, "/acme/companions/profiles", nil, true) + }}, + {"POST /{vendor}/{plugin}/{controller}", 404, "create on a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true) + }}, + {"DELETE /{vendor}/{plugin}/{controller}/{id}", 404, "delete on a singleton", "not_found", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder { + return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/companions/profiles/%d", profileID(t, e)), nil, true) + }}, + } } // conformCases is the conformance case of every inventoried admin route, in diff --git a/modules/cabana/reorder_test.go b/modules/cabana/reorder_test.go new file mode 100644 index 0000000..f0381f5 --- /dev/null +++ b/modules/cabana/reorder_test.go @@ -0,0 +1,265 @@ +package cabana_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestReorderConfigCompile pins the boot checks of config_reorder.yaml +// (D-09), Docker-free through cabana.Activate: unknown keys, a modelClass +// mismatch, columns that are not columns of the record, a negative maxDepth, +// a partition over a column without a belongsTo relation field, a partition +// label column the related record lacks, and the reorder toolbar button +// without the file. +func TestReorderConfigCompile(t *testing.T) { + const cfg = "controllers/links/config_reorder.yaml" + reorder := func(t *testing.T, old, new string) map[string]string { + t.Helper() + src := companionsFile(t, cfg) + if !strings.Contains(src, old) { + t.Fatalf("fixture lacks %q", old) + } + return map[string]string{cfg: strings.Replace(src, old, new, 1)} + } + t.Run("the fixture compiles, and defaults fill parentColumn and sortColumn", func(t *testing.T) { + if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil { + t.Fatal(err) + } + files := reorder(t, "parentColumn: parent_id\nsortColumn: sort_order\n", "") + if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil { + t.Fatalf("defaults: %v", err) + } + files = reorder(t, "maxDepth: 3\n", "maxDepth: 0\n") + if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil { + t.Fatalf("unlimited depth: %v", err) + } + files = reorder(t, "partition:\n column: group_id\n relation: group\n label: Group\n", "") + if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil { + t.Fatalf("without a partition: %v", err) + } + files = reorder(t, " label: Group\n", " label: Group\n nameFrom: title\n") + if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, files)}); err != nil { + t.Fatalf("partition nameFrom: %v", err) + } + }) + for _, tc := range []struct { + name string + files func(t *testing.T) map[string]string + want []string + }{ + {"unknown key", func(t *testing.T) map[string]string { + return reorder(t, "title: Reorder links\n", "title: Reorder links\nshowTree: true\n") + }, []string{cfg, "showTree"}}, + {"unknown partition key", func(t *testing.T) map[string]string { + return reorder(t, " label: Group\n", " label: Group\n scope: x\n") + }, []string{cfg, "scope"}}, + {"modelClass mismatch", func(t *testing.T) map[string]string { return reorder(t, "modelClass: Link\n", "modelClass: Other\n") }, []string{cfg, `modelClass "Other" does not match "Link"`}}, + {"nameFrom not a column", func(t *testing.T) map[string]string { return reorder(t, "nameFrom: title\n", "nameFrom: label\n") }, []string{"nameFrom label is not a column of the record"}}, + {"nameFrom not an identifier", func(t *testing.T) map[string]string { + return reorder(t, "nameFrom: title\n", "nameFrom: \"title; drop\"\n") + }, []string{"nameFrom", "is not a column name"}}, + {"parentColumn not a column", func(t *testing.T) map[string]string { + return reorder(t, "parentColumn: parent_id\n", "parentColumn: parent\n") + }, []string{"parentColumn parent is not a column of the record"}}, + {"sortColumn not a column", func(t *testing.T) map[string]string { + return reorder(t, "sortColumn: sort_order\n", "sortColumn: position\n") + }, []string{"sortColumn position is not a column of the record"}}, + {"negative maxDepth", func(t *testing.T) map[string]string { return reorder(t, "maxDepth: 3\n", "maxDepth: -1\n") }, []string{"maxDepth must be 0 (unlimited) or more"}}, + {"partition column not a column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: menu_id\n") }, []string{"partition.column menu_id is not a column of the record"}}, + {"partition relation unknown", func(t *testing.T) map[string]string { return reorder(t, "relation: group\n", "relation: menu\n") }, []string{"partition.relation menu must be a belongsTo relation field of the form over group_id"}}, + {"partition relation over another column", func(t *testing.T) map[string]string { return reorder(t, "column: group_id\n", "column: parent_id\n") }, []string{"partition.relation group must be a belongsTo relation field of the form over parent_id"}}, + {"partition nameFrom not a related column", func(t *testing.T) map[string]string { + return reorder(t, " label: Group\n", " label: Group\n nameFrom: name\n") + }, []string{"partition.nameFrom name is not a column of the related record"}}, + {"reorder button without the file", func(t *testing.T) map[string]string { return map[string]string{cfg: ""} }, []string{"config_list.yaml", "toolbar.buttons: reorder needs config_reorder.yaml"}}, + {"a reorder page needs a list", func(t *testing.T) map[string]string { + return map[string]string{"controllers/profiles/config_reorder.yaml": "title: Profiles\nnameFrom: title\nparentColumn: id\nsortColumn: id\n"} + }, []string{"a reorder page needs config_list.yaml"}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, tc.files(t))}) + if err == nil { + t.Fatal("boot accepted the reorder config") + } + for _, part := range tc.want { + if !strings.Contains(err.Error(), part) { + t.Fatalf("err = %v, want %q", err, part) + } + } + }) + } +} + +// TestReorderPostgres pins the D-09 routes on Postgres: the GET scopes and +// orders the partition's records, the POST writes both columns, and every +// refused request (stale set, foreign partition node, unknown id, duplicate, +// self parent, parent outside the set, cycle, depth above maxDepth, missing +// or unknown partition) answers its status and writes nothing. +func TestReorderPostgres(t *testing.T) { + e := newCompanionsEnv(t) + alpha, beta := companionsGroup{Title: "Beta"}, companionsGroup{Title: "Alpha"} + for _, g := range []*companionsGroup{&alpha, &beta} { + if err := e.db.Create(g).Error; err != nil { + t.Fatal(err) + } + } + // Records of alpha ("Beta"): sort order ties break by id; one record of + // beta ("Alpha") is the foreign node. + links := []companionsLink{ + {Title: "A", GroupID: &alpha.ID, SortOrder: 2}, + {Title: "B", GroupID: &alpha.ID, SortOrder: 1}, + {Title: "C", GroupID: &alpha.ID, SortOrder: 2}, + {Title: "D", GroupID: &alpha.ID, SortOrder: 3}, + {Title: "E", GroupID: &alpha.ID, SortOrder: 4}, + {Title: "X", GroupID: &beta.ID, SortOrder: 1}, + {Title: "Free"}, + } + for i := range links { + if err := e.db.Create(&links[i]).Error; err != nil { + t.Fatal(err) + } + } + a, b, c, d, x := links[0].ID, links[1].ID, links[2].ID, links[3].ID, links[5].ID + ee := links[4].ID + + t.Run("GET", func(t *testing.T) { + tree := reorderTree(t, e.send(t, http.MethodGet, fmt.Sprintf("/acme/companions/links/reorder?partition=%d", alpha.ID), nil, true)) + var names []string + for _, n := range tree.Nodes { + names = append(names, n.Name) + } + if strings.Join(names, ",") != "B,A,C,D,E" || tree.Title != "Reorder links" || tree.Partition.Label != "Group" { + t.Fatalf("tree = %+v (names %v)", tree, names) + } + // Without a partition the first one by label ("Alpha") is shown. + first := reorderTree(t, e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, true)) + if first.Partition == nil || first.Partition.Value == nil || *first.Partition.Value != beta.ID || len(first.Nodes) != 1 || first.Nodes[0].ID != x { + t.Fatalf("default partition tree = %+v", first) + } + if opts := first.Partition.Options; len(opts) != 2 || opts[0].Label != "Alpha" || opts[1].Label != "Beta" { + t.Fatalf("partition options = %+v", opts) + } + for path, status := range map[string]int{ + "/acme/companions/links/reorder?partition=999999": http.StatusNotFound, + "/acme/companions/links/reorder?partition=abc": http.StatusUnprocessableEntity, + "/acme/companions/links/reorder?partition=0": http.StatusUnprocessableEntity, + "/acme/companions/profiles/reorder": http.StatusNotFound, + } { + if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != status { + t.Fatalf("%s status=%d want %d body=%s", path, rec.Code, status, rec.Body.String()) + } + } + if rec := e.send(t, http.MethodGet, "/acme/companions/links/reorder", nil, false); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous status=%d", rec.Code) + } + }) + + post := func(partition any, nodes ...map[string]any) *httptest.ResponseRecorder { + body := map[string]any{"nodes": nodes} + if partition != nil { + body["partition"] = partition + } + return e.send(t, http.MethodPost, "/acme/companions/links/reorder", body, true) + } + n := reorderNode + + t.Run("POST writes both columns", func(t *testing.T) { + tree := reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, &a, 2))) + got := companionsLinkRows(t, e) + check := func(id uint, parent *uint, sort int) { + t.Helper() + row := got[id] + if (parent == nil) != (row.ParentID == nil) || (parent != nil && *row.ParentID != *parent) || row.SortOrder != sort { + t.Fatalf("row %d = parent %v sort %d, want %v %d", id, row.ParentID, row.SortOrder, parent, sort) + } + } + check(a, nil, 1) + check(b, &a, 1) + check(c, &b, 1) + check(d, nil, 2) + check(ee, &a, 2) + check(x, nil, 1) + if len(tree.Nodes) != 5 || tree.Nodes[0].ID != a { + t.Fatalf("answered tree = %+v", tree) + } + }) + + before := companionsLinkRows(t, e) + for _, tc := range []struct { + name string + rec func() *httptest.ResponseRecorder + status int + code string + detail string + }{ + {"a node left out", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4)) + }, http.StatusConflict, "reorder_stale", ""}, + {"a node of another partition", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(x, nil, 6)) + }, http.StatusConflict, "reorder_stale", ""}, + {"a foreign node in place of one", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(x, nil, 5)) + }, http.StatusConflict, "reorder_stale", ""}, + {"an unknown id", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(999999, nil, 5)) + }, http.StatusConflict, "reorder_stale", ""}, + {"a duplicate id", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, nil, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5), n(a, nil, 6)) + }, http.StatusUnprocessableEntity, "validation_failed", "nodes"}, + {"id zero", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(0, nil, 1), n(b, nil, 2)) + }, http.StatusUnprocessableEntity, "validation_failed", "nodes"}, + {"its own parent", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, &a, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5)) + }, http.StatusUnprocessableEntity, "validation_failed", "nodes"}, + {"a parent outside the set", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, &x, 1), n(b, nil, 2), n(c, nil, 3), n(d, nil, 4), n(ee, nil, 5)) + }, http.StatusUnprocessableEntity, "validation_failed", "nodes"}, + {"a cycle", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, &c, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3)) + }, http.StatusUnprocessableEntity, "validation_failed", "nodes"}, + {"deeper than maxDepth", func() *httptest.ResponseRecorder { + return post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, &c, 1), n(ee, nil, 2)) + }, http.StatusUnprocessableEntity, "validation_failed", "nodes"}, + {"no partition", func() *httptest.ResponseRecorder { + return post(nil, n(a, nil, 1)) + }, http.StatusUnprocessableEntity, "validation_failed", "partition"}, + {"an unknown partition", func() *httptest.ResponseRecorder { + return post(999999, n(a, nil, 1)) + }, http.StatusNotFound, "", ""}, + {"a singleton controller", func() *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/acme/companions/profiles/reorder", map[string]any{"nodes": []any{}}, true) + }, http.StatusNotFound, "", ""}, + {"anonymous", func() *httptest.ResponseRecorder { + return e.send(t, http.MethodPost, "/acme/companions/links/reorder", map[string]any{"partition": alpha.ID, "nodes": []any{}}, false) + }, http.StatusUnauthorized, "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := tc.rec() + if rec.Code != tc.status { + t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String()) + } + if tc.code != "" { + code, details := companionsError(t, rec.Body.Bytes()) + if code != tc.code || (tc.detail != "" && len(details[tc.detail]) != 1) { + t.Fatalf("error = %s %v, want %s on %q", code, details, tc.code, tc.detail) + } + if tc.code == "reorder_stale" && !strings.Contains(rec.Body.String(), "Rekordy zmieniły się w międzyczasie") { + t.Fatalf("stale message = %s", rec.Body.String()) + } + } + sameLinkRows(t, e, before, tc.name) + }) + } + + t.Run("exactly maxDepth is accepted", func(t *testing.T) { + reorderTree(t, post(alpha.ID, n(a, nil, 1), n(b, &a, 1), n(c, &b, 1), n(d, nil, 2), n(ee, nil, 3))) + if row := companionsLinkRows(t, e)[c]; row.ParentID == nil || *row.ParentID != b { + t.Fatalf("c = %+v", row) + } + }) +} diff --git a/modules/cabana/repeater_test.go b/modules/cabana/repeater_test.go new file mode 100644 index 0000000..d459db2 --- /dev/null +++ b/modules/cabana/repeater_test.go @@ -0,0 +1,426 @@ +package cabana + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// Docker-free unit tests of the Phase 17 form features (plan 06): they +// compile the acme.unit links controller from an fstest tree and run the +// decode, recheck and sanitize steps of a save without a database. The +// Postgres tests through the HTTP routes are in companions_postgres_test.go, +// reorder_test.go and singleton_test.go. + +// ucLink is the links model of the unit fixture. +type ucLink struct { + ID uint `gorm:"column:id;primaryKey"` + Title string `gorm:"column:title"` + Type *string `gorm:"column:type"` + Reference *string `gorm:"column:reference"` + Description *string `gorm:"column:description"` + Rows lagoon.Jsonable[[]map[string]any] `gorm:"column:rows"` +} + +func (*ucLink) TableName() string { return "uc_links" } +func (*ucLink) Fillable() []string { + return []string{"title", "type", "reference", "description", "rows"} +} +func (*ucLink) Rules() map[string]string { return map[string]string{} } + +// ucDependent is the fake dependent options binding: acme-page offers a +// two-level tree, acme-list and every other value take no reference. +func ucDependent(_ context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) { + for _, v := range values { + switch v { + case "acme-page": + return pact.DependentOptionSet{Applies: true, Options: []pact.Option{{Value: "1", Label: "Home"}, {Value: "2", Label: "About", Depth: 1}}}, nil + case "acme-broken": + return pact.DependentOptionSet{}, errors.New("references failed") + } + } + return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil +} + +// ucLinks is the links controller of the unit fixture. +type ucLinks struct { + calls *[]string +} + +func (ucLinks) ID() string { return "acme.unit.links" } +func (ucLinks) ModelName() string { return "Link" } +func (ucLinks) ConfigDir() string { return "controllers/links" } +func (ucLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} } +func (ucLinks) NewRecord() any { return &ucLink{} } +func (ucLinks) DropdownOptions(string) []pact.Option { + return []pact.Option{{Value: "url", Label: "URL"}, {Value: "acme-page", Label: "Page"}, {Value: "acme-list", Label: "All pages"}} +} +func (c ucLinks) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) { + if c.calls != nil { + *c.calls = append(*c.calls, fmt.Sprintf("%s=%v", field, values)) + } + return ucDependent(ctx, field, values) +} + +// ucPlainLinks is the same controller without a DependentOptionsProvider. +type ucPlainLinks struct{} + +func (ucPlainLinks) ID() string { return "acme.unit.links" } +func (ucPlainLinks) ModelName() string { return "Link" } +func (ucPlainLinks) ConfigDir() string { return "controllers/links" } +func (ucPlainLinks) RequiredPermissions() []string { return []string{"acme.unit.access"} } +func (ucPlainLinks) NewRecord() any { return &ucLink{} } +func (ucPlainLinks) DropdownOptions(string) []pact.Option { + return []pact.Option{{Value: "url", Label: "URL"}} +} + +// ucPlugin serves the unit fixture tree with one controller. +type ucPlugin struct { + fsys fs.FS + ctl pact.AdminController +} + +func (ucPlugin) ID() string { return "acme.unit" } +func (ucPlugin) Requires() []string { return nil } +func (ucPlugin) Register(*backpack.App) error { return nil } +func (ucPlugin) Boot(*backpack.App) error { return nil } +func (p ucPlugin) AdminFS() fs.FS { return p.fsys } +func (p ucPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{p.ctl} +} + +// ucFields is the start of the unit fixture's fields.yaml: a title, a type +// dropdown and a reference dropdown that depends on it. +const ucFields = "fields:\n" + + " title:\n type: text\n label: Title\n" + + " type:\n type: dropdown\n label: Type\n options: typeOptions\n" + + " reference:\n type: dropdown\n label: Reference\n dependsOn: type\n" + +// ucRepeater is a valid repeater named rows with every nested type it +// takes; extra is appended to the repeater's own keys. +func ucRepeater(extra string) string { + return " rows:\n type: repeater\n label: Rows\n" + extra + + " form:\n fields:\n" + + " title:\n type: text\n required: true\n" + + " body:\n type: richeditor\n" + + " count:\n type: number\n" + + " on:\n type: checkbox\n" + + " flag:\n type: switch\n" + + " note:\n type: textarea\n" + + " type:\n type: dropdown\n options: typeOptions\n" + + " reference:\n type: dropdown\n dependsOn: type\n" +} + +// ucFS is the unit fixture tree with the given fields.yaml. +func ucFS(fields string) fstest.MapFS { + return fstest.MapFS{ + "controllers/links/config_form.yaml": &fstest.MapFile{Data: []byte("name: Link\nform: ~/plugins/acme/unit/models/link/fields.yaml\nmodelClass: Link\n")}, + "controllers/links/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/unit/models/link/columns.yaml\nmodelClass: Link\nrecordsPerPage: 20\n")}, + "models/link/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")}, + "models/link/fields.yaml": &fstest.MapFile{Data: []byte(fields)}, + } +} + +// ucCompile compiles the links controller of fields with ctl (ucLinks when +// nil). +func ucCompile(fields string, ctl pact.AdminController) (*CompiledController, error) { + if ctl == nil { + ctl = ucLinks{} + } + reg, err := ftCompile(ucPlugin{fsys: ucFS(fields), ctl: ctl}) + if err != nil { + return nil, err + } + cc, ok := reg.Get("acme.unit.links") + if !ok { + return nil, errors.New("no links controller") + } + return cc, nil +} + +func ucMustCompile(t *testing.T, fields string, ctl pact.AdminController) *CompiledController { + t.Helper() + cc, err := ucCompile(fields, ctl) + if err != nil { + t.Fatalf("compile: %v", err) + } + return cc +} + +// ucField returns the compiled form field name of cc. +func ucField(t *testing.T, cc *CompiledController, name string) FormField { + t.Helper() + for _, field := range cc.Form.Fields { + if field.Name == name { + return field + } + } + t.Fatalf("no field %s", name) + return FormField{} +} + +// TestRepeaterCompile pins D-07's compile rules: every key a repeater +// refuses, every nested type it refuses, the nested tab/context/preset and +// dependsOn checks, the row bounds and titleFrom, and the repeater-only keys +// on other types. +func TestRepeaterCompile(t *testing.T) { + t.Run("a valid repeater compiles with its nested form", func(t *testing.T) { + cc := ucMustCompile(t, ucFields+ucRepeater(" prompt: Add row\n titleFrom: title\n minItems: 1\n maxItems: 3\n tab: Content\n span: left\n required: true\n comment: Rows\n"), nil) + rows := ucField(t, cc, "rows") + if rows.Span != "full" || rows.Prompt != "Add row" || rows.TitleFrom != "title" || rows.MinItems == nil || *rows.MinItems != 1 || rows.MaxItems == nil || *rows.MaxItems != 3 || !rows.Required || rows.Tab != "Content" { + t.Fatalf("rows = %+v", rows) + } + if rows.Form == nil || len(rows.Form.Fields) != 8 { + t.Fatalf("nested form = %+v", rows.Form) + } + var names []string + for _, inner := range rows.Form.Fields { + names = append(names, inner.Name+":"+inner.Type) + } + if strings.Join(names, ",") != "title:text,body:richeditor,count:number,on:checkbox,flag:switch,note:textarea,type:dropdown,reference:dropdown" { + t.Fatalf("nested fields = %v", names) + } + if !rows.Form.Fields[0].Required || rows.Form.Fields[7].DependsOn != "type" { + t.Fatalf("nested keys lost: %+v", rows.Form.Fields) + } + if names := repeaterNames(cc); !names["rows"] || len(names) != 1 { + t.Fatalf("repeaterNames = %v", names) + } + }) + + nested := func(field string) string { + return " rows:\n type: repeater\n form:\n fields:\n" + field + } + for _, tc := range []struct { + name, fields, want string + }{ + {"groups", ucFields + ucRepeater(" groups: ~/groups.yaml\n"), "repeater groups are not supported"}, + {"style", ucFields + ucRepeater(" style: accordion\n"), "repeater style is not supported"}, + {"options key", ucFields + ucRepeater(" options: typeOptions\n"), "options is not valid on type: repeater"}, + {"size key", ucFields + ucRepeater(" size: large\n"), "size is not valid on type: repeater"}, + {"default key", ucFields + ucRepeater(" default: []\n"), "default is not valid on type: repeater"}, + {"attributes key", ucFields + ucRepeater(" attributes: {}\n"), "attributes is not valid on type: repeater"}, + {"no form", ucFields + " rows:\n type: repeater\n", "a repeater needs form: with fields"}, + {"form without fields", ucFields + " rows:\n type: repeater\n form:\n label: x\n", "a repeater form takes fields only"}, + {"form groups", ucFields + " rows:\n type: repeater\n form:\n groups: {}\n", "repeater groups are not supported"}, + {"empty fields", ucFields + " rows:\n type: repeater\n form:\n fields: {}\n", "form.fields is empty"}, + {"nested tab", ucFields + nested(" title:\n type: text\n tab: Main\n"), "tab, context and preset are not supported in a repeater row"}, + {"nested context", ucFields + nested(" title:\n type: text\n context: update\n"), "tab, context and preset are not supported in a repeater row"}, + {"nested dependsOn to a missing sibling", ucFields + nested(" reference:\n type: dropdown\n dependsOn: type\n"), "is not a field of the same repeater row"}, + {"nested dependsOn to itself", ucFields + nested(" reference:\n type: dropdown\n dependsOn: reference\n"), "dependsOn names the field itself"}, + {"negative maxItems", ucFields + ucRepeater(" maxItems: -1\n"), "maxItems: want a non-negative integer"}, + {"text maxItems", ucFields + ucRepeater(" maxItems: many\n"), "maxItems: want a non-negative integer"}, + {"negative minItems", ucFields + ucRepeater(" minItems: -2\n"), "minItems: want a non-negative integer"}, + {"minItems above maxItems", ucFields + ucRepeater(" minItems: 4\n maxItems: 2\n"), "minItems 4 is greater than maxItems 2"}, + {"titleFrom outside the form", ucFields + ucRepeater(" titleFrom: missing\n"), "titleFrom missing is not a field of the repeater form"}, + {"maxItems on text", ucFields + " extra:\n type: text\n maxItems: 2\n", "maxItems is only valid on type: repeater"}, + {"minItems on textarea", ucFields + " extra:\n type: textarea\n minItems: 2\n", "minItems is only valid on type: repeater"}, + {"titleFrom on dropdown", ucFields + " extra:\n type: dropdown\n options: typeOptions\n titleFrom: x\n", "titleFrom is only valid on type: repeater"}, + {"form on text", ucFields + " extra:\n type: text\n form:\n fields: {}\n", "form is only valid on type: repeater"}, + {"groups on text", ucFields + " extra:\n type: text\n groups: x\n", "repeater groups are not supported"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := ucCompile(tc.fields, nil) + wantErr(t, err, tc.want) + }) + } + + // Every core type outside text, textarea, number, checkbox, switch, + // dropdown and richeditor is refused in a row. + // Each type gets the keys it needs on its own, so the refusal reached is + // the repeater's. + typeKeys := map[string]string{ + "relation-manager": " relation: parts\n", + "widget": " widget: acme-x\n action: lookup\n", + "partial": " path: summary\n", + "permissioneditor": " mode: checkbox\n", + "repeater": " form:\n fields:\n a:\n type: text\n", + } + for typ := range formFieldTypes { + if repeaterNestedTypes[typ] { + continue + } + t.Run("nested "+typ, func(t *testing.T) { + _, err := ucCompile(ucFields+nested(" inner:\n type: "+typ+"\n"+typeKeys[typ]), nil) + wantErr(t, err, "field inner: type "+typ+" is not supported in a repeater") + }) + } +} + +// ucDecode runs liftRepeaterValues on a JSON body. +func ucDecode(t *testing.T, cc *CompiledController, raw, op string) (map[string]any, map[string]any) { + t.Helper() + body := utJSON(t, raw).(map[string]any) + err := liftRepeaterValues(context.Background(), cc, body, op, nil) + if err == nil { + return body, nil + } + var invalid *ValidationError + if !errors.As(err, &invalid) { + t.Fatalf("err = %v, want a ValidationError", err) + } + return body, invalid.Details +} + +// TestRepeaterDecode pins the D-07 decode of a submitted repeater value: +// rows keep only the nested keys, scalars are coerced per nested type, +// required nested values fail under field.index.nested, the row bounds and +// the list shape are checked, and stored values hydrate back to rows. +func TestRepeaterDecode(t *testing.T) { + cc := ucMustCompile(t, ucFields+ucRepeater(" minItems: 1\n maxItems: 3\n"), nil) + + t.Run("rows keep only nested keys, coerced per type", func(t *testing.T) { + body, details := ucDecode(t, cc, `{"rows":[{"title":"One","body":"

x

","count":"12","on":"yes","flag":1,"note":7,"type":"acme-page","reference":2,"extra":"dropped"},{"title":5,"count":3.5,"on":"off","flag":false,"note":true}]}`, "update") + if details != nil { + t.Fatalf("details = %v", details) + } + rows := body["rows"].([]any) + first, second := rows[0].(map[string]any), rows[1].(map[string]any) + if _, extra := first["extra"]; extra || len(first) != 8 || len(second) != 8 { + t.Fatalf("row keys = %v / %v", first, second) + } + want := map[string]any{"title": "One", "body": "

x

", "count": json.Number("12"), "on": true, "flag": true, "note": "7", "type": "acme-page", "reference": "2"} + if fmt.Sprint(first) != fmt.Sprint(want) { + t.Fatalf("first row = %v\nwant %v", first, want) + } + want = map[string]any{"title": "5", "body": nil, "count": json.Number("3.5"), "on": false, "flag": false, "note": "true", "type": nil, "reference": nil} + if fmt.Sprint(second) != fmt.Sprint(want) { + t.Fatalf("second row = %v\nwant %v", second, want) + } + }) + + t.Run("coercion refusals and required values are keyed per row", func(t *testing.T) { + _, details := ucDecode(t, cc, `{"rows":[{"title":"ok","count":"abc"},{"title":" ","on":"maybe"},{"title":{"x":1},"flag":[1]}]}`, "update") + for key, part := range map[string]string{ + "rows.0.count": "must be a number", + "rows.1.title": "is required", + "rows.1.on": "must be true or false", + "rows.2.title": "must be text", + "rows.2.flag": "must be true or false", + } { + msgs, _ := details[key].([]string) + if len(msgs) != 1 || !strings.Contains(msgs[0], key) || !strings.Contains(msgs[0], part) { + t.Fatalf("%s = %v, want %q", key, details[key], part) + } + } + if len(details) != 5 { + t.Fatalf("details = %v", details) + } + }) + + t.Run("a missing required nested value fails", func(t *testing.T) { + _, details := ucDecode(t, cc, `{"rows":[{"title":"ok"},{"body":"

x

"}]}`, "update") + if msgs, _ := details["rows.1.title"].([]string); len(msgs) != 1 || len(details) != 1 { + t.Fatalf("details = %v", details) + } + }) + + t.Run("the list shape is checked", func(t *testing.T) { + for _, raw := range []string{`{"rows":"x"}`, `{"rows":{"a":1}}`, `{"rows":3}`, `{"rows":[{"title":"ok"},"x"]}`, `{"rows":[[1]]}`} { + _, details := ucDecode(t, cc, raw, "update") + msgs, _ := details["rows"].([]string) + if len(msgs) != 1 || !strings.Contains(msgs[0], "must be a list of rows") { + t.Fatalf("%s: details = %v", raw, details) + } + } + }) + + t.Run("row bounds", func(t *testing.T) { + _, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"},{"title":"4"}]}`, "update") + if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMaxItems { + t.Fatalf("max details = %v", details) + } + _, details = ucDecode(t, cc, `{"rows":[]}`, "update") + if msgs, _ := details["rows"].([]string); len(msgs) != 1 || msgs[0] != msgRepeaterMinItems { + t.Fatalf("min details = %v", details) + } + body, details := ucDecode(t, cc, `{"rows":[{"title":"1"},{"title":"2"},{"title":"3"}]}`, "update") + if details != nil || len(body["rows"].([]any)) != 3 { + t.Fatalf("three rows: %v %v", body, details) + } + }) + + t.Run("null is an empty list and an absent repeater is left alone", func(t *testing.T) { + free := ucMustCompile(t, ucFields+ucRepeater(""), nil) + body, details := ucDecode(t, free, `{"rows":null}`, "update") + if details != nil || fmt.Sprint(body["rows"]) != "[]" { + t.Fatalf("null rows: %v %v", body, details) + } + body, details = ucDecode(t, free, `{"title":"x"}`, "create") + if _, present := body["rows"]; present || details != nil { + t.Fatalf("absent rows: %v %v", body, details) + } + }) + + t.Run("a required repeater needs a row", func(t *testing.T) { + required := ucMustCompile(t, ucFields+ucRepeater(" required: true\n"), nil) + for _, tc := range []struct{ raw, op string }{{`{"rows":[]}`, "update"}, {`{"rows":null}`, "create"}, {`{"title":"x"}`, "create"}} { + _, details := ucDecode(t, required, tc.raw, tc.op) + if msgs, _ := details["rows"].([]string); len(msgs) != 1 || !strings.Contains(msgs[0], "is required") { + t.Fatalf("%s on %s: details = %v", tc.raw, tc.op, details) + } + } + if _, details := ucDecode(t, required, `{"title":"x"}`, "update"); details != nil { + t.Fatalf("absent on update: %v", details) + } + }) + + t.Run("nested richeditors are sanitized with row-path notices", func(t *testing.T) { + projected := map[string]any{"rows": []any{ + map[string]any{"title": "a", "body": "

clean

"}, + map[string]any{"title": "b", "body": `

dirty

`}, + map[string]any{"title": "c", "body": nil}, + }} + notices := sanitizeRepeaterRichEditors(cc, projected) + rows := projected["rows"].([]any) + if rows[0].(map[string]any)["body"] != "

clean

" || rows[1].(map[string]any)["body"] != "

dirty

" || rows[2].(map[string]any)["body"] != nil { + t.Fatalf("rows = %v", rows) + } + var fields []string + for _, n := range notices { + fields = append(fields, n.field+":"+n.notice.Message) + } + if strings.Join(fields, ",") != "rows.1.body:"+msgRichRemovedElements+",rows.1.body:"+msgRichRemovedAttributes { + t.Fatalf("notices = %v", fields) + } + }) + + t.Run("stored rows hydrate to a list", func(t *testing.T) { + stored := lagoon.Jsonable[[]map[string]any]{Data: []map[string]any{{"title": "a"}}, Valid: true} + text := `[{"title":"b"}]` + var nilText *string + for _, tc := range []struct { + value any + want string + }{ + {stored, "[map[title:a]]"}, + {text, "[map[title:b]]"}, + {&text, "[map[title:b]]"}, + {nilText, "[]"}, + {nil, "[]"}, + {"not json", "[]"}, + {"null", "[]"}, + {[]map[string]any{{"title": "c"}}, "[map[title:c]]"}, + } { + data := map[string]any{"rows": tc.value, "title": "kept"} + hydrateRepeaterFields(cc, data) + if fmt.Sprint(data["rows"]) != tc.want || data["title"] != "kept" { + t.Fatalf("hydrate(%#v) = %v, want %s", tc.value, data["rows"], tc.want) + } + } + data := map[string]any{"title": "no rows key"} + hydrateRepeaterFields(cc, data) + if _, present := data["rows"]; present { + t.Fatal("hydrate added a missing rows key") + } + }) +} diff --git a/modules/cabana/richeditor_test.go b/modules/cabana/richeditor_test.go new file mode 100644 index 0000000..a17a044 --- /dev/null +++ b/modules/cabana/richeditor_test.go @@ -0,0 +1,136 @@ +package cabana + +import ( + "errors" + "fmt" + "strings" + "testing" + "testing/fstest" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/seaglass" +) + +// TestRichEditorCompile pins D-10's Docker-free rules: the keys a richeditor +// takes, the notices a removal report becomes, the value check before a +// save, the projected sanitize step, and the boot refusal of the +// controller-only features on settings forms. +func TestRichEditorCompile(t *testing.T) { + t.Run("keys", func(t *testing.T) { + cc := ucMustCompile(t, ucFields+" description:\n type: richeditor\n label: Description\n size: large\n span: left\n comment: HTML\n required: true\n tab: Content\n default: \"

x

\"\n", nil) + if d := ucField(t, cc, "description"); d.Type != richEditorType || d.Size != "large" || d.Tab != "Content" { + t.Fatalf("description = %+v", d) + } + for _, key := range []string{"options: typeOptions", "attributes: {}", "placeholder: x", "toolbarButtons: bold", "prompt: x", "maxItems: 2"} { + _, err := ucCompile(ucFields+" description:\n type: richeditor\n "+key+"\n", nil) + if err == nil { + t.Fatalf("richeditor accepted %s", key) + } + } + _, err := ucCompile(ucFields+" description:\n type: richeditor\n options: typeOptions\n", nil) + wantErr(t, err, "options is not valid on type: richeditor") + }) + + t.Run("notices", func(t *testing.T) { + for _, tc := range []struct { + name string + in seaglass.Removals + want string + }{ + {"nothing removed", seaglass.Removals{}, "[]"}, + {"elements", seaglass.Removals{Elements: []string{"script", "iframe"}, ElementCount: 3, Changed: true}, + "[" + msgRichRemovedElements + " names=script, iframe count=3]"}, + {"attributes", seaglass.Removals{Attributes: []string{"onclick"}, AttributeCount: 1, Changed: true}, + "[" + msgRichRemovedAttributes + " names=onclick count=1]"}, + {"comments", seaglass.Removals{Comments: 2, Changed: true}, "[" + msgRichRemovedComments + " names= count=2]"}, + {"all three in order", seaglass.Removals{Elements: []string{"style"}, ElementCount: 1, Attributes: []string{"style", "rel"}, AttributeCount: 2, Comments: 1, Changed: true}, + "[" + msgRichRemovedElements + " names=style count=1 " + msgRichRemovedAttributes + " names=style, rel count=2 " + msgRichRemovedComments + " names= count=1]"}, + {"markup changed only", seaglass.Removals{Changed: true}, "[" + msgRichMarkupChanged + " names= count=0]"}, + } { + var parts []string + for _, n := range richEditorNotices(tc.in) { + parts = append(parts, fmt.Sprintf("%s names=%s count=%d", n.Message, n.Params["names"], n.Count)) + } + if got := "[" + strings.Join(parts, " ") + "]"; got != tc.want { + t.Fatalf("%s: notices = %s\nwant %s", tc.name, got, tc.want) + } + } + }) + + cc := ucMustCompile(t, ucFields+" description:\n type: richeditor\n"+ucRepeater(""), nil) + + t.Run("a value that is not a string or null is a 422 on the field", func(t *testing.T) { + for _, raw := range []string{`{"description":"

x

"}`, `{"description":null}`, `{"title":"no description"}`} { + if err := checkRichEditorValues(cc, utJSON(t, raw).(map[string]any), "update"); err != nil { + t.Fatalf("%s: %v", raw, err) + } + } + for _, raw := range []string{`{"description":1}`, `{"description":true}`, `{"description":["

x

"]}`, `{"description":{"pl":"

x

"}}`} { + err := checkRichEditorValues(cc, utJSON(t, raw).(map[string]any), "create") + var invalid *ValidationError + if !errors.As(err, &invalid) || invalid.Details["description"] == nil || len(invalid.Details) != 1 { + t.Fatalf("%s: err = %v", raw, err) + } + } + }) + + t.Run("projected values are sanitized, clean ones byte for byte", func(t *testing.T) { + clean := "

It's fine & \"quoted\"

" + projected := map[string]any{"description": clean} + if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != clean { + t.Fatalf("clean: %v %v", projected, notices) + } + projected = map[string]any{"description": `

a

`} + notices := sanitizeRichEditors(cc, projected) + if projected["description"] != "

a

" || len(notices) != 2 || notices[0].field != "description" || + notices[0].notice.Message != msgRichRemovedAttributes || notices[1].notice.Message != msgRichRemovedComments { + t.Fatalf("dirty: %v %+v", projected, notices) + } + projected = map[string]any{"description": nil, "title": ``} + if notices := sanitizeRichEditors(cc, projected); len(notices) != 0 || projected["description"] != nil || projected["title"] != `` { + t.Fatalf("null and other fields: %v %v", projected, notices) + } + if notices := sanitizeRichEditors(cc, map[string]any{}); len(notices) != 0 { + t.Fatal("absent value reported") + } + }) + + t.Run("settings forms refuse the controller-only features", func(t *testing.T) { + for name, field := range map[string]string{ + "richeditor": " type: richeditor\n", + "repeater": " type: repeater\n form:\n fields:\n a:\n type: text\n", + "dependsOn": " type: dropdown\n dependsOn: enabled\n", + } { + fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n enabled:\n type: switch\n extra:\n" + field)}} + item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }} + _, err := compileSetting("acme.unit", item, fsys) + wantErr(t, err, "setting demo field extra", "supported in controller forms only") + if name != "dependsOn" { + wantErr(t, err, name) + } + } + }) + + t.Run("controllerFormOnly", func(t *testing.T) { + for _, field := range []FormField{{Type: richEditorType}, {Type: repeaterType}, {Type: "dropdown", DependsOn: "type"}} { + if err := controllerFormOnly(field); err == nil { + t.Fatalf("%+v accepted", field) + } + } + for _, field := range []FormField{{Type: "text"}, {Type: "dropdown"}, {Type: "markdown"}} { + if err := controllerFormOnly(field); err != nil { + t.Fatalf("%+v refused: %v", field, err) + } + } + }) +} + +// ucSettings is the settings model of the settings refusal case. +type ucSettings struct { + ID uint `gorm:"column:id;primaryKey"` + Enabled bool `gorm:"column:enabled"` + Extra string `gorm:"column:extra"` +} + +func (*ucSettings) Fillable() []string { return []string{"enabled", "extra"} } +func (*ucSettings) Rules() map[string]string { return map[string]string{} } diff --git a/modules/cabana/singleton_test.go b/modules/cabana/singleton_test.go new file mode 100644 index 0000000..f06df73 --- /dev/null +++ b/modules/cabana/singleton_test.go @@ -0,0 +1,113 @@ +package cabana_test + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// TestSingletonCompile pins the D-11 boot rules, Docker-free: a singleton +// controller needs config_form.yaml and no config_list.yaml, and may keep +// one (its create button is dropped then). +func TestSingletonCompile(t *testing.T) { + if err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, nil)}); err != nil { + t.Fatalf("fixture without a list: %v", err) + } + withList := companionsOverlay(t, map[string]string{ + "controllers/profiles/config_list.yaml": "list: ~/plugins/acme/companions/models/profile/columns.yaml\nmodelClass: Profile\ntoolbar:\n buttons: [create]\n", + "models/profile/columns.yaml": "columns:\n title:\n label: Title\n", + }) + if err := activateCompanions(t, companionsFSPlugin{fsys: withList}); err != nil { + t.Fatalf("singleton with a list: %v", err) + } + err := activateCompanions(t, companionsFSPlugin{fsys: companionsOverlay(t, map[string]string{"controllers/profiles/config_form.yaml": ""})}) + if err == nil || !strings.Contains(err.Error(), "acme.companions.profiles: a singleton controller (pact.AdminSingleton) needs config_form.yaml") { + t.Fatalf("singleton without a form: err = %v", err) + } + // A controller that is not a singleton still needs its list. + err = activateCompanions(t, companionsFSPlugin{ + fsys: companionsOverlay(t, nil), + controllers: []pact.AdminController{plainProfiles{}}, + }) + if err == nil || !strings.Contains(err.Error(), "config_list.yaml") { + t.Fatalf("plain controller without a list: err = %v", err) + } +} + +// plainProfiles is the profiles controller without pact.AdminSingleton. +type plainProfiles struct{} + +func (plainProfiles) ID() string { return "acme.companions.profiles" } +func (plainProfiles) ModelName() string { return "Profile" } +func (plainProfiles) ConfigDir() string { return "controllers/profiles" } +func (plainProfiles) RequiredPermissions() []string { return []string{"acme.companions.access"} } +func (plainProfiles) NewRecord() any { return &companionsProfile{} } + +// TestSingleton pins the D-11 routes on Postgres: the singleton route +// creates the record once and answers its id; the record shows and updates; list, create, delete, bulk delete +// and reorder answer 404; the form schema says singleton; a controller that +// is not a singleton answers 404 on the route; anonymous requests 401. +func TestSingleton(t *testing.T) { + e := newCompanionsEnv(t) + first := e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true) + id := dataID(t, first.Body.Bytes()) + for range 2 { + if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id { + t.Fatalf("id = %d, want %d", again, id) + } + } + var count int64 + if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("profiles = %d (%v)", count, err) + } + var profile companionsProfile + if err := e.db.First(&profile, id).Error; err != nil || profile.Key != "default" || profile.Title != "Default" { + t.Fatalf("profile = %+v (%v)", profile, err) + } + + path := fmt.Sprintf("/acme/companions/profiles/%d", id) + if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"title":"Default"`) { + t.Fatalf("show = %d %s", rec.Code, rec.Body.String()) + } + if rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true); rec.Code != http.StatusOK { + t.Fatalf("update = %d %s", rec.Code, rec.Body.String()) + } + if err := e.db.First(&profile, id).Error; err != nil || profile.Title != "Renamed" || profile.Key != "default" { + t.Fatalf("updated profile = %+v (%v)", profile, err) + } + if again := dataID(t, e.send(t, http.MethodGet, "/acme/companions/profiles/singleton", nil, true).Body.Bytes()); again != id { + t.Fatalf("id after update = %d", again) + } + + for _, tc := range []struct { + method, path string + body any + auth bool + status int + }{ + {http.MethodGet, "/acme/companions/profiles", nil, true, http.StatusNotFound}, + {http.MethodPost, "/acme/companions/profiles", map[string]any{"title": "x"}, true, http.StatusNotFound}, + {http.MethodDelete, path, nil, true, http.StatusNotFound}, + {http.MethodPost, "/acme/companions/profiles/bulk-delete", map[string]any{"ids": []uint{id}}, true, http.StatusNotFound}, + {http.MethodGet, "/acme/companions/profiles/reorder", nil, true, http.StatusNotFound}, + {http.MethodGet, "/acme/companions/links/singleton", nil, true, http.StatusNotFound}, + {http.MethodGet, "/acme/companions/profiles/singleton", nil, false, http.StatusUnauthorized}, + } { + if rec := e.send(t, tc.method, tc.path, tc.body, tc.auth); rec.Code != tc.status { + t.Fatalf("%s %s status=%d want %d body=%s", tc.method, tc.path, rec.Code, tc.status, rec.Body.String()) + } + } + if err := e.db.Model(&companionsProfile{}).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("profiles after refused requests = %d (%v)", count, err) + } + schema := e.send(t, http.MethodGet, "/acme/companions/profiles/schema/form", nil, true) + if !strings.Contains(schema.Body.String(), `"singleton":true`) { + t.Fatalf("form schema = %s", schema.Body.String()) + } + if links := e.send(t, http.MethodGet, "/acme/companions/links/schema/form", nil, true); strings.Contains(links.Body.String(), `"singleton":true`) { + t.Fatalf("a plain form schema says singleton: %s", links.Body.String()) + } +} diff --git a/modules/pact/menu_item_types_test.go b/modules/pact/menu_item_types_test.go new file mode 100644 index 0000000..e494ec7 --- /dev/null +++ b/modules/pact/menu_item_types_test.go @@ -0,0 +1,117 @@ +package pact + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "gorm.io/gorm" +) + +// TestOptionDepthOmitEmpty pins that Option.Depth leaves every flat option +// payload unchanged: depth 0 has no JSON key, a nested option carries it. +func TestOptionDepthOmitEmpty(t *testing.T) { + for _, tc := range []struct { + opt Option + want string + }{ + {Option{Value: "a", Label: "A"}, `{"value":"a","label":"A"}`}, + {Option{Value: "", Label: ""}, `{"value":"","label":""}`}, + {Option{Value: "b", Label: "B", Depth: 1}, `{"value":"b","label":"B","depth":1}`}, + {Option{Value: "c", Label: "C", Depth: 3}, `{"value":"c","label":"C","depth":3}`}, + } { + raw, err := json.Marshal(tc.opt) + if err != nil || string(raw) != tc.want { + t.Fatalf("Marshal(%+v) = %s, %v; want %s", tc.opt, raw, err, tc.want) + } + var back Option + if err := json.Unmarshal(raw, &back); err != nil || back != tc.opt { + t.Fatalf("round trip of %s = %+v, %v", raw, back, err) + } + } +} + +// menuTypesPlugin is a plugin-shaped value with menu item types. +type menuTypesPlugin struct{ types []MenuItemType } + +func (p menuTypesPlugin) MenuItemTypes() []MenuItemType { return p.types } + +// singletonController is a controller-shaped value that edits one record. +type singletonController struct{} + +func (singletonController) SingletonRecord(context.Context, *gorm.DB) (any, error) { return 1, nil } + +// dependentController answers the options of a dependent dropdown. +type dependentController struct{} + +func (dependentController) DependentOptions(_ context.Context, field string, values map[string]any) (DependentOptionSet, error) { + if values["type"] == nil { + return DependentOptionSet{Applies: false}, nil + } + return DependentOptionSet{Applies: true, Options: []Option{{Value: field, Label: "x"}}}, nil +} + +var ( + _ HasMenuItemTypes = menuTypesPlugin{} + _ AdminSingleton = singletonController{} + _ DependentOptionsProvider = dependentController{} +) + +// TestMenuItemTypeContracts pins the shape of the menu item type contracts: +// a type marshals without its functions, a resolved item keeps its +// alternates in the order given (never a map), list types carry their +// children in Items, and the capability interfaces are satisfied by plain +// values. +func TestMenuItemTypeContracts(t *testing.T) { + typ := MenuItemType{ + Key: "acme-post", Label: "acme.blog::lang.post", DefaultPattern: "/blog/:slug", PatternFrom: "", + Placeholders: []string{":slug", ":id"}, Nesting: true, DynamicItems: false, + References: func(context.Context) ([]MenuItemReference, error) { return nil, nil }, + Resolve: func(_ context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) { + return &ResolvedMenuItem{Title: in.Reference, URL: in.Pattern}, nil + }, + } + raw, err := json.Marshal(typ) + if err != nil { + t.Fatalf("Marshal(MenuItemType) = %v", err) + } + if strings.Contains(string(raw), "Resolve") || strings.Contains(string(raw), "References") { + t.Fatalf("functions leaked into JSON: %s", raw) + } + got, err := typ.Resolve(context.Background(), MenuItemResolve{Type: "acme-post", Reference: "7", Pattern: "/blog/:slug"}) + if err != nil || got.Title != "7" || got.URL != "/blog/:slug" { + t.Fatalf("Resolve = %+v, %v", got, err) + } + + when := time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) + item := ResolvedMenuItem{ + Title: "All posts", URL: "", MTime: &when, + Alternates: []MenuItemAlternate{{Locale: "pl", URL: "/a"}, {Locale: "en", URL: "/en/a"}, {Locale: "de", URL: "/de/a"}}, + Items: []ResolvedMenuItem{{Title: "One", URL: "/blog/one"}, {Title: "Two", URL: "/blog/two"}}, + } + var locales []string + for _, alt := range item.Alternates { + locales = append(locales, alt.Locale) + } + if strings.Join(locales, ",") != "pl,en,de" || item.URL != "" || len(item.Items) != 2 { + t.Fatalf("resolved list item = %+v", item) + } + + var source any = menuTypesPlugin{types: []MenuItemType{typ}} + if p, ok := source.(HasMenuItemTypes); !ok || len(p.MenuItemTypes()) != 1 { + t.Fatal("HasMenuItemTypes not satisfied") + } + set, err := dependentController{}.DependentOptions(context.Background(), "reference", map[string]any{"type": nil}) + if err != nil || set.Applies || set.Options != nil { + t.Fatalf("not-applying set = %+v, %v", set, err) + } + set, err = dependentController{}.DependentOptions(context.Background(), "rows.reference", map[string]any{"type": "acme-post"}) + if err != nil || !set.Applies || set.Options[0].Value != "rows.reference" { + t.Fatalf("applying set = %+v, %v", set, err) + } + if rec, err := (singletonController{}).SingletonRecord(context.Background(), nil); err != nil || rec != 1 { + t.Fatalf("SingletonRecord = %v, %v", rec, err) + } +} diff --git a/modules/party/registry_test.go b/modules/party/registry_test.go index db71b15..489b19f 100644 --- a/modules/party/registry_test.go +++ b/modules/party/registry_test.go @@ -1,6 +1,7 @@ package party import ( + "context" "fmt" "io/fs" "os" @@ -15,6 +16,7 @@ import ( "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/postcard" + "git.golem15.com/golem15/summercms/modules/signpost" ) type tracePlugin struct { @@ -346,7 +348,6 @@ func TestActivatePublishesActivatedOrderAndRefusesSecond(t *testing.T) { } } - func TestRegisterNilIsNoop(t *testing.T) { Register(nil) } @@ -527,12 +528,12 @@ type mailPlugin struct { sawMailer *bool } -func (p *mailPlugin) ID() string { return p.id } -func (p *mailPlugin) Requires() []string { return nil } -func (p *mailPlugin) Register(*backpack.App) error { return nil } -func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys } -func (p *mailPlugin) MailTemplates() []string { return p.templates } -func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts } +func (p *mailPlugin) ID() string { return p.id } +func (p *mailPlugin) Requires() []string { return nil } +func (p *mailPlugin) Register(*backpack.App) error { return nil } +func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys } +func (p *mailPlugin) MailTemplates() []string { return p.templates } +func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts } func (p *mailPlugin) Boot(app *backpack.App) error { if p.sawMailer == nil { @@ -551,7 +552,7 @@ func TestActivatePublishesMailerBeforeBoot(t *testing.T) { templates: []string{"golem15.hello::mail.hello"}, layouts: map[string]string{"hello": "golem15.hello::mail.layouts.hello"}, fsys: fstest.MapFS{ - "views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")}, + "views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")}, "views/mail/layouts/hello.htm": {Data: []byte("name = \"Hello\"\n==\n{{ .Content }}\n==\n
{{ .Content }}
\n")}, }, } @@ -580,3 +581,80 @@ func TestActivateMissingMailTemplateFailsBootWithName(t *testing.T) { t.Fatalf("error %q should name the boot plugin and missing template", msg) } } + +// menuTypesPlugin registers one menu item type and records whether the +// signpost registry was published at Register and at Boot. +type menuTypesPlugin struct { + id string + key string + log *[]string + atRegister *bool + atBoot *bool + typesAtBoot *int +} + +func (p *menuTypesPlugin) ID() string { return p.id } +func (p *menuTypesPlugin) Requires() []string { return nil } + +func (p *menuTypesPlugin) MenuItemTypes() []pact.MenuItemType { + *p.log = append(*p.log, p.id+":types") + return []pact.MenuItemType{{Key: p.key, DefaultPattern: "/:slug", Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { + return &pact.ResolvedMenuItem{}, nil + }}} +} + +func (p *menuTypesPlugin) Register(app *backpack.App) error { + *p.log = append(*p.log, p.id+":register") + _, ok := app.Lookup[*signpost.Registry]() + *p.atRegister = ok + return nil +} + +func (p *menuTypesPlugin) Boot(app *backpack.App) error { + *p.log = append(*p.log, p.id+":boot") + reg, ok := app.Lookup[*signpost.Registry]() + *p.atBoot = ok + if ok { + *p.typesAtBoot = len(reg.Types()) + } + return nil +} + +// TestActivateMenuItemTypesOrder pins the party hook: menu item types are +// collected after every Register and before the first Boot, so a Boot sees +// the full registry; a duplicate key fails activation naming both plugins and +// no plugin boots. +func TestActivateMenuItemTypesOrder(t *testing.T) { + var log []string + var reg1, boot1, reg2, boot2 bool + var n1, n2 int + a := &menuTypesPlugin{id: "acme.a", key: "acme-a", log: &log, atRegister: ®1, atBoot: &boot1, typesAtBoot: &n1} + b := &menuTypesPlugin{id: "acme.b", key: "acme-b", log: &log, atRegister: ®2, atBoot: &boot2, typesAtBoot: &n2} + app := backpack.New(&compass.Config{}) + if _, err := activate([]Plugin{a, b}, app, []string{"acme.a", "acme.b"}); err != nil { + t.Fatalf("activate: %v", err) + } + if reg1 || reg2 { + t.Fatal("the registry existed during Register") + } + if !boot1 || !boot2 || n1 != 2 || n2 != 2 { + t.Fatalf("Boot saw registry %v/%v with %d/%d types, want both and 2", boot1, boot2, n1, n2) + } + want := "acme.a:register,acme.b:register,acme.a:types,acme.b:types,acme.a:boot,acme.b:boot" + if got := strings.Join(log, ","); got != want { + t.Fatalf("order = %s\nwant %s", got, want) + } + + log = nil + dupA := &menuTypesPlugin{id: "acme.a", key: "acme-same", log: &log, atRegister: ®1, atBoot: &boot1, typesAtBoot: &n1} + dupB := &menuTypesPlugin{id: "acme.b", key: "acme-same", log: &log, atRegister: ®2, atBoot: &boot2, typesAtBoot: &n2} + _, err := activate([]Plugin{dupA, dupB}, backpack.New(&compass.Config{}), []string{"acme.a", "acme.b"}) + if err == nil || !strings.Contains(err.Error(), "party: menu item types") || !strings.Contains(err.Error(), "acme.a") || !strings.Contains(err.Error(), "acme.b") { + t.Fatalf("duplicate error = %v", err) + } + for _, entry := range log { + if strings.HasSuffix(entry, ":boot") { + t.Fatalf("a plugin booted after the duplicate: %v", log) + } + } +} diff --git a/modules/seaglass/seaglass_test.go b/modules/seaglass/seaglass_test.go index 4c09194..7468983 100644 --- a/modules/seaglass/seaglass_test.go +++ b/modules/seaglass/seaglass_test.go @@ -1,6 +1,8 @@ package seaglass_test import ( + "slices" + "strings" "testing" "git.golem15.com/golem15/summercms/modules/seaglass" @@ -48,3 +50,161 @@ func TestSanitizeByteStable(t *testing.T) { } } } + +// TestSanitizeReport pins the removal report: element, attribute and comment +// names and counts, the ten-name cap with its exact counts, a repeated +// attribute, a style that lost a property, and the sanitized output itself. +func TestSanitizeReport(t *testing.T) { + for _, tc := range []struct { + name string + in, out string + elements []string + elemCount int + attributes []string + attrCount int + comments int + }{ + { + name: "element, attribute and comments", + in: `

Hi

`, + out: `

Hi

`, elements: []string{"script"}, elemCount: 1, attributes: []string{"onclick"}, attrCount: 1, comments: 2, + }, + { + name: "names are distinct in first-seen order, counts are exact", + in: `

1

2

`, + out: `

1

2

`, elements: []string{"script"}, elemCount: 2, attributes: []string{"onclick", "onmouseover"}, attrCount: 3, + }, + { + name: "ten names then an ellipsis, the count stays exact", + in: `
`, + out: "", elements: []string{"iframe", "object", "embed", "form", "input", "base", "meta", "link", "svg", "math", "…"}, elemCount: 12, + }, + { + name: "a repeated attribute counts as removed", + in: `x`, out: `x`, attributes: []string{"href"}, attrCount: 1, + }, + { + name: "a style that lost a property counts under style", + in: `

x

`, out: `

x

`, attributes: []string{"style"}, attrCount: 1, + }, + { + name: "target and rel are removed", + in: `x`, out: `x`, attributes: []string{"target", "rel"}, attrCount: 2, + }, + { + name: "a link without an allowed href loses its tag, not its text", + in: `

x y

`, out: `

x y

`, elements: []string{"a"}, elemCount: 1, + }, + { + name: "only comments", + in: `

a

`, out: `

a

`, 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 + }{ + {``, []string{"onerror", "alert"}}, + {``, []string{"onerror", "alert"}}, + {`

x

`, []string{"onmouseover", "alert"}}, + {``, []string{"x`, []string{"href", "javascript", "alert"}}, + {`x`, []string{"href", "javascript", "alert"}}, + {`x`, []string{"href", "avascript", "alert"}}, + {"x", []string{"href", "script", "alert"}}, + {`x`, []string{"href", "javascript", "alert"}}, + {`x`, []string{"`, []string{"`, []string{"`, []string{"x`, []string{"href", "vbscript"}}, + {`x`, []string{"javascript", "alert"}}, + {`
x
`, []string{"style", "javascript"}}, + {``, []string{"x

`, []string{"x

`, []string{"`, []string{" %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{ + "", + "

Data: 12 maja 2024, metadata: none

", + "

javascript: is just a word here

", + `

It's "fine"

`, + `call`, + } { + if err := seaglass.Check(html); err != nil { + t.Errorf("Check(%q) = %v", html, err) + } + } + for _, html := range []string{ + "", + `dup`, + `

x

`, + `ftp`, + } { + err := seaglass.Check(html) + if err == nil || !strings.HasPrefix(err.Error(), "seaglass: ") { + t.Errorf("Check(%q) = %v, want a seaglass error", html, err) + } + } +} diff --git a/modules/signpost/signpost_test.go b/modules/signpost/signpost_test.go index 423b073..7a6f12f 100644 --- a/modules/signpost/signpost_test.go +++ b/modules/signpost/signpost_test.go @@ -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) + } +} diff --git a/scripts/check-phase17.sh b/scripts/check-phase17.sh index de288a5..19d0bb5 100755 --- a/scripts/check-phase17.sh +++ b/scripts/check-phase17.sh @@ -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" }