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:
208
admin/tests/app/dateFormat.test.ts
Normal file
208
admin/tests/app/dateFormat.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
46
admin/tests/app/sessionKey.test.ts
Normal file
46
admin/tests/app/sessionKey.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
38
admin/tests/fixtures/deferred.files.json
vendored
Normal file
38
admin/tests/fixtures/deferred.files.json
vendored
Normal 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": {}
|
||||
}
|
||||
287
admin/tests/fixtures/deferred.relation-schema.json
vendored
Normal file
287
admin/tests/fixtures/deferred.relation-schema.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
admin/tests/fixtures/typed.ts
vendored
9
admin/tests/fixtures/typed.ts
vendored
@@ -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). */
|
||||
|
||||
437
admin/tests/form/FileuploadField.test.ts
Normal file
437
admin/tests/form/FileuploadField.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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('—')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
209
admin/tests/relation/RelationChildModal.test.ts
Normal file
209
admin/tests/relation/RelationChildModal.test.ts
Normal 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]])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
119
admin/tests/relation/RelationPivotModal.test.ts
Normal file
119
admin/tests/relation/RelationPivotModal.test.ts
Normal 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
333
scripts/check-phase12.2.sh
Executable 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
|
||||
Reference in New Issue
Block a user