Files
summercms/admin/tests/form/FileuploadField.test.ts
Jakub Zych d5494faa81 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
2026-10-02 20:51:36 +02:00

438 lines
19 KiB
TypeScript

// 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')
})
})