test(12.2-05): SPA unit tests for the phase and the check-phase12.2 gate

- sessionKey, dateFormat, FileuploadField (protected thumbnails and
  keyboard reorder backstops), RelationChildModal, RelationPivotModal,
  RelationManager row actions and create-screen deferral, date and time
  list cells; typed deferred relation-schema and file-list fixtures
- scripts/check-phase12.2.sh: go, security (named tests, refuses missing
  or skipped), spa, openapi, dist, docs, hygiene and app stages, a
  detector self-test, one PASS or FAIL line per stage under --all
This commit is contained in:
Jakub Zych
2026-10-02 20:51:36 +02:00
parent 5b06b20ac3
commit d5494faa81
11 changed files with 1856 additions and 4 deletions

View File

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

View File

@@ -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(<T extends ArrayBufferView | null>(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')
})
})

View File

@@ -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": {}
}

View File

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

View File

@@ -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). */

View File

@@ -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<string, string> = {}
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> = {}): 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> = {}): 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<string, Route>
}
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<void> {
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<HTMLElement>('[data-caption-modal]')!
expect(modal).not.toBeNull()
const title = modal.querySelector<HTMLInputElement>('[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')
})
})

View File

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

View File

@@ -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<HTMLElement>('[data-relation-child-modal]')!
}
function setInput(selector: string, value: string): void {
const input = body().querySelector<HTMLInputElement | HTMLTextAreaElement>(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<string, Route> = {}, extra: Record<string, unknown> = {}) {
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<HTMLInputElement>('#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<HTMLInputElement>('#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<HTMLTextAreaElement>('#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]])
})
})

View File

@@ -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<string, Route> = {}): Record<string, Route> {
}
}
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
async function mountManager(
overrides: Record<string, Route> = {},
props: Record<string, unknown> = {},
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<string, unknown> = {}) {
const schema = clone(members)
schema.data.view.toolbarButtons = buttons
Object.assign(schema.data, extra)
return { body: schema }
}
function session(recordId: number): FormSession & { markDirty: ReturnType<typeof vi.fn> } {
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<HTMLElement>('[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()
})
})

View File

@@ -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<HTMLElement>('[data-relation-pivot-modal]')!
}
function setNote(value: string): void {
const input = dialog().querySelector<HTMLInputElement>('#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<string, Route>) {
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<HTMLInputElement>('#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<HTMLInputElement>('#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]])
})
})

333
scripts/check-phase12.2.sh Executable file
View File

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