// 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, createUploadGate, type FormSession } from '../../src/components/form/formContext' import { UPLOAD_ID_HEADER } from '../../src/api/files' 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 Reply, 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 = {} 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 { 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 { 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[]): Reply { return { body: { data: files, meta: {} } } } interface Setup { field?: FormField recordId?: number routes?: Record } 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 { activeUploads, beginUpload } = createUploadGate() const session: FormSession = { key: KEY, recordId, routes: (name: string) => parentFileRoutes(SOURCE, recordId, name, KEY), markDirty, pendingChanges: ref(0), revision: ref(0), beginUpload, activeUploads, } 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, activeUploads } } 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' }) } 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, activeUploads } = await setup() await choose(wrapper, [png('a.png'), png('b.png')]) expect(wrapper.find('[data-state="uploading"]').exists()).toBe(true) expect(activeUploads.value).toBe(1) expect(wrapper.find('[data-state="queued"]').text()).toContain('backend::lang.fileupload.queued') const first = FakeXHR.sent[0]! expect(first.headers[SESSION_HEADER]).toBe(KEY) expect(first.headers[UPLOAD_ID_HEADER]).toMatch(/^[A-Za-z0-9_-]{8,}$/) 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) expect(activeUploads.value).toBe(0) }) it('keeps the same upload id on retry and holds the form upload gate until the XHR ends', async () => { const { wrapper, activeUploads } = await setup() await choose(wrapper, [png('a.png')]) expect(activeUploads.value).toBe(1) const first = FakeXHR.sent[0]! const id = first.headers[UPLOAD_ID_HEADER] expect(id).toBeTruthy() first.respond(500, { error: { code: 'error', message: 'Server error', details: {} } }) await flushPromises() expect(activeUploads.value).toBe(0) await wrapper.find('[data-action="retry"]').trigger('click') await flushPromises() expect(FakeXHR.sent[1]!.headers[UPLOAD_ID_HEADER]).toBe(id) FakeXHR.sent[1]!.respond(201, { data: item(52, 'a.png', { pending: true }), meta: {} }) await flushPromises() expect(activeUploads.value).toBe(0) }) it('deletes a server-side file when the upload is aborted after the store', async () => { let listed = 0 const { wrapper, calls } = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: () => { listed++ return listed === 1 ? listOf([]) : listOf([item(99, 'lost.png', { pending: true })]) }, [`DELETE ${BASE}/7/files/photos/99`]: { body: { data: { removed: true, pending: true }, meta: {} } }, }, }) await choose(wrapper, [png('lost.png')]) FakeXHR.sent[0]!.abort() await flushPromises() expect(requestsTo(calls, 'DELETE', `${BASE}/7/files/photos/99`)).toHaveLength(1) expect(wrapper.find('[data-file-item]').exists()).toBe(false) }) it('adopts a stored file when the response is lost and keeps the same upload id on retry', async () => { let listed = 0 const { wrapper } = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: () => { listed++ return listed === 1 ? listOf([]) : listOf([item(77, 'net.png', { pending: true })]) }, }, }) await choose(wrapper, [png('net.png')]) FakeXHR.sent[0]!.onerror?.() await flushPromises() expect(wrapper.find('[data-state="done"]').exists()).toBe(true) expect(wrapper.find('[data-file-item]').text()).toContain('net.png') }) 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' }) }) it('sends only the latest order when a second move happens while the first request is open', async () => { const replies: Array<(ids: number[]) => Reply> = [] const seen: number[][] = [] const { wrapper, calls } = await setup({ routes: { [`GET ${BASE}/7/files/photos`]: listOf(three), [`POST ${BASE}/7/files/photos/reorder`]: async (request) => { const body = (await request.clone().json()) as { ids: number[] } seen.push(body.ids) return await new Promise((resolve) => { replies.push((ids) => { const ordered = ids.map((id) => three.find((file) => file.id === id)!) const reply = { body: { data: ordered, meta: {} } } resolve(reply) return reply }) }) }, }, }) keydown(wrapper.findAll('[data-handle]')[0]!.element, 'ArrowDown') await flushPromises() await wait(450) await flushPromises() expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(1) keydown(wrapper.findAll('[data-handle]')[1]!.element, 'ArrowDown') await flushPromises() await wait(450) await flushPromises() expect(replies).toHaveLength(1) replies[0]!([42, 41, 43]) await flushPromises() expect(requestsTo(calls, 'POST', `${BASE}/7/files/photos/reorder`)).toHaveLength(2) expect(seen[1]).toEqual([42, 43, 41]) }) }) 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('[data-caption-modal]')! expect(modal).not.toBeNull() const title = modal.querySelector('[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') }) })