diff --git a/admin/tests/app/dateFormat.test.ts b/admin/tests/app/dateFormat.test.ts new file mode 100644 index 0000000..304cfc8 --- /dev/null +++ b/admin/tests/app/dateFormat.test.ts @@ -0,0 +1,208 @@ +// Datepicker value conversions (Phase 12.2, D-18 to D-21): parse and emit +// per mode, ignoreTimezone, display formats, week start and bounds. The +// datetime cases run in a fixed non-UTC zone. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + CalendarDate, + CalendarDateTime, + Time, + ZonedDateTime, + resetLocalTimeZone, + setLocalTimeZone, +} from '@internationalized/date' +import { + DEFAULT_DISPLAY, + boundValue, + dateMode, + dayBounds, + emitFieldValue, + formatDisplay, + parseFieldValue, + startValue, + weekStart, +} from '../../src/app/dateFormat' + +beforeEach(() => setLocalTimeZone('Europe/Warsaw')) +afterEach(() => resetLocalTimeZone()) + +describe('dateMode', () => { + it('defaults to datetime', () => { + expect(dateMode('date')).toBe('date') + expect(dateMode('time')).toBe('time') + expect(dateMode('datetime')).toBe('datetime') + expect(dateMode(undefined)).toBe('datetime') + expect(dateMode(null)).toBe('datetime') + expect(dateMode('week')).toBe('datetime') + }) +}) + +describe('parseFieldValue', () => { + it('reads null, empty and unreadable values as null', () => { + for (const mode of ['date', 'datetime', 'time'] as const) { + expect(parseFieldValue(mode, null)).toBeNull() + expect(parseFieldValue(mode, undefined)).toBeNull() + expect(parseFieldValue(mode, '')).toBeNull() + expect(parseFieldValue(mode, ' ')).toBeNull() + expect(parseFieldValue(mode, 42)).toBeNull() + expect(parseFieldValue(mode, 'garbage')).toBeNull() + } + }) + + it('reads a date as a calendar day, ignoring any time part', () => { + expect(parseFieldValue('date', '2026-10-02')?.toString()).toBe('2026-10-02') + expect(parseFieldValue('date', '2026-10-02T23:30:00Z')?.toString()).toBe('2026-10-02') + expect(parseFieldValue('date', '2026-10-02')).toBeInstanceOf(CalendarDate) + }) + + it('reads a time with or without seconds or a date', () => { + expect(parseFieldValue('time', '14:30')?.toString()).toBe('14:30:00') + expect(parseFieldValue('time', '14:30:15')?.toString()).toBe('14:30:15') + expect(parseFieldValue('time', '2026-10-02T08:05:00Z')?.toString()).toBe('08:05:00') + expect(parseFieldValue('time', '14:30')).toBeInstanceOf(Time) + }) + + it('shows a datetime in the local zone, a zone-less one read as UTC', () => { + const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') as ZonedDateTime + expect(zoned).toBeInstanceOf(ZonedDateTime) + expect(zoned.timeZone).toBe('Europe/Warsaw') + expect([zoned.hour, zoned.minute]).toEqual([12, 30]) + const winter = parseFieldValue('datetime', '2026-01-15 10:30:00') as ZonedDateTime + expect([winter.hour, winter.minute]).toEqual([11, 30]) + const offset = parseFieldValue('datetime', '2026-10-02T10:30:00+02:00') as ZonedDateTime + expect([offset.hour, offset.minute]).toEqual([10, 30]) + }) + + it('keeps the UTC wall clock with ignoreTimezone', () => { + const wall = parseFieldValue('datetime', '2026-10-02T10:30:00Z', true) + expect(wall).toBeInstanceOf(CalendarDateTime) + expect(wall?.toString()).toBe('2026-10-02T10:30:00') + }) +}) + +describe('emitFieldValue', () => { + it('emits null for no value', () => { + expect(emitFieldValue('date', null)).toBeNull() + expect(emitFieldValue('datetime', undefined)).toBeNull() + expect(emitFieldValue('time', null)).toBeNull() + }) + + it('emits a date as YYYY-MM-DD and a time as HH:MM:SS', () => { + expect(emitFieldValue('date', new CalendarDate(26, 1, 2))).toBe('0026-01-02') + expect(emitFieldValue('date', new CalendarDateTime(2026, 10, 2, 23, 59))).toBe('2026-10-02') + expect(emitFieldValue('time', new Time(7, 5))).toBe('07:05:00') + expect(emitFieldValue('time', new CalendarDate(2026, 10, 2))).toBeNull() + expect(emitFieldValue('date', new Time(7, 5))).toBeNull() + }) + + it('emits a local datetime as a UTC instant without milliseconds', () => { + expect(emitFieldValue('datetime', new CalendarDateTime(2026, 10, 2, 12, 30))).toBe('2026-10-02T10:30:00Z') + expect(emitFieldValue('datetime', new CalendarDateTime(2026, 1, 1, 0, 30))).toBe('2025-12-31T23:30:00Z') + expect(emitFieldValue('datetime', new CalendarDate(2026, 10, 2))).toBe('2026-10-01T22:00:00Z') + const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') + expect(emitFieldValue('datetime', zoned)).toBe('2026-10-02T10:30:00Z') + }) + + it('emits the wall clock unchanged with ignoreTimezone', () => { + expect(emitFieldValue('datetime', new CalendarDateTime(2026, 10, 2, 12, 30), true)).toBe('2026-10-02T12:30:00Z') + const zoned = parseFieldValue('datetime', '2026-10-02T10:30:00Z') as ZonedDateTime + expect(emitFieldValue('datetime', zoned, true)).toBe('2026-10-02T12:30:00Z') + expect(emitFieldValue('datetime', new CalendarDate(2026, 10, 2), true)).toBe('2026-10-02T00:00:00Z') + }) + + it('round-trips every mode', () => { + for (const [mode, text] of [ + ['date', '2026-02-28'], + ['time', '23:59:59'], + ['datetime', '2026-03-29T01:30:00Z'], + ] as const) { + expect(emitFieldValue(mode, parseFieldValue(mode, text))).toBe(text) + } + expect(emitFieldValue('datetime', parseFieldValue('datetime', '2026-10-02T10:30:00Z', true), true)).toBe('2026-10-02T10:30:00Z') + }) +}) + +describe('formatDisplay', () => { + const day = new CalendarDate(2026, 10, 2) + const at = new CalendarDateTime(2026, 10, 2, 14, 5, 9) + + it('uses the mode default without a display format', () => { + expect(formatDisplay(day, 'date', undefined, 'en')).toBe('2026-10-02') + expect(formatDisplay(at, 'datetime', '', 'en')).toBe('2026-10-02 14:05') + expect(formatDisplay(new Time(9, 3), 'time', null, 'en')).toBe('09:03') + expect(DEFAULT_DISPLAY).toEqual({ date: 'YYYY-MM-DD', datetime: 'YYYY-MM-DD HH:mm', time: 'HH:mm' }) + expect(formatDisplay(null, 'date', 'DD', 'en')).toBe('') + }) + + it('renders every token', () => { + expect(formatDisplay(at, 'datetime', 'DD.MM.YYYY HH:mm:ss', 'pl')).toBe('02.10.2026 14:05:09') + expect(formatDisplay(at, 'datetime', 'D/M/YY H:mm', 'en')).toBe('2/10/26 14:05') + expect(formatDisplay(at, 'datetime', 'hh:mm A', 'en')).toBe('02:05 PM') + expect(formatDisplay(new CalendarDateTime(2026, 10, 2, 0, 5), 'datetime', 'h:mm a', 'en')).toBe('12:05 am') + expect(formatDisplay(at, 'datetime', 'dddd, D MMMM YYYY', 'en')).toBe('Friday, 2 October 2026') + expect(formatDisplay(at, 'datetime', 'ddd D MMM', 'en')).toBe('Fri 2 Oct') + expect(formatDisplay(at, 'datetime', 'YYYY-MM-DD [at] HH:mm', 'en')).toBe('2026-10-02 at 14:05') + }) + + it('names months and days in the admin locale, genitive next to a day', () => { + expect(formatDisplay(day, 'date', 'D MMMM YYYY', 'pl')).toBe('2 października 2026') + expect(formatDisplay(day, 'date', 'MMMM YYYY', 'pl')).toBe('październik 2026') + expect(formatDisplay(day, 'date', 'dddd', 'pl')).toBe('piątek') + }) + + it('leaves clock tokens empty on a date and date tokens empty on a time', () => { + expect(formatDisplay(day, 'date', 'YYYY-MM-DD HH:mm', 'en')).toBe('2026-10-02 :') + expect(formatDisplay(new Time(9, 3), 'time', 'DD HH:mm', 'en')).toBe(' 09:03') + }) +}) + +describe('weekStart', () => { + it('takes an explicit firstDay', () => { + expect(weekStart('pl', 0)).toBe(0) + expect(weekStart('en-US', 6)).toBe(6) + }) + + it('falls back to the locale for a missing or invalid firstDay', () => { + expect(weekStart('pl')).toBe(1) + expect(weekStart('en-US')).toBe(0) + expect(weekStart('pl', 7)).toBe(1) + expect(weekStart('pl', 1.5)).toBe(1) + expect(weekStart('en-US', null)).toBe(0) + }) +}) + +describe('dayBounds and boundValue', () => { + const now = new CalendarDate(2026, 10, 2) + + it('prefers minDate and maxDate over yearRange', () => { + const bounds = dayBounds({ minDate: '2026-01-01', maxDate: '2026-12-31T00:00:00Z', yearRange: [5] }, now) + expect(bounds.min?.toString()).toBe('2026-01-01') + expect(bounds.max?.toString()).toBe('2026-12-31') + expect(dayBounds({ minDate: 'garbage' }, now)).toEqual({}) + }) + + it('reads both yearRange forms', () => { + const around = dayBounds({ yearRange: [10] }, now) + expect([around.min?.toString(), around.max?.toString()]).toEqual(['2016-01-01', '2036-12-31']) + const span = dayBounds({ yearRange: [2000, 2040] }, now) + expect([span.min?.toString(), span.max?.toString()]).toEqual(['2000-01-01', '2040-12-31']) + expect(dayBounds({ yearRange: [] }, now)).toEqual({}) + }) + + it('turns a bound day into the start or end of that day per mode', () => { + const day = new CalendarDate(2026, 10, 2) + expect(boundValue(day, 'date', false, true)?.toString()).toBe('2026-10-02') + expect(boundValue(day, 'time', false, true)).toBeUndefined() + expect(boundValue(undefined, 'date', false, true)).toBeUndefined() + expect(boundValue(day, 'datetime', true, true)?.toString()).toBe('2026-10-02T23:59:59') + const zoned = boundValue(day, 'datetime', false, false) as ZonedDateTime + expect(zoned.timeZone).toBe('Europe/Warsaw') + expect([zoned.hour, zoned.minute]).toEqual([0, 0]) + }) + + it('starts a fresh picker at today in its value type', () => { + expect(startValue('date', false)).toBeInstanceOf(CalendarDate) + expect(startValue('datetime', true)).toBeInstanceOf(CalendarDateTime) + expect(startValue('datetime', false)).toBeInstanceOf(ZonedDateTime) + expect(startValue('time', false)).toBeInstanceOf(ZonedDateTime) + }) +}) diff --git a/admin/tests/app/sessionKey.test.ts b/admin/tests/app/sessionKey.test.ts new file mode 100644 index 0000000..260678f --- /dev/null +++ b/admin/tests/app/sessionKey.test.ts @@ -0,0 +1,46 @@ +// Form session keys (Phase 12.2, D-02): 32 random bytes from +// crypto.getRandomValues as unpadded base64url, 43 characters, never the +// same twice, and only ever sent in the two headers. +import { describe, expect, it, vi } from 'vitest' +import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../src/app/sessionKey' + +const SERVER_PATTERN = /^[A-Za-z0-9_-]{32,128}$/ + +describe('newSessionKey', () => { + it('is 43 base64url characters the server accepts', () => { + for (let i = 0; i < 50; i++) { + const key = newSessionKey() + expect(key).toHaveLength(43) + expect(key).toMatch(/^[A-Za-z0-9_-]+$/) + expect(key).toMatch(SERVER_PATTERN) + expect(key).not.toContain('=') + } + }) + + it('draws 32 bytes from crypto.getRandomValues and encodes them', () => { + const spy = vi.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation((array: T): T => { + const bytes = array as unknown as Uint8Array + bytes.fill(0xfb) + bytes[31] = 0xff + return array + }) + const key = newSessionKey() + expect(spy).toHaveBeenCalledTimes(1) + const arg = spy.mock.calls[0]![0] as unknown as Uint8Array + expect(arg).toBeInstanceOf(Uint8Array) + expect(arg.byteLength).toBe(32) + // 0xfb bytes give "+" and "/" in standard base64: base64url turns them + // into "-" and "_". + expect(key).toBe('-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_v7-_8') + }) + + it('differs on every call', () => { + const keys = new Set(Array.from({ length: 200 }, () => newSessionKey())) + expect(keys.size).toBe(200) + }) + + it('names the two headers', () => { + expect(SESSION_HEADER).toBe('X-Session-Key') + expect(CHILD_SESSION_HEADER).toBe('X-Child-Session-Key') + }) +}) diff --git a/admin/tests/fixtures/deferred.files.json b/admin/tests/fixtures/deferred.files.json new file mode 100644 index 0000000..27599a5 --- /dev/null +++ b/admin/tests/fixtures/deferred.files.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": 41, + "file_name": "front.png", + "file_size": 120400, + "content_type": "image/png", + "title": "Front", + "description": "", + "sort_order": 41, + "pending": false, + "created_at": "2026-10-02T10:00:00Z" + }, + { + "id": 42, + "file_name": "back.png", + "file_size": 98000, + "content_type": "image/png", + "title": "", + "description": "", + "sort_order": 42, + "pending": true, + "created_at": "2026-10-02T10:05:00Z" + }, + { + "id": 43, + "file_name": "manual.pdf", + "file_size": 2400000, + "content_type": "application/pdf", + "title": "", + "description": "", + "sort_order": 43, + "pending": false, + "created_at": "2026-10-02T10:10:00Z" + } + ], + "meta": {} +} diff --git a/admin/tests/fixtures/deferred.relation-schema.json b/admin/tests/fixtures/deferred.relation-schema.json new file mode 100644 index 0000000..89eadfa --- /dev/null +++ b/admin/tests/fixtures/deferred.relation-schema.json @@ -0,0 +1,287 @@ +{ + "parts": { + "data": { + "name": "parts", + "label": "Parts", + "kind": "hasMany", + "deferrable": true, + "view": { + "list": { + "columns": [ + { + "key": "label", + "label": "Label", + "searchable": true, + "sortable": true + } + ] + }, + "toolbarButtons": [ + "create", + "update", + "delete", + "link", + "unlink" + ], + "showSearch": false + }, + "manage": { + "list": { + "columns": [ + { + "key": "label", + "label": "Label", + "searchable": true, + "sortable": true + } + ] + }, + "toolbarButtons": [], + "showSearch": false + }, + "messages": { + "link": { + "other": "Add member" + }, + "linkHint": { + "other": "Pick people who are not members yet. The owner is not listed." + }, + "candidateSearch": { + "other": "Search by name or email…" + }, + "linked": { + "one": "Added :count member", + "other": "Added :count members" + }, + "unlinkSelected": { + "other": "Remove selected" + }, + "unlinkConfirm": { + "one": "Remove :count member?", + "other": "Remove :count members?" + }, + "unlinked": { + "one": "Removed :count member", + "other": "Removed :count members" + }, + "empty": { + "other": "This widget has no members." + }, + "create": { + "other": "New record" + }, + "createTitle": { + "other": "New record" + }, + "updateTitle": { + "other": "Edit record" + }, + "previewTitle": { + "other": "Record preview" + }, + "created": { + "other": "Record created" + }, + "updated": { + "other": "Record saved" + }, + "deleteSelected": { + "other": "Delete selected" + }, + "deleteConfirm": { + "other": "Delete the selected (:count)? This cannot be undone." + }, + "deleteOneConfirm": { + "other": "Delete this record? This cannot be undone." + }, + "deleted": { + "one": "Deleted :count record", + "other": "Deleted :count records" + }, + "pivotTitle": { + "other": "Link details" + }, + "pivotSaved": { + "other": "Link details saved" + }, + "editPivot": { + "other": "Edit link details: :name" + }, + "createSubmit": { + "other": "Create record" + }, + "updateSubmit": { + "other": "Save record" + }, + "pivotSubmit": { + "other": "Save link details" + }, + "linkSubmit": { + "other": "Add link" + } + }, + "manageForm": [ + { + "name": "label", + "label": "Label", + "type": "text", + "required": true + }, + { + "name": "notes", + "label": "Notes", + "type": "textarea", + "tab": "Details" + } + ], + "viewForm": [ + { + "name": "label", + "label": "Label", + "type": "text" + } + ] + }, + "meta": { + "locale": "en" + } + }, + "members": { + "data": { + "name": "members", + "label": "Members", + "kind": "belongsToMany", + "deferrable": true, + "view": { + "list": { + "columns": [ + { + "key": "email", + "label": "E-mail", + "searchable": true, + "sortable": true + } + ] + }, + "toolbarButtons": [ + "link", + "unlink" + ], + "showSearch": false + }, + "manage": { + "list": { + "columns": [ + { + "key": "email", + "label": "E-mail", + "searchable": true, + "sortable": true + } + ] + }, + "toolbarButtons": [], + "showSearch": false + }, + "messages": { + "link": { + "other": "Add member" + }, + "linkHint": { + "other": "Pick people who are not members yet. The owner is not listed." + }, + "candidateSearch": { + "other": "Search by name or email…" + }, + "linked": { + "one": "Added :count member", + "other": "Added :count members" + }, + "unlinkSelected": { + "other": "Remove selected" + }, + "unlinkConfirm": { + "one": "Remove :count member?", + "other": "Remove :count members?" + }, + "unlinked": { + "one": "Removed :count member", + "other": "Removed :count members" + }, + "empty": { + "other": "This widget has no members." + }, + "create": { + "other": "New record" + }, + "createTitle": { + "other": "New record" + }, + "updateTitle": { + "other": "Edit record" + }, + "previewTitle": { + "other": "Record preview" + }, + "created": { + "other": "Record created" + }, + "updated": { + "other": "Record saved" + }, + "deleteSelected": { + "other": "Delete selected" + }, + "deleteConfirm": { + "other": "Delete the selected (:count)? This cannot be undone." + }, + "deleteOneConfirm": { + "other": "Delete this record? This cannot be undone." + }, + "deleted": { + "one": "Deleted :count record", + "other": "Deleted :count records" + }, + "pivotTitle": { + "other": "Link details" + }, + "pivotSaved": { + "other": "Link details saved" + }, + "editPivot": { + "other": "Edit link details: :name" + }, + "createSubmit": { + "other": "Create record" + }, + "updateSubmit": { + "other": "Save record" + }, + "pivotSubmit": { + "other": "Save link details" + }, + "linkSubmit": { + "other": "Add link" + } + }, + "viewForm": [ + { + "name": "email", + "label": "E-mail", + "type": "text" + } + ], + "pivotForm": [ + { + "name": "note", + "label": "Note", + "type": "text", + "required": true + } + ] + }, + "meta": { + "locale": "en" + } + } +} diff --git a/admin/tests/fixtures/typed.ts b/admin/tests/fixtures/typed.ts index d73411d..32b3305 100644 --- a/admin/tests/fixtures/typed.ts +++ b/admin/tests/fixtures/typed.ts @@ -10,7 +10,9 @@ import settingsJson from './settings.json' import extensionFormSchemaJson from './extension.form-schema.json' import extensionListSchemaJson from './extension.list-schema.json' import extensionPartialJson from './extension.partial.json' +import deferredFilesJson from './deferred.files.json' import deferredFormSchemaJson from './deferred.form-schema.json' +import deferredRelationSchemaJson from './deferred.relation-schema.json' import formSchemaJson from './widgets.form-schema.json' import listJson from './widgets.list.json' import listSchemaJson from './widgets.list-schema.json' @@ -33,6 +35,13 @@ export const settingsFixture: { export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson /** A gadget form with deferred-binding fields: fileupload (Phase 12.2). */ export const deferredFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = deferredFormSchemaJson +/** Relation schemas with child, view and pivot forms: a hasMany and a belongsToMany (Phase 12.2). */ +export const deferredRelationSchemaFixture: { + parts: S['cabana.Envelope-cabana_RelationSchema'] + members: S['cabana.Envelope-cabana_RelationSchema'] +} = deferredRelationSchemaJson +/** A protected file list: a captioned image, a pending image and a PDF (Phase 12.2). */ +export const deferredFilesFixture: S['cabana.Envelope-array_cabana_FileItem'] = deferredFilesJson /** A form with a plugin widget and its controller script (Phase 10.1). */ export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson /** A list with a header partial and a controller stylesheet (Phase 10.1). */ diff --git a/admin/tests/form/FileuploadField.test.ts b/admin/tests/form/FileuploadField.test.ts new file mode 100644 index 0000000..480f760 --- /dev/null +++ b/admin/tests/form/FileuploadField.test.ts @@ -0,0 +1,437 @@ +// FileuploadField (Phase 12.2, D-03, D-08 to D-10; UI-SPEC section 3) +// mounted on its own with the form session FormView would provide. HTTP +// goes through the fetch mock and real file route adapters, uploads +// through a hand-answered fake XMLHttpRequest. Covers UI-SPEC backstops 3 +// (protected thumbnails: session header, object URL, revoked on unmount) +// and 4 (keyboard reorder: focus kept, announced, one debounced request). +// The language bundle has no fileupload strings, so texts are their keys. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { ref } from 'vue' +import FileuploadField from '../../src/components/form/fields/FileuploadField.vue' +import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext' +import { parentFileRoutes } from '../../src/api/files' +import type { FileItem, FormField } from '../../src/api/types' +import { SESSION_HEADER } from '../../src/app/sessionKey' +import { toasts } from '../../src/state/useToasts' +import { deferredFilesFixture } from '../fixtures/typed' +import { API, keydown, mockApi, requestsTo, resetState, wait, type Route } from '../helpers' + +const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' } +const KEY = 'k'.repeat(43) +const BASE = `${API}/acme/demo/gadgets` + +class FakeXHR { + static sent: FakeXHR[] = [] + method = '' + url = '' + headers: Record = {} + body: FormData | null = null + status = 0 + responseText = '' + upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null } + onload: (() => void) | null = null + onerror: (() => void) | null = null + onabort: (() => void) | null = null + ontimeout: (() => void) | null = null + open(method: string, url: string): void { + this.method = method + this.url = url + } + setRequestHeader(name: string, value: string): void { + this.headers[name] = value + } + send(body: FormData): void { + this.body = body + FakeXHR.sent.push(this) + } + abort(): void { + this.onabort?.() + } + progress(loaded: number, total: number): void { + this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent) + } + respond(status: number, body: unknown): void { + this.status = status + this.responseText = JSON.stringify(body) + this.onload?.() + } +} + +function photos(extra: Partial = {}): FormField { + return { + name: 'photos', + label: 'Photos', + type: 'fileupload', + mode: 'image', + multiple: true, + maxFiles: 3, + fileTypes: ['jpg', 'png'], + maxFilesize: 2, + useCaption: true, + ...extra, + } as FormField +} + +function item(id: number, name: string, extra: Partial = {}): FileItem { + return { + id, + file_name: name, + file_size: 1200, + content_type: 'image/png', + title: '', + description: '', + sort_order: id, + pending: false, + created_at: '2026-10-02T10:00:00Z', + url: `/storage/app/uploads/public/${name}`, + thumb_url: `/storage/app/uploads/public/thumb_${name}`, + ...extra, + } +} + +function listOf(files: FileItem[]): Route { + return { body: { data: files, meta: {} } } +} + +interface Setup { + field?: FormField + recordId?: number + routes?: Record +} + +async function setup(options: Setup = {}) { + const recordId = options.recordId ?? 7 + const field = options.field ?? photos() + const calls = mockApi(options.routes ?? { [`GET ${BASE}/${recordId}/files/${field.name}`]: listOf([]) }) + const markDirty = vi.fn() + const session: FormSession = { + key: KEY, + recordId, + routes: (name: string) => parentFileRoutes(SOURCE, recordId, name, KEY), + markDirty, + pendingChanges: ref(0), + revision: ref(0), + } + const wrapper = mount(FileuploadField, { + props: { field, modelValue: undefined, controlId: 'field-photos', describedBy: 'field-photos-comment' }, + global: { provide: { [FORM_SESSION as symbol]: session } }, + attachTo: document.body, + }) + await flushPromises() + return { wrapper, calls, markDirty } +} + +async function choose(wrapper: VueWrapper, files: File[]): Promise { + const input = wrapper.find('[data-fileupload-input]') + Object.defineProperty(input.element, 'files', { value: files, configurable: true }) + await input.trigger('change') + await flushPromises() +} + +function png(name: string, size = 1024): File { + return new File([new Uint8Array(size)], name, { type: 'image/png' }) +} + +function names(wrapper: VueWrapper): string[] { + return wrapper.findAll('[data-file-item]').map((li) => li.find('img').attributes('alt') ?? li.text()) +} + +// URL.createObjectURL and revokeObjectURL replaced for one test; the +// originals come back in afterEach. +const originalUrls = { create: URL.createObjectURL, revoke: URL.revokeObjectURL } +function objectUrls(create: (blob: Blob) => string, revoke: (url: string) => void): void { + URL.createObjectURL = create as typeof URL.createObjectURL + URL.revokeObjectURL = revoke +} + +beforeEach(() => { + resetState() + FakeXHR.sent = [] + vi.stubGlobal('XMLHttpRequest', FakeXHR) +}) + +afterEach(() => { + vi.unstubAllGlobals() + URL.createObjectURL = originalUrls.create + URL.revokeObjectURL = originalUrls.revoke + document.body.innerHTML = '' +}) + +describe('dropzone and client checks', () => { + it('shows the prompt and the limits line, and accepts the allowed types', async () => { + const { wrapper, calls } = await setup() + const [list] = requestsTo(calls, 'GET', `${BASE}/7/files/photos`) + expect(list!.headers.get(SESSION_HEADER)).toBe(KEY) + const zone = wrapper.find('[data-fileupload-dropzone]') + expect(zone.text()).toContain('backend::lang.fileupload.default_prompt_many') + expect(wrapper.find('[data-fileupload-limits]').text()).toBe( + 'backend::lang.fileupload.limits_types · backend::lang.fileupload.limits_size · backend::lang.fileupload.limits_count', + ) + expect(wrapper.find('[data-fileupload-input]').attributes('accept')).toBe('.jpg,.png') + expect(wrapper.find('[data-fileupload-input]').attributes('multiple')).toBeDefined() + const single = await setup({ field: photos({ name: 'cover', multiple: false, prompt: 'Drop a cover' }), routes: { [`GET ${BASE}/7/files/cover`]: listOf([]) } }) + expect(single.wrapper.find('[data-fileupload-dropzone]').text()).toContain('Drop a cover') + expect(single.wrapper.find('[data-fileupload-input]').attributes('multiple')).toBeUndefined() + }) + + it('refuses a wrong type and an oversized file and reports files past maxFiles', async () => { + const { wrapper } = await setup() + await choose(wrapper, [new File(['x'], 'a.gif', { type: 'image/gif' }), png('big.png', 3 << 20), png('a.png'), png('b.png'), png('c.png'), png('d.png'), png('e.png')]) + const failed = wrapper.findAll('[data-state="failed"]') + expect(failed.map((f) => f.find('[data-file-error]').text())).toEqual([ + 'backend::lang.fileupload.upload_error: backend::lang.fileupload.wrong_type', + 'backend::lang.fileupload.upload_error: backend::lang.fileupload.too_large', + ]) + const alert = wrapper.find('[data-fileupload-too-many]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text()).toContain('backend::lang.fileupload.too_many') + expect(FakeXHR.sent).toHaveLength(1) + expect(wrapper.findAll('[data-state="queued"]')).toHaveLength(2) + }) +}) + +describe('upload states', () => { + it('goes queued, uploading with progress, done; a server failure can be retried', async () => { + const { wrapper, markDirty } = await setup() + await choose(wrapper, [png('a.png'), png('b.png')]) + expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true) + expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued') + const first = FakeXHR.sent[0]! + expect(first.headers[SESSION_HEADER]).toBe(KEY) + first.progress(30, 60) + await flushPromises() + expect(wrapper.find('[data-state="uploading"] [role="progressbar"]').exists()).toBe(true) + first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } }) + await flushPromises() + expect(markDirty).not.toHaveBeenCalled() + const failed = wrapper.find('[data-state="failed"]') + expect(failed.find('[data-file-error]').text()).toContain('Server error') + // The next file is sent as soon as the first one failed. + expect(FakeXHR.sent).toHaveLength(2) + FakeXHR.sent[1]!.respond(201, { data: item(51, 'b.png', { pending: true }), meta: {} }) + await flushPromises() + expect(markDirty).toHaveBeenCalledTimes(1) + await failed.find('[data-action="retry"]').trigger('click') + await flushPromises() + expect(FakeXHR.sent).toHaveLength(3) + expect((FakeXHR.sent[2]!.body!.get('file_data') as File).name).toBe('a.png') + FakeXHR.sent[2]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} }) + await flushPromises() + expect(wrapper.findAll('[data-state="done"]')).toHaveLength(2) + expect(markDirty).toHaveBeenCalledTimes(2) + }) + + it('shows the Unsaved chip on pending files of a saved record only', async () => { + const pending = [item(41, 'a.png', { pending: true }), item(42, 'b.png')] + const update = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: listOf(pending) } }) + expect(update.wrapper.findAll('[data-unsaved-chip]')).toHaveLength(1) + const create = await setup({ recordId: 0, routes: { [`GET ${BASE}/0/files/photos`]: listOf(pending) } }) + expect(create.wrapper.findAll('[data-unsaved-chip]')).toHaveLength(0) + }) + + it('removes a saved file through the session and marks the form dirty', async () => { + const { wrapper, calls, markDirty } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png'), item(42, 'b.png')]), + [`DELETE ${BASE}/7/files/photos/41`]: { body: { data: { removed: true, pending: true }, meta: {} } }, + }, + }) + await wrapper.findAll('[data-action="remove-file"]')[0]!.trigger('click') + await flushPromises() + const [remove] = requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/41`) + expect(remove!.headers.get(SESSION_HEADER)).toBe(KEY) + expect(markDirty).toHaveBeenCalledTimes(1) + expect(wrapper.findAll('[data-file-item]')).toHaveLength(1) + }) + + it('puts a file back and toasts when the removal fails', async () => { + const { wrapper } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]), + [`DELETE ${BASE}/7/files/photos/41`]: { status: 404, body: { error: { code: 'not_found', message: 'Gone', details: {} } } }, + }, + }) + await wrapper.find('[data-action="remove-file"]').trigger('click') + await flushPromises() + expect(wrapper.findAll('[data-file-item]')).toHaveLength(1) + expect(toasts.value.at(-1)).toMatchObject({ text: 'Gone', tone: 'danger' }) + }) +}) + +describe('protected thumbnails (backstop 3)', () => { + it('fetches the thumb with the session header, shows it from an object URL and revokes it on unmount', async () => { + const create = vi.fn(() => 'blob:thumb-41') + const revoke = vi.fn() + objectUrls(create, revoke) + const { wrapper, calls } = await setup({ + field: photos({ protected: true }), + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png', { url: undefined, thumb_url: undefined })]), + [`GET ${BASE}/7/files/photos/41/thumb`]: () => ({ body: 'png-bytes' }), + }, + }) + await flushPromises() + const [thumb] = requestsTo(calls, 'GET', `${BASE}/7/files/photos/41/thumb`) + expect(thumb).toBeDefined() + expect(thumb!.headers.get(SESSION_HEADER)).toBe(KEY) + expect(new URL(thumb!.url).search).toBe('') + expect(create).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-file-item] img').attributes('src')).toBe('blob:thumb-41') + expect(wrapper.find('a[href^="/storage"]').exists()).toBe(false) + expect(revoke).not.toHaveBeenCalled() + wrapper.unmount() + expect(revoke).toHaveBeenCalledWith('blob:thumb-41') + }) + + it('shows no preview when the thumb cannot be fetched', async () => { + objectUrls(vi.fn(() => 'blob:x'), vi.fn()) + const { wrapper } = await setup({ + field: photos({ protected: true }), + routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png', { url: undefined, thumb_url: undefined })]) }, + }) + await flushPromises() + expect(wrapper.find('[data-preview-unavailable]').exists()).toBe(true) + }) +}) + +describe('keyboard reorder (backstop 4)', () => { + const three = [item(41, 'a.png'), item(42, 'b.png'), item(43, 'c.png')] + + it('moves the item, keeps focus on its handle, announces the move and sends one request after 400ms', async () => { + const { wrapper, calls } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf(three), + [`POST ${BASE}/7/files/photos/reorder`]: { body: { data: [three[1], three[2], three[0]], meta: {} } }, + }, + }) + const handle = wrapper.findAll('[data-handle]')[0]! + const uid = handle.attributes('data-handle') + ;(handle.element as HTMLElement).focus() + keydown(handle.element, 'ArrowDown') + await flushPromises() + let focused = document.activeElement as HTMLElement + expect(focused.dataset.handle).toBe(uid) + keydown(focused, 'ArrowDown') + await flushPromises() + focused = document.activeElement as HTMLElement + expect(focused.dataset.handle).toBe(uid) + expect(names(wrapper)).toEqual(['b.png', 'c.png', 'a.png']) + expect(wrapper.find('[data-fileupload-live]').text()).toBe('backend::lang.fileupload.moved') + // Up at the top and down at the bottom do nothing. + keydown(focused, 'ArrowDown') + await flushPromises() + expect(names(wrapper)).toEqual(['b.png', 'c.png', 'a.png']) + expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(0) + await wait(450) + await flushPromises() + const sent = requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`) + expect(sent).toHaveLength(1) + expect(sent[0]!.headers.get(SESSION_HEADER)).toBe(KEY) + expect(await sent[0]!.clone().json()).toEqual({ ids: [42, 43, 41] }) + }) + + it('restores the order and toasts when the reorder fails', async () => { + const { wrapper } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf(three), + [`POST ${BASE}/7/files/photos/reorder`]: { status: 422, body: { error: { code: 'validation_failed', message: 'x', details: {} } } }, + }, + }) + keydown(wrapper.findAll('[data-handle]')[2]!.element, 'ArrowUp') + await flushPromises() + expect(names(wrapper)).toEqual(['a.png', 'c.png', 'b.png']) + await wait(450) + await flushPromises() + expect(names(wrapper)).toEqual(['a.png', 'b.png', 'c.png']) + expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.fileupload.reorder_failed', tone: 'danger' }) + }) +}) + +describe('caption modal', () => { + it('saves the title and description at once and shows a 422 under its input', async () => { + let reply: Route = { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { title: ['The title may not be greater than 255 characters.'] } } } } + const { wrapper, calls, markDirty } = await setup({ + routes: { + [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]), + [`PUT ${BASE}/7/files/photos/41`]: (request) => (typeof reply === 'function' ? reply(request) : reply), + }, + }) + await wrapper.find('[data-caption]').trigger('click') + await flushPromises() + const modal = document.body.querySelector('[data-caption-modal]')! + expect(modal).not.toBeNull() + const title = modal.querySelector('[data-caption-title]')! + title.value = 'Front' + title.dispatchEvent(new Event('input')) + ;(modal.querySelector('[data-action="save-caption"]') as HTMLButtonElement).click() + await flushPromises() + expect(title.getAttribute('aria-invalid')).toBe('true') + expect(modal.textContent).toContain('The title may not be greater than 255 characters.') + + reply = { body: { data: item(41, 'a.png', { title: 'Front' }), meta: {} } } + ;(modal.querySelector('[data-action="save-caption"]') as HTMLButtonElement).click() + await flushPromises() + const puts = requestsTo(calls, 'PUT', `${BASE}/7/files/photos/41`) + expect(puts).toHaveLength(2) + expect(puts[1]!.headers.get(SESSION_HEADER)).toBe(KEY) + expect(await puts[1]!.clone().json()).toEqual({ title: 'Front', description: '' }) + expect(document.body.querySelector('[data-caption-modal]')).toBeNull() + expect(markDirty).not.toHaveBeenCalled() + }) +}) + +describe('read-only and load failure', () => { + it('renders files without controls when read-only, and an empty line without files', async () => { + const shown = await setup({ field: photos({ readOnly: true }), routes: { [`GET ${BASE}/7/files/photos`]: listOf([item(41, 'a.png')]) } }) + expect(shown.wrapper.find('[data-fileupload-dropzone]').exists()).toBe(false) + expect(shown.wrapper.find('[data-action="remove-file"]').exists()).toBe(false) + expect(shown.wrapper.find('[data-handle]').exists()).toBe(false) + expect(shown.wrapper.find('[data-caption]').exists()).toBe(false) + const empty = await setup({ field: photos({ readOnly: true }) }) + expect(empty.wrapper.find('[data-fileupload-empty]').text()).toBe('backend::lang.fileupload.empty') + }) + + it('shows the load_failed alert when the list cannot be read', async () => { + const { wrapper } = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } } } }) + const alert = wrapper.find('[data-fileupload-failed]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text()).toBe('backend::lang.fileupload.load_failed') + }) +}) + +describe('file rows of a protected field', () => { + it('lists captions and pending files, and downloads through an object URL revoked on unmount', async () => { + const create = vi.fn(() => 'blob:download-43') + const revoke = vi.fn() + objectUrls(create, revoke) + const clicked: string[] = [] + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + clicked.push(`${this.href}|${this.download}`) + }) + const field = photos({ name: 'docs', mode: 'file', protected: true, fileTypes: ['pdf', 'png'], maxFiles: 5 }) + const { wrapper, calls } = await setup({ + field, + routes: { + [`GET ${BASE}/7/files/docs`]: { body: deferredFilesFixture }, + [`GET ${BASE}/7/files/docs/41/thumb`]: { body: 'png' }, + [`GET ${BASE}/7/files/docs/42/thumb`]: { body: 'png' }, + [`GET ${BASE}/7/files/docs/43/download`]: { body: 'pdf' }, + }, + }) + expect(wrapper.find('[data-fileupload-rows]').exists()).toBe(true) + const rows = wrapper.findAll('[data-file-item]') + expect(rows).toHaveLength(3) + expect(rows[0]!.text()).toContain('Front') + expect(rows[1]!.find('[data-unsaved-chip]').exists()).toBe(true) + expect(wrapper.find('a[href^="/storage"]').exists()).toBe(false) + await rows[2]!.find('[data-action="download"]').trigger('click') + await flushPromises() + const [download] = requestsTo(calls, 'GET', `${BASE}/7/files/docs/43/download`) + expect(download!.headers.get(SESSION_HEADER)).toBe(KEY) + expect(clicked).toEqual(['blob:download-43|manual.pdf']) + click.mockRestore() + wrapper.unmount() + expect(revoke).toHaveBeenCalledWith('blob:download-43') + }) +}) diff --git a/admin/tests/list/CellValue.test.ts b/admin/tests/list/CellValue.test.ts index 0304d8a..1fa0b90 100644 --- a/admin/tests/list/CellValue.test.ts +++ b/admin/tests/list/CellValue.test.ts @@ -75,3 +75,28 @@ describe('cell value (D-12)', () => { expect(cell(toggle, null).find('[data-switch]').exists()).toBe(false) }) }) + +describe('date and time cells (Phase 12.2)', () => { + const date = { ...listColumn('created_at'), key: 'released_on', type: 'date' } as ListColumn + const time = { ...listColumn('created_at'), key: 'opens_at', type: 'time' } as ListColumn + + it('shows a stored date as is, never shifted by a time zone', () => { + expect(cell(date, '2026-10-02').text()).toBe('2026-10-02') + expect(cell(date, '2026-01-01T00:00:00Z').text()).toBe('2026-01-01') + expect(cell(date, '2026-10-02').find('span.tabular-nums').exists()).toBe(true) + }) + + it('shows a stored time without its seconds', () => { + expect(cell(time, '14:30:15').text()).toBe('14:30') + expect(cell(time, '08:05').text()).toBe('08:05') + expect(cell(time, '14:30:15').find('span.tabular-nums').exists()).toBe(true) + }) + + it('renders an empty date or time as a muted dash', () => { + for (const column of [date, time]) { + for (const value of [null, undefined, '']) { + expect(cell(column, value).find('[data-empty]').text()).toBe('—') + } + } + }) +}) diff --git a/admin/tests/relation/RelationChildModal.test.ts b/admin/tests/relation/RelationChildModal.test.ts new file mode 100644 index 0000000..53438ba --- /dev/null +++ b/admin/tests/relation/RelationChildModal.test.ts @@ -0,0 +1,209 @@ +// RelationChildModal (Phase 12.2, D-11, D-17; UI-SPEC section 4) mounted on +// its own: titles and submit labels per mode, the loading skeleton and the +// load failure alert, a 422 that switches to the invalid field's tab and +// focuses it, the 404 "gone" flow, delete with a confirmation, the modal's +// own session key next to the form's on every call, and the confirmation +// before a dirty close. Texts come from the relation schema fixture. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import RelationChildModal from '../../src/components/relation/RelationChildModal.vue' +import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey' +import { toasts } from '../../src/state/useToasts' +import { deferredRelationSchemaFixture } from '../fixtures/typed' +import { API, mockApi, requestsTo, resetState, wait, type Route } from '../helpers' + +const source = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' } +const RECORDS = `${API}/acme/demo/gadgets/0/relations/parts/records` +const FORM_KEY = 'f'.repeat(43) +const schema = deferredRelationSchemaFixture.parts.data +const record = { data: { id: 9, label: 'Bolt', notes: 'Steel' }, meta: { labels: {} } } + +function body(): HTMLElement { + return document.body.querySelector('[data-relation-child-modal]')! +} + +function setInput(selector: string, value: string): void { + const input = body().querySelector(selector)! + input.value = value + input.dispatchEvent(new Event('input')) +} + +function click(selector: string): void { + ;(document.body.querySelector(selector) as HTMLElement).click() +} + +async function open(mode: 'create' | 'update' | 'preview', routes: Record = {}, extra: Record = {}) { + const calls = mockApi(routes) + const wrapper = mount(RelationChildModal, { + props: { + open: true, + source, + ownerId: 0, + relation: 'parts', + schema, + mode, + childId: mode === 'create' ? null : 9, + sessionKey: FORM_KEY, + canDelete: true, + ...extra, + }, + attachTo: document.body, + }) + await flushPromises() + return { wrapper, calls } +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('titles and modes', () => { + it('names each mode with its title and submit label', async () => { + const create = await open('create') + expect(body().dataset.mode).toBe('create') + expect(body().querySelector('h2')!.textContent).toBe('New record') + expect(body().querySelector('[data-action="save-child"]')!.textContent).toContain('Create record') + expect(body().querySelector('[data-action="delete-child"]')).toBeNull() + create.wrapper.unmount() + document.body.innerHTML = '' + + const update = await open('update', { [`GET ${RECORDS}/9`]: { body: record } }) + expect(body().querySelector('h2')!.textContent).toBe('Edit record') + expect(body().querySelector('[data-action="save-child"]')!.textContent).toContain('Save record') + expect(body().querySelector('[data-action="delete-child"]')).not.toBeNull() + expect(body().querySelector('#child-parts-label')!.value).toBe('Bolt') + update.wrapper.unmount() + document.body.innerHTML = '' + + await open('preview', { [`GET ${RECORDS}/9`]: { body: record } }) + expect(body().querySelector('h2')!.textContent).toBe('Record preview') + expect(body().querySelector('[data-action="save-child"]')).toBeNull() + const input = body().querySelector('#child-parts-label')! + expect(input.readOnly || input.disabled || input.closest('fieldset')?.disabled).toBeTruthy() + }) + + it('shows a skeleton while the child loads and an alert when it cannot be read', async () => { + let release: (() => void) | null = null + const slow: Route = () => + new Promise((resolve) => { + release = () => resolve({ body: record }) + }) + const { wrapper } = await open('update', { [`GET ${RECORDS}/9`]: slow }) + expect(body().querySelector('[data-child-loading]')).not.toBeNull() + release!() + await flushPromises() + expect(body().querySelector('[data-child-loading]')).toBeNull() + wrapper.unmount() + document.body.innerHTML = '' + + await open('update', { [`GET ${RECORDS}/9`]: { status: 500, body: { error: { code: 'error', message: 'Boom', details: {} } } } }) + const alert = body().querySelector('[data-child-load-failed]')! + expect(alert).not.toBeNull() + expect(body().querySelector('[data-action="save-child"]')?.hasAttribute('disabled') ?? true).toBe(true) + }) +}) + +describe('saving', () => { + it('sends both keys, the modal key new on every open, and closes with a toast', async () => { + const { wrapper, calls } = await open('create', { [`POST ${RECORDS}`]: { status: 201, body: record } }) + setInput('#child-parts-label', 'Bolt') + await flushPromises() + click('[data-action="save-child"]') + await flushPromises() + const [post] = requestsTo(calls, 'POST', RECORDS) + expect(post!.headers.get(SESSION_HEADER)).toBe(FORM_KEY) + const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? '' + expect(childKey).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(childKey).not.toBe(FORM_KEY) + expect(await post!.clone().json()).toEqual({ label: 'Bolt' }) + expect(wrapper.emitted('update:open')).toEqual([[false]]) + expect(wrapper.emitted('changed')).toEqual([['created']]) + expect(toasts.value.at(-1)).toMatchObject({ text: 'Record created', tone: 'success' }) + + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + await flushPromises() + setInput('#child-parts-label', 'Nut') + click('[data-action="save-child"]') + await flushPromises() + const second = requestsTo(calls, 'POST', RECORDS)[1]! + expect(second.headers.get(CHILD_SESSION_HEADER)).not.toBe(childKey) + }) + + it('switches to the tab of the first invalid field and focuses it on a 422', async () => { + await open('create', { + [`POST ${RECORDS}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { notes: ['The notes field is required.'] } } } }, + }) + setInput('#child-parts-label', 'Bolt') + click('[data-action="save-child"]') + await flushPromises() + await wait(0) + const notes = body().querySelector('#child-parts-notes') + expect(notes).not.toBeNull() + expect(document.activeElement).toBe(notes) + expect(body().textContent).toContain('The notes field is required.') + expect(body().querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain('Details') + }) + + it('closes, toasts child_gone and emits gone on a 404', async () => { + const { wrapper } = await open('update', { + [`GET ${RECORDS}/9`]: { body: record }, + [`PUT ${RECORDS}/9`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } }, + }) + setInput('#child-parts-label', 'Gone') + click('[data-action="save-child"]') + await flushPromises() + expect(wrapper.emitted('update:open')).toEqual([[false]]) + expect(wrapper.emitted('gone')).toHaveLength(1) + expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' }) + }) + + it('deletes the child after a confirmation', async () => { + const { wrapper, calls } = await open('update', { + [`GET ${RECORDS}/9`]: { body: record }, + [`POST ${API}/acme/demo/gadgets/0/relations/parts/delete`]: { body: { data: { deleted: 1 }, meta: {} } }, + }) + click('[data-action="delete-child"]') + await flushPromises() + const confirm = document.body.querySelector('[data-confirm-dialog]')! + expect(confirm.textContent).toContain('Delete this record? This cannot be undone.') + click('[data-confirm-dialog] [data-action="confirm"]') + await flushPromises() + const [del] = requestsTo(calls, 'POST', `${API}/acme/demo/gadgets/0/relations/parts/delete`) + expect(await del!.clone().json()).toEqual({ ids: [9] }) + expect(del!.headers.get(SESSION_HEADER)).toBe(FORM_KEY) + expect(wrapper.emitted('changed')).toEqual([['deleted']]) + expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 1 record' }) + }) + + it('asks before closing with unsaved changes', async () => { + const { wrapper } = await open('create') + click('[data-action="close"]') + await flushPromises() + expect(document.body.querySelector('[data-confirm-dialog]')).toBeNull() + expect(wrapper.emitted('update:open')).toEqual([[false]]) + + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + await flushPromises() + setInput('#child-parts-label', 'Draft') + await flushPromises() + click('[data-relation-child-modal] [data-action="close"]') + await flushPromises() + expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull() + click('[data-confirm-dialog] [data-action="cancel"]') + await flushPromises() + expect(wrapper.emitted('update:open')).toHaveLength(1) + click('[data-relation-child-modal] [data-action="close"]') + await flushPromises() + click('[data-confirm-dialog] [data-action="confirm"]') + await flushPromises() + expect(wrapper.emitted('update:open')).toEqual([[false], [false]]) + }) +}) diff --git a/admin/tests/relation/RelationManager.test.ts b/admin/tests/relation/RelationManager.test.ts index b30d7e6..6207895 100644 --- a/admin/tests/relation/RelationManager.test.ts +++ b/admin/tests/relation/RelationManager.test.ts @@ -1,10 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { ref } from 'vue' import { createMemoryHistory } from 'vue-router' import RelationManager from '../../src/components/relation/RelationManager.vue' +import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext' import { createAdminRouter } from '../../src/app/router' +import { SESSION_HEADER } from '../../src/app/sessionKey' import { toasts } from '../../src/state/useToasts' -import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed' +import { clone, deferredRelationSchemaFixture, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed' import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers' const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' } @@ -21,12 +24,19 @@ function routes(overrides: Record = {}): Record { } } -async function mountManager(overrides: Record = {}, props: Record = {}) { +async function mountManager( + overrides: Record = {}, + props: Record = {}, + session: FormSession | null = null, +) { const calls = mockApi(routes(overrides)) const wrapper = mount(RelationManager, { props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props }, // DataTable injects the router for its row links. - global: { plugins: [createAdminRouter(createMemoryHistory())] }, + global: { + plugins: [createAdminRouter(createMemoryHistory())], + provide: session ? { [FORM_SESSION as symbol]: session } : {}, + }, attachTo: document.body, }) await flushPromises() @@ -224,3 +234,134 @@ describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => { expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true') }) }) + +describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-14)', () => { + const members = deferredRelationSchemaFixture.members + const RECORDS = `${LINKED}/records` + const PIVOT = `${LINKED}/pivot` + const DELETE = `${LINKED}/delete` + const first = linkedFixture.all.data[0]! + + function withButtons(buttons: string[], extra: Record = {}) { + const schema = clone(members) + schema.data.view.toolbarButtons = buttons + Object.assign(schema.data, extra) + return { body: schema } + } + + function session(recordId: number): FormSession & { markDirty: ReturnType } { + return { + key: 'f'.repeat(43), + recordId, + routes: () => { + throw new Error('no file routes in this test') + }, + markDirty: vi.fn(), + pendingChanges: ref(0), + revision: ref(0), + } + } + + it('opens the pivot modal on a row click when update is not declared', async () => { + const { calls } = await mountManager({ + [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), + [`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } }, + }) + ;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click() + await flushPromises() + expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull() + expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull() + expect(requestsTo(calls, 'GET', `${PIVOT}/${first.id}`)).toHaveLength(1) + }) + + it('opens the view form read-only without update and without a pivot form', async () => { + const { calls } = await mountManager({ + [`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [] }), + [`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } }, + }) + ;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click() + await flushPromises() + const modal = document.body.querySelector('[data-relation-child-modal]')! + expect(modal.dataset.mode).toBe('preview') + expect(requestsTo(calls, 'GET', `${RECORDS}/${first.id}`)).toHaveLength(1) + }) + + it('offers no row action without update, a pivot form or a view form', async () => { + const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [], viewForm: [] }) }) + expect(wrapper.find('[data-row-open]').exists()).toBe(false) + }) + + it('adds a trailing pivot button next to update on a belongsToMany with a pivot form', async () => { + const { wrapper } = await mountManager({ + [`GET ${SCHEMA}`]: withButtons(['update', 'link'], { manageForm: [{ name: 'email', label: 'E-mail', type: 'text' }] }), + [`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } }, + }) + const buttons = wrapper.findAll('[data-action="edit-pivot"]') + expect(buttons).toHaveLength(linkedFixture.all.data.length) + expect(buttons[0]!.attributes('aria-label')).toBe(`Edit link details: ${first.email}`) + await buttons[0]!.trigger('click') + await flushPromises() + expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull() + }) + + it('confirms a delete with the plural count and toasts the plural result', async () => { + const { wrapper } = await mountManager({ + [`GET ${SCHEMA}`]: withButtons(['delete']), + [`POST ${DELETE}`]: { body: { data: { deleted: 2 }, meta: {} } }, + }) + const boxes = wrapper.findAll('tbody [role="checkbox"]') + await boxes[0]!.trigger('click') + await boxes[1]!.trigger('click') + await wrapper.find('[data-action="delete"]').trigger('click') + await flushPromises() + expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete the selected (2)? This cannot be undone.') + ;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click() + await flushPromises() + expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 2 records', tone: 'success' }) + }) + + it('works on owner 0 with the form key on the create screen and marks the form dirty', async () => { + const field = { ...formField('members'), deferrable: true } + const create = session(0) + const ZERO = `${API}/acme/demo/widgets/0/relations/members` + const { wrapper, calls } = await mountManager( + { + [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), + [`GET ${ZERO}`]: { body: linkedFixture.all }, + [`POST ${ZERO}/unlink`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } }, + }, + { field, recordId: null }, + create, + ) + const manager = wrapper.find('[data-relation-manager]') + expect(manager.attributes('data-deferred')).toBe('true') + expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note') + const [list] = requestsTo(calls, 'GET', ZERO) + expect(list!.headers.get(SESSION_HEADER)).toBe(create.key) + await selectFirstAndUnlink(wrapper) + const [unlink] = requestsTo(calls, 'POST', `${ZERO}/unlink`) + expect(unlink!.headers.get(SESSION_HEADER)).toBe(create.key) + expect(create.markDirty).toHaveBeenCalledTimes(1) + }) + + it('stays hidden on the create screen when the field is not deferrable', async () => { + const field = { ...formField('members'), deferrable: false } + const { wrapper, calls } = await mountManager({}, { field, recordId: null }, session(0)) + expect(wrapper.find('[data-relation-manager]').exists()).toBe(false) + expect(calls).toHaveLength(0) + }) + + it('sends no session key and leaves the form clean on a saved record', async () => { + const update = session(1) + const { wrapper, calls } = await mountManager( + { [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), [`POST ${UNLINK}`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } } }, + { field: { ...formField('members'), deferrable: true } }, + update, + ) + expect(wrapper.find('[data-relation-pending]').exists()).toBe(false) + expect(requestsTo(calls, 'GET', LINKED)[0]!.headers.get(SESSION_HEADER)).toBeNull() + await selectFirstAndUnlink(wrapper) + expect(requestsTo(calls, 'POST', UNLINK)[0]!.headers.get(SESSION_HEADER)).toBeNull() + expect(update.markDirty).not.toHaveBeenCalled() + }) +}) diff --git a/admin/tests/relation/RelationPivotModal.test.ts b/admin/tests/relation/RelationPivotModal.test.ts new file mode 100644 index 0000000..e298f82 --- /dev/null +++ b/admin/tests/relation/RelationPivotModal.test.ts @@ -0,0 +1,119 @@ +// RelationPivotModal (Phase 12.2, D-14; UI-SPEC section 4, pivot modal): +// loads the link's pivot form values with the form's session key, saves +// only the pivot form fields, toasts pivot_saved, keeps a 422 in the +// dialog, closes and reloads on a 404, and asks before a dirty close. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import RelationPivotModal from '../../src/components/relation/RelationPivotModal.vue' +import { SESSION_HEADER } from '../../src/app/sessionKey' +import { toasts } from '../../src/state/useToasts' +import { deferredRelationSchemaFixture } from '../fixtures/typed' +import { API, mockApi, requestsTo, resetState, type Route } from '../helpers' + +const source = { vendor: 'acme', plugin: 'demo', controller: 'gadgets' } +const PIVOT = `${API}/acme/demo/gadgets/5/relations/members/pivot/12` +const FORM_KEY = 'p'.repeat(43) +const schema = deferredRelationSchemaFixture.members.data + +function dialog(): HTMLElement { + return document.body.querySelector('[data-relation-pivot-modal]')! +} + +function setNote(value: string): void { + const input = dialog().querySelector('#pivot-members-note')! + input.value = value + input.dispatchEvent(new Event('input')) +} + +function click(selector: string): void { + ;(document.body.querySelector(selector) as HTMLElement).click() +} + +async function open(routes: Record) { + const calls = mockApi(routes) + const wrapper = mount(RelationPivotModal, { + props: { open: true, source, ownerId: 5, relation: 'members', schema, childId: 12, childName: 'ada@example.test', sessionKey: FORM_KEY }, + attachTo: document.body, + }) + await flushPromises() + return { wrapper, calls } +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('relation pivot modal', () => { + it('loads the link details, saves the pivot fields only and toasts pivot_saved', async () => { + const { wrapper, calls } = await open({ + [`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } }, + [`PUT ${PIVOT}`]: { body: { data: { id: 12, note: 'Owner' }, meta: {} } }, + }) + expect(dialog().textContent).toContain('Link details') + expect(dialog().textContent).toContain('ada@example.test') + const [get] = requestsTo(calls, 'GET', PIVOT) + expect(get!.headers.get(SESSION_HEADER)).toBe(FORM_KEY) + expect(dialog().querySelector('#pivot-members-note')!.value).toBe('Lead') + setNote('Owner') + await flushPromises() + click('[data-action="save-pivot"]') + await flushPromises() + const [put] = requestsTo(calls, 'PUT', PIVOT) + expect(await put!.clone().json()).toEqual({ note: 'Owner' }) + expect(put!.headers.get(SESSION_HEADER)).toBe(FORM_KEY) + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.emitted('update:open')).toEqual([[false]]) + expect(toasts.value.at(-1)).toMatchObject({ text: 'Link details saved', tone: 'success' }) + }) + + it('keeps a 422 in the dialog under its field', async () => { + const { wrapper } = await open({ + [`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } }, + [`PUT ${PIVOT}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { note: ['The note field is required.'] } } } }, + }) + setNote('') + click('[data-action="save-pivot"]') + await flushPromises() + expect(wrapper.emitted('update:open')).toBeUndefined() + expect(dialog().textContent).toContain('The note field is required.') + expect(dialog().querySelector('#pivot-members-note')!.getAttribute('aria-invalid')).toBe('true') + }) + + it('closes, toasts child_gone and emits gone when the link is gone', async () => { + const { wrapper } = await open({ [`GET ${PIVOT}`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } } }) + expect(wrapper.emitted('gone')).toHaveLength(1) + expect(wrapper.emitted('update:open')).toEqual([[false]]) + expect(toasts.value.at(-1)).toMatchObject({ text: 'backend::lang.relation.child_gone', tone: 'danger' }) + }) + + it('toasts the generic error and keeps the input when the save fails otherwise', async () => { + const { wrapper } = await open({ + [`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } }, + [`PUT ${PIVOT}`]: { status: 500, body: { error: { code: 'error', message: 'Boom', details: {} } } }, + }) + setNote('Changed') + click('[data-action="save-pivot"]') + await flushPromises() + expect(wrapper.emitted('update:open')).toBeUndefined() + expect(dialog().querySelector('#pivot-members-note')!.value).toBe('Changed') + expect(toasts.value.at(-1)?.tone).toBe('danger') + }) + + it('asks before closing with unsaved changes', async () => { + const { wrapper } = await open({ [`GET ${PIVOT}`]: { body: { data: { id: 12, note: 'Lead' }, meta: {} } } }) + setNote('Changed') + await flushPromises() + click('[data-relation-pivot-modal] [data-action="cancel"]') + await flushPromises() + expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull() + click('[data-confirm-dialog] [data-action="confirm"]') + await flushPromises() + expect(wrapper.emitted('update:open')).toEqual([[false]]) + }) +}) diff --git a/scripts/check-phase12.2.sh b/scripts/check-phase12.2.sh new file mode 100755 index 0000000..ce6cd80 --- /dev/null +++ b/scripts/check-phase12.2.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# Phase 12.2 fail-closed gate (admin datepicker and fileupload fields, +# relation child CRUD, deferred binding). +# +# Every stage exits non-zero on a failing command, a go test run that fails, +# skips, matches zero tests or does not build, a named security test that is +# missing, renamed or skipped, OpenAPI or dist drift, a docs checker problem +# or a hygiene violation. --self-test proves the go test detector fails +# closed on planted inputs. --all runs every stage, prints one PASS or FAIL +# line per stage and stops at the first failure. +# +# Framework commands run in summercms.go; the application stage runs in the +# sibling repository named by PHASE122_APP (default ../fonoteka.go). +# Run with FORCE_COLOR unset: bonfire's colour tests read it. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="${PHASE122_APP:-$ROOT/../fonoteka.go}" + +# The named tests of the security stage, by prefix. Each prefix must match +# at least one top-level test that passes; any skip refuses. +SECURITY_CABANA=(TestRelationChildScope TestProtectedFile TestDeferredCommit TestFileupload TestRelationChild) +SECURITY_LAGOON=(TestPurgeDeferred TestDeferredStore TestDeferredConcurrentFirstBind) +SECURITY_ATTACH=(TestIsAllowedImage TestStore) + +# Files this phase added; the hygiene stage refuses a consuming-application +# name in them and in the framework docs. +PHASE_FILES=( + modules/cabana/testdata/deferred + modules/cabana/phase122_fixture_test.go + modules/cabana/relation_child_scope_test.go + modules/cabana/protected_file_test.go + modules/cabana/relation_child_test.go + modules/cabana/fileupload_test.go + modules/cabana/deferred_commit_test.go + modules/cabana/datepicker_test.go + modules/cabana/field_file.go + modules/cabana/field_date.go + modules/cabana/deferred.go + modules/cabana/relation_child.go + modules/cabana/relation_form.go + modules/lagoon/date.go + modules/lagoon/deferred.go + modules/lagoon/purge.go + modules/lagoon/schedule.go + modules/lagoon/purge_test.go + modules/lagoon/attach/store.go + modules/lagoon/attach/guard.go + modules/lagoon/attach/guard_test.go + admin/src/app/sessionKey.ts + admin/src/app/dateFormat.ts + admin/src/api/files.ts + admin/tests/app/sessionKey.test.ts + admin/tests/app/dateFormat.test.ts + admin/tests/form/DatepickerField.test.ts + admin/tests/form/FileuploadField.test.ts + admin/tests/relation/RelationChildModal.test.ts + admin/tests/relation/RelationPivotModal.test.ts + admin/tests/fixtures/deferred.files.json + admin/tests/fixtures/deferred.relation-schema.json +) +HYGIENE_DOCS=(modules/cabana/README.md modules/lagoon/README.md modules/conga/README.md modules/pact/README.md docs admin/src) +APP_NAMES='fonoteka|p[lł]ytarium' + +usage() { + cat >&2 <<'EOF' +usage: + check-phase12.2.sh --self-test + check-phase12.2.sh --go + check-phase12.2.sh --security + check-phase12.2.sh --spa + check-phase12.2.sh --openapi + check-phase12.2.sh --dist + check-phase12.2.sh --docs + check-phase12.2.sh --hygiene + check-phase12.2.sh --app + check-phase12.2.sh --all (default) +EOF + exit 2 +} + +# detect reads go test -json. Exit 1 fail or build failure, 2 skip, 3 zero +# tests, 4 non-JSON, 5 a required prefix has no passing top-level test. +# REQUIRE_PREFIXES lists the prefixes. +detect() { + python3 - "$1" <<'PY' +import json, os, sys +path = sys.argv[1] +prefixes = os.environ.get("REQUIRE_PREFIXES", "").split() +passed = set() +failed = [] +with open(path, encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line.startswith("{"): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + print("refuse: non-json test output", file=sys.stderr) + sys.exit(4) + action = ev.get("Action") + test = ev.get("Test") or "" + pkg = ev.get("Package") or ev.get("ImportPath") or "" + if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")): + print(f"refuse: build failed {pkg}", file=sys.stderr) + sys.exit(1) + if action == "output" and "no tests to run" in (ev.get("Output") or ""): + print(f"refuse: no tests to run in {pkg}", file=sys.stderr) + sys.exit(3) + if action == "skip" and test: + print(f"refuse: skipped {pkg} {test}", file=sys.stderr) + sys.exit(2) + if action == "fail": + failed.append(f"{pkg} {test}".strip()) + if action == "pass" and test: + passed.add(test) +if failed: + print("refuse: failed " + ", ".join(failed), file=sys.stderr) + sys.exit(1) +if not passed: + print("refuse: zero tests", file=sys.stderr) + sys.exit(3) +top = {name for name in passed if "/" not in name} +missing = [p for p in prefixes if not any(name.startswith(p) for name in top)] +if missing: + print("refuse: missing named test: no passing test for " + ", ".join(missing), file=sys.stderr) + sys.exit(5) +PY +} + +# go_json DIR [go test args...] runs go test -json -count=1 through detect. +go_json() { + local dir="$1" + shift + local log err rc=0 dc=0 + log="$(mktemp)" + err="$(mktemp)" + (cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err" || rc=$? + detect "$log" || dc=$? + if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then + cat "$err" >&2 || true + grep -v '^{' "$log" | tail -n 20 >&2 || true + rm -f "$log" "$err" + echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 + return 1 + fi + rm -f "$log" "$err" +} + +# named DIR PKG PREFIX... runs the tests matching the prefixes verbosely and +# requires a passing top-level test for each one. +named() { + local dir="$1" pkg="$2" + shift 2 + local regex + regex="^($(IFS='|'; echo "$*"))" + REQUIRE_PREFIXES="$*" go_json "$dir" "$pkg" -v -run "$regex" +} + +expect_detect() { + local name="$1" want="$2" payload="$3" log dc=0 + log="$(mktemp)" + printf '%s\n' "$payload" >"$log" + detect "$log" 2>/dev/null || dc=$? + rm -f "$log" + if [[ "$dc" -ne "$want" ]]; then + echo "refuse: self-test $name: detector exit $dc, want $want" >&2 + return 1 + fi +} + +run_self_test() { + bash -n "${BASH_SOURCE[0]}" + expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestRelationChildScope"}' + expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p","Test":"TestProtectedFileScope"}' + expect_detect package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} +{"Action":"fail","Package":"p"}' + expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}' + expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestRelationChildScope"}' + expect_detect zero 3 '{"Action":"pass","Package":"p"}' + expect_detect no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"} +{"Action":"pass","Package":"p"}' + expect_detect nonjson 4 '{"Action":"pass",' + REQUIRE_PREFIXES="TestRelationChildScope TestProtectedFile" expect_detect missing-named 5 \ + '{"Action":"pass","Package":"p","Test":"TestRelationChildScope"}' + REQUIRE_PREFIXES="TestProtectedFile" expect_detect subtest-only 5 \ + '{"Action":"pass","Package":"p","Test":"TestOther/TestProtectedFile"}' + REQUIRE_PREFIXES="TestRelationChildScope TestProtectedFile" expect_detect named 0 \ + '{"Action":"pass","Package":"p","Test":"TestRelationChildScopeToolbar"} +{"Action":"pass","Package":"p","Test":"TestProtectedFileHeaders"}' + local flag + for flag in --self-test --go --security --spa --openapi --dist --docs --hygiene --app --all; do + grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || { + echo "refuse: missing mode $flag" >&2 + return 1 + } + done + echo "phase12.2 self-test passed" +} + +run_go() { + (cd "$ROOT" && go vet ./...) + go_json "$ROOT" ./... + echo "phase12.2 go passed" +} + +run_security() { + named "$ROOT" ./modules/cabana "${SECURITY_CABANA[@]}" + named "$ROOT" ./modules/lagoon "${SECURITY_LAGOON[@]}" + named "$ROOT" ./modules/lagoon/attach "${SECURITY_ATTACH[@]}" + echo "phase12.2 security passed" +} + +run_spa() { + npm --prefix "$ROOT/admin" run typecheck + local log rc=0 + log="$(mktemp)" + npm --prefix "$ROOT/admin" test >"$log" 2>&1 || rc=$? + if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled (Errors|Rejection)|FAIL ' "$log"; then + tail -n 60 "$log" >&2 + rm -f "$log" + echo "refuse: admin Vitest run failed (exit $rc)" >&2 + return 1 + fi + grep -E 'Test Files|Tests ' "$log" || true + rm -f "$log" + echo "phase12.2 spa passed" +} + +run_openapi() { + "$ROOT/scripts/check-admin-openapi.sh" --check + named "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase09ContractInventory TestPhase09PermissionMatrix + echo "phase12.2 openapi passed" +} + +run_dist() { + "$ROOT/scripts/check-admin-dist.sh" + echo "phase12.2 dist passed" +} + +run_docs() { + go_json "$ROOT" ./cmd/summer -run '^TestDocsTree$' + local out + out="$(cd "$ROOT" && go run ./cmd/summer docs:build --check 2>&1)" || { + echo "$out" >&2 + echo "refuse: docs:build --check failed" >&2 + return 1 + } + go_json "$ROOT" ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' + echo "phase12.2 docs passed" +} + +run_hygiene() { + local bad=0 hits + hits="$(cd "$ROOT" && grep -rniIE "$APP_NAMES" "${HYGIENE_DOCS[@]}" "${PHASE_FILES[@]}" 2>/dev/null || true)" + if [[ -n "$hits" ]]; then + echo "refuse: hygiene: consuming-application names in the framework: $hits" >&2 + bad=1 + fi + # The acme.deferred fixture lives only in _test.go files and testdata. + hits="$(cd "$ROOT" && grep -rlnE 'acme\.deferred|dfPlugin' --include='*.go' . 2>/dev/null | grep -vE '_test\.go$' || true)" + if [[ -n "$hits" ]]; then + echo "refuse: hygiene: the test fixture plugin is referenced by production code: $hits" >&2 + bad=1 + fi + # Session keys travel only in headers, never in a URL. + hits="$(cd "$ROOT" && grep -rnE '[?&](session_key|sessionKey|child_session_key)=' admin/src modules/cabana --include='*.ts' --include='*.vue' --include='*.go' 2>/dev/null || true)" + if [[ -n "$hits" ]]; then + echo "refuse: hygiene: a session key in a URL: $hits" >&2 + bad=1 + fi + hits="$(cd "$ROOT" && grep -rnE 'v-html|innerHTML|outerHTML|insertAdjacentHTML' admin/src 2>/dev/null || true)" + if [[ -n "$hits" ]]; then + echo "refuse: hygiene: raw-HTML sink in admin/src: $hits" >&2 + bad=1 + fi + hits="$(cd "$ROOT" && gofmt -l modules/cabana modules/lagoon modules/conga modules/pact 2>/dev/null || true)" + if [[ -n "$hits" ]]; then + echo "refuse: hygiene: gofmt: $hits" >&2 + bad=1 + fi + [[ "$bad" -eq 0 ]] || return 1 + echo "phase12.2 hygiene passed" +} + +run_app() { + [[ -d "$APP" ]] || { + echo "refuse: application repository $APP not found (set PHASE122_APP)" >&2 + return 1 + } + go -C "$APP" build ./... + go -C "$APP" vet ./... + go_json "$APP" ./plugins/golem15/fonoteka -run Admin + REQUIRE_PREFIXES="TestSchemaMatchesPHPSnapshot TestMigrateSeedsCanonicalGenres" \ + go_json "$APP" ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestMigrateSeedsCanonicalGenres)$' + if [[ -n "$(git -C "$APP" status --porcelain)" ]]; then + git -C "$APP" status --short >&2 + echo "refuse: the application repository has uncommitted changes" >&2 + return 1 + fi + echo "phase12.2 app passed" +} + +run_all() { + local stage + for stage in self-test go security spa openapi dist docs hygiene app; do + # Each stage runs in its own process, so errexit stays in force + # inside it (bash ignores set -e in a function called from an if). + if bash "${BASH_SOURCE[0]}" "--$stage"; then + echo "PASS $stage" + else + echo "FAIL $stage" + exit 1 + fi + done + echo "phase12.2 all stages passed" +} + +case "${1:---all}" in + --self-test) run_self_test ;; + --go) run_go ;; + --security) run_security ;; + --spa) run_spa ;; + --openapi) run_openapi ;; + --dist) run_dist ;; + --docs) run_docs ;; + --hygiene) run_hygiene ;; + --app) run_app ;; + --all) run_all ;; + *) usage ;; +esac