Keep form save behind in-flight uploads, make retries idempotent via X-Upload-Id, cap remaining JSON bodies, and surface pending pivot type errors instead of zeroing them. Co-authored-by: Cursor <cursoragent@cursor.com>
421 lines
17 KiB
TypeScript
421 lines
17 KiB
TypeScript
// Deferred binding end to end in the SPA (Phase 12.2, D-02, D-03): a new
|
|
// record's form makes one session key, uploads go to record id 0 with that
|
|
// key in a header, the form turns dirty, and the create save sends the same
|
|
// key so the server can attach the files. The upload XMLHttpRequest is
|
|
// replaced by a fake the test answers by hand. The datepicker part runs in a
|
|
// fixed non-UTC zone: a stored UTC instant shows as local time and goes back
|
|
// unchanged, a date goes out as YYYY-MM-DD. A deferrable hasMany manager
|
|
// on the create screen works on owner id 0 with the same key, and its child
|
|
// modal adds its own key for the child's save.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, type DOMWrapper, type VueWrapper } from '@vue/test-utils'
|
|
import { resetLocalTimeZone, setLocalTimeZone } from '@internationalized/date'
|
|
import { setBundle } from '../../src/app/i18n'
|
|
import type { RelationSchema } from '../../src/api/types'
|
|
import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../../src/app/sessionKey'
|
|
import { clearUser } from '../../src/state/useAuth'
|
|
import { setNavigation } from '../../src/state/useNavigation'
|
|
import { clearToasts } from '../../src/state/useToasts'
|
|
import lang from '../fixtures/lang.json'
|
|
import { deferredFormSchemaFixture, relationSchemaFixture } from '../fixtures/typed'
|
|
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
|
|
|
const BASE = `${API}/acme/demo/gadgets`
|
|
const KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
|
|
|
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 fileItem(id: number, name: string, pending: boolean) {
|
|
return {
|
|
id,
|
|
file_name: name,
|
|
file_size: 1_200_000,
|
|
content_type: 'image/png',
|
|
title: '',
|
|
description: '',
|
|
sort_order: id,
|
|
pending,
|
|
created_at: '2026-10-02T10:00:00Z',
|
|
url: `/storage/app/uploads/public/${name}`,
|
|
thumb_url: `/storage/app/uploads/public/thumb_${name}`,
|
|
}
|
|
}
|
|
|
|
const created = { data: { id: 5, name: 'Box' }, meta: { labels: {} } }
|
|
|
|
// A hasMany of parts: create and delete in the toolbar, a one-field manage form.
|
|
const partsSchema: RelationSchema = {
|
|
...relationSchemaFixture.data,
|
|
name: 'parts',
|
|
label: 'Parts',
|
|
kind: 'hasMany',
|
|
deferrable: true,
|
|
view: { ...relationSchemaFixture.data.view, toolbarButtons: ['create', 'delete'], showSearch: false },
|
|
manageForm: [{ name: 'title', label: 'Title', type: 'text', required: true }],
|
|
}
|
|
const noParts = { data: [], meta: { page: 1, per_page: 20, total: 0, last_page: 1 } }
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/form`]: { body: deferredFormSchemaFixture },
|
|
[`GET ${BASE}/0/files/photos`]: { body: { data: [], meta: {} } },
|
|
[`POST ${BASE}`]: { status: 201, body: created },
|
|
[`GET ${BASE}/5`]: { body: created },
|
|
[`GET ${BASE}/5/files/photos`]: { body: { data: [fileItem(41, 'box.png', false)], meta: {} } },
|
|
[`GET ${BASE}/schema/relation/parts`]: { body: { data: partsSchema, meta: {} } },
|
|
[`GET ${BASE}/0/relations/parts`]: { body: noParts },
|
|
[`GET ${BASE}/5/relations/parts`]: { body: noParts },
|
|
[`GET ${BASE}/7/relations/parts`]: { body: noParts },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
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' })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearUser()
|
|
setNavigation([])
|
|
clearToasts()
|
|
setBundle(lang.data, 'pl')
|
|
FakeXHR.sent = []
|
|
vi.stubGlobal('XMLHttpRequest', FakeXHR)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('fileupload on a new record (tracer)', () => {
|
|
it('uploads to record 0 with the form key, turns the form dirty and saves with the same key', async () => {
|
|
const { wrapper, calls, router } = await mountApp('/acme/demo/gadgets/create', routes())
|
|
|
|
// The file list of the unsaved record is read with the form's key.
|
|
const [list] = requestsTo(calls, 'GET', `${BASE}/0/files/photos`)
|
|
expect(list).toBeDefined()
|
|
const key = list!.headers.get(SESSION_HEADER) ?? ''
|
|
expect(key).toMatch(KEY_PATTERN)
|
|
expect(new URL(list!.url).search).toBe('')
|
|
expect(wrapper.find('[data-fileupload-dropzone]').text()).toContain('backend::lang.fileupload.default_prompt_many')
|
|
|
|
await choose(wrapper, [png('box.png')])
|
|
|
|
expect(FakeXHR.sent).toHaveLength(1)
|
|
const upload = FakeXHR.sent[0]!
|
|
expect(upload.method).toBe('POST')
|
|
expect(new URL(upload.url, 'http://local').pathname).toBe(`${BASE}/0/files/photos`)
|
|
expect(upload.url).not.toContain('?')
|
|
expect(upload.headers[SESSION_HEADER]).toBe(key)
|
|
expect(upload.headers['X-Upload-Id']).toMatch(/^[A-Za-z0-9_-]{8,}$/)
|
|
expect(upload.headers['X-Requested-With']).toBe('XMLHttpRequest')
|
|
expect((upload.body!.get('file_data') as File).name).toBe('box.png')
|
|
expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true)
|
|
|
|
upload.progress(50, 100)
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-state="uploading"]').text()).toContain('backend::lang.fileupload.uploading')
|
|
|
|
upload.respond(201, { data: fileItem(41, 'box.png', true), meta: {} })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-state="done"]').exists()).toBe(true)
|
|
// On a create form every file is pending, so no Unsaved chip.
|
|
expect(wrapper.find('[data-unsaved-chip]').exists()).toBe(false)
|
|
|
|
// The upload alone makes the form dirty: leaving asks first.
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
const confirm = document.body.querySelector('[data-confirm-dialog]')
|
|
expect(confirm).not.toBeNull()
|
|
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.name).toBe('create')
|
|
|
|
await wrapper.find('#field-name').setValue('Box')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
const [save] = requestsTo(calls, 'POST', BASE)
|
|
expect(save).toBeDefined()
|
|
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
|
|
expect(await save!.clone().json()).toEqual({ name: 'Box' })
|
|
|
|
// The create follows its redirect to the saved record, with a fresh key.
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/gadgets/5')
|
|
const [reload] = requestsTo(calls, 'GET', `${BASE}/5/files/photos`)
|
|
expect(reload).toBeDefined()
|
|
const next = reload!.headers.get(SESSION_HEADER) ?? ''
|
|
expect(next).toMatch(KEY_PATTERN)
|
|
expect(next).not.toBe(key)
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-file-item]').text()).toContain('box.png')
|
|
})
|
|
|
|
it('does not save while an upload is still in flight', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/create', routes())
|
|
await choose(wrapper, [png('box.png')])
|
|
expect(FakeXHR.sent).toHaveLength(1)
|
|
await wrapper.find('#field-name').setValue('Box')
|
|
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(0)
|
|
FakeXHR.sent[0]!.respond(201, { data: fileItem(41, 'box.png', true), meta: {} })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
|
|
})
|
|
|
|
it('refuses a wrong type and an oversized file before sending and drops files past maxFiles', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/gadgets/create', routes())
|
|
|
|
await choose(wrapper, [
|
|
new File(['x'], 'notes.txt', { type: 'text/plain' }),
|
|
png('huge.png', 3 * (1 << 20)),
|
|
png('a.png'),
|
|
png('b.png'),
|
|
png('c.png'),
|
|
png('d.png'),
|
|
])
|
|
|
|
const failed = wrapper.findAll('[data-state="failed"]')
|
|
expect(failed).toHaveLength(2)
|
|
expect(failed[0]!.find('[data-file-error]').text()).toContain('backend::lang.fileupload.wrong_type')
|
|
expect(failed[1]!.find('[data-file-error]').text()).toContain('backend::lang.fileupload.too_large')
|
|
expect(wrapper.find('[data-fileupload-too-many]').attributes('role')).toBe('alert')
|
|
// One upload at a time: a.png is sent, b.png and c.png wait, d.png is dropped.
|
|
expect(FakeXHR.sent).toHaveLength(1)
|
|
expect((FakeXHR.sent[0]!.body!.get('file_data') as File).name).toBe('a.png')
|
|
expect(wrapper.findAll('[data-state="queued"]')).toHaveLength(2)
|
|
|
|
FakeXHR.sent[0]!.respond(422, {
|
|
error: { code: 'validation_failed', message: 'The given data was invalid.', details: { photos: ['The photos must be an image.'] } },
|
|
})
|
|
await flushPromises()
|
|
expect(wrapper.findAll('[data-state="failed"]')[2]!.find('[data-file-error]').text()).toContain(
|
|
'The photos must be an image.',
|
|
)
|
|
expect(FakeXHR.sent).toHaveLength(2)
|
|
expect((FakeXHR.sent[1]!.body!.get('file_data') as File).name).toBe('b.png')
|
|
})
|
|
})
|
|
|
|
const saved = {
|
|
data: { id: 7, name: 'Box', starts_at: '2026-10-02T10:30:00Z', released_on: null },
|
|
meta: { labels: {} },
|
|
}
|
|
|
|
function editRoutes(): Record<string, Route> {
|
|
return routes({
|
|
[`GET ${BASE}/7`]: { body: saved },
|
|
[`GET ${BASE}/7/files/photos`]: { body: { data: [], meta: {} } },
|
|
[`PUT ${BASE}/7`]: { body: saved },
|
|
})
|
|
}
|
|
|
|
function segment(field: DOMWrapper<Element>, part: string): DOMWrapper<Element> {
|
|
return field.find(`[data-reka-date-field-segment="${part}"]`)
|
|
}
|
|
|
|
async function typeInto(field: DOMWrapper<Element>, part: string, digits: string): Promise<void> {
|
|
const target = segment(field, part)
|
|
for (const key of digits) {
|
|
await target.trigger('keydown', { key })
|
|
}
|
|
}
|
|
|
|
describe('datepicker values in a fixed time zone', () => {
|
|
beforeEach(() => setLocalTimeZone('Europe/Warsaw'))
|
|
afterEach(() => resetLocalTimeZone())
|
|
|
|
it('shows a stored datetime in local time, saves it unchanged and sends a typed date as YYYY-MM-DD', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes())
|
|
|
|
const starts = wrapper.find('[data-field="starts_at"]')
|
|
expect(segment(starts, 'hour').text()).toBe('12')
|
|
expect(segment(starts, 'minute').text()).toBe('30')
|
|
expect(segment(starts, 'timeZoneName').exists()).toBe(true)
|
|
// Not required and set: the clear button is offered next to the trigger.
|
|
expect(starts.find('[data-action="clear"]').attributes('aria-label')).toBe('backend::lang.datepicker.clear')
|
|
expect(starts.find('[data-action="open-calendar"]').attributes('aria-label')).toBe(
|
|
'backend::lang.datepicker.open_calendar',
|
|
)
|
|
// The label points at the field's id; no native date input is rendered by the field itself.
|
|
expect(wrapper.find('label[for="field-starts_at"]').exists()).toBe(true)
|
|
|
|
const released = wrapper.find('[data-field="released_on"]')
|
|
expect(segment(released, 'day').attributes('data-placeholder')).toBeDefined()
|
|
// Polish segment order: day, month, year.
|
|
const parts = released
|
|
.findAll('[data-reka-date-field-segment]')
|
|
.map((el) => el.attributes('data-reka-date-field-segment'))
|
|
.filter((part) => part !== 'literal' && part !== 'trigger')
|
|
expect(parts).toEqual(['day', 'month', 'year'])
|
|
|
|
await typeInto(released, 'day', '02')
|
|
await typeInto(released, 'month', '10')
|
|
await typeInto(released, 'year', '2026')
|
|
await flushPromises()
|
|
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
const [put] = requestsTo(calls, 'PUT', `${BASE}/7`)
|
|
expect(put).toBeDefined()
|
|
const body = await put!.clone().json()
|
|
expect(body.starts_at).toBe('2026-10-02T10:30:00Z')
|
|
expect(body.released_on).toBe('2026-10-02')
|
|
})
|
|
|
|
it('converts an edited local time back to UTC', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes())
|
|
|
|
const starts = wrapper.find('[data-field="starts_at"]')
|
|
await segment(starts, 'minute').trigger('keydown', { key: 'ArrowUp' })
|
|
await flushPromises()
|
|
expect(segment(starts, 'minute').text()).toBe('31')
|
|
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
const [put] = requestsTo(calls, 'PUT', `${BASE}/7`)
|
|
expect((await put!.clone().json()).starts_at).toBe('2026-10-02T10:31:00Z')
|
|
})
|
|
|
|
it('clears an optional value to null', async () => {
|
|
const { wrapper, calls } = await mountApp('/acme/demo/gadgets/7', editRoutes())
|
|
|
|
await wrapper.find('[data-field="starts_at"] [data-action="clear"]').trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-field="starts_at"] [data-action="clear"]').exists()).toBe(false)
|
|
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
const [put] = requestsTo(calls, 'PUT', `${BASE}/7`)
|
|
expect((await put!.clone().json()).starts_at).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('relation manager on a new record', () => {
|
|
it('creates a child on owner 0 with the form key and its own key, turns the form dirty and saves with the key', async () => {
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/gadgets/create',
|
|
routes({
|
|
[`POST ${BASE}/0/relations/parts/records`]: {
|
|
status: 201,
|
|
body: { data: { id: 90, title: 'Bolt' }, meta: { labels: {} } },
|
|
},
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
|
|
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', `${BASE}/0/relations/parts`)
|
|
const key = list!.headers.get(SESSION_HEADER) ?? ''
|
|
expect(key).toMatch(KEY_PATTERN)
|
|
expect(new URL(list!.url).search).not.toContain(key)
|
|
// create is the toolbar's only primary; delete waits for a selection.
|
|
expect(manager.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
|
|
|
await manager.find('[data-action="create"]').trigger('click')
|
|
await flushPromises()
|
|
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')
|
|
expect(modal).not.toBeNull()
|
|
expect(modal!.dataset.mode).toBe('create')
|
|
const title = modal!.querySelector<HTMLInputElement>('#child-parts-title')!
|
|
title.value = 'Bolt'
|
|
title.dispatchEvent(new Event('input'))
|
|
await flushPromises()
|
|
;(modal!.querySelector('[data-action="save-child"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
|
|
const [post] = requestsTo(calls, 'POST', `${BASE}/0/relations/parts/records`)
|
|
expect(post).toBeDefined()
|
|
expect(post!.headers.get(SESSION_HEADER)).toBe(key)
|
|
const childKey = post!.headers.get(CHILD_SESSION_HEADER) ?? ''
|
|
expect(childKey).toMatch(KEY_PATTERN)
|
|
expect(childKey).not.toBe(key)
|
|
expect(await post!.clone().json()).toEqual({ title: 'Bolt' })
|
|
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
|
|
// The list reloads with the same key.
|
|
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(2)
|
|
|
|
// The deferred child alone makes the form dirty.
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
const confirm = document.body.querySelector('[data-confirm-dialog]')
|
|
expect(confirm).not.toBeNull()
|
|
;(confirm!.querySelector('[data-action="cancel"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
|
|
await wrapper.find('#field-name').setValue('Box')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
const [save] = requestsTo(calls, 'POST', BASE)
|
|
expect(save!.headers.get(SESSION_HEADER)).toBe(key)
|
|
})
|
|
|
|
it('hides a non-deferrable manager on the create screen', async () => {
|
|
const schema = structuredClone(deferredFormSchemaFixture)
|
|
const parts = schema.data.fields.find((field) => field.name === 'parts')!
|
|
parts.deferrable = false
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/gadgets/create',
|
|
routes({ [`GET ${BASE}/schema/form`]: { body: schema } }),
|
|
)
|
|
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
|
expect(requestsTo(calls, 'GET', `${BASE}/0/relations/parts`)).toHaveLength(0)
|
|
})
|
|
})
|