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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user