// 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. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { 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 } 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 = {} 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: {} } } function routes(overrides: Record = {}): Record { 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: {} } }, ...overrides, } } async function choose(wrapper: VueWrapper, files: File[]): Promise { const input = wrapper.find('[data-fileupload-input]') Object.defineProperty(input.element, 'files', { value: files, configurable: true }) await input.trigger('change') await flushPromises() } function png(name: string, size = 1024): File { return new File([new Uint8Array(size)], name, { type: 'image/png' }) } 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-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('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') }) })