Files
summercms/admin/tests/form/FileCaptionModal.test.ts
Jakub Zych 1541cd4167 test(admin): import the untested SPA modules directly for check-phase10 hygiene
- mlLocale: type predicates, localeRecord, the ML_LOCALE_CHANGE broadcast
- MLField: locale switch, whole-map emit, broadcast follow, content-locale
- FileCaptionModal: prefill, save via FileRoutes.update, 422 stays, danger toast
- ApiTokensPanel and ApiTokenCreateDialog mounted directly next to the app tests
2026-10-11 16:13:19 +02:00

139 lines
4.9 KiB
TypeScript

// FileCaptionModal (Phase 12.2, D-09; UI-SPEC section 3, caption modal)
// mounted on its own over a stub FileRoutes, so no HTTP is involved. Save
// sends title and description through routes.update; a 422 stays in the
// dialog under its inputs; any other failure keeps the input and posts a
// danger toast. The language bundle has no fileupload strings, so the test
// asserts on data attributes and events rather than texts.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import type { FileCallResult, FileRoutes } from '../../src/api/files'
import type { FileItem } from '../../src/api/types'
import FileCaptionModal from '../../src/components/form/fields/FileCaptionModal.vue'
import { toasts } from '../../src/state/useToasts'
import { resetState } from '../helpers'
const FILE: FileItem = {
id: 41,
file_name: 'front.png',
file_size: 120400,
content_type: 'image/png',
title: 'Front',
description: 'The front side',
sort_order: 41,
pending: false,
created_at: '2026-10-02T10:00:00Z',
}
function routesStub(update: FileRoutes['update']): FileRoutes {
return {
list: vi.fn(),
upload: vi.fn(),
update: vi.fn(update),
remove: vi.fn(),
reorder: vi.fn(),
download: vi.fn(),
thumb: vi.fn(),
}
}
function ok(item: FileItem): FileCallResult<FileItem> {
return { data: item, status: 200, error: null }
}
async function mountModal(routes: FileRoutes) {
const wrapper = mount(FileCaptionModal, {
props: { open: true, file: FILE, routes, preview: null, idPrefix: 'gadget' },
attachTo: document.body,
})
await flushPromises()
return wrapper
}
function el<T extends Element>(selector: string): T {
const found = document.body.querySelector<T>(selector)
if (!found) {
throw new Error(`missing ${selector}`)
}
return found
}
function type(selector: string, value: string): void {
const input = el<HTMLInputElement | HTMLTextAreaElement>(selector)
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
async function save(): Promise<void> {
el<HTMLButtonElement>('[data-action="save-caption"]').click()
await flushPromises()
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('FileCaptionModal mounted directly', () => {
it('prefills title and description from the file', async () => {
await mountModal(routesStub(async () => ok(FILE)))
expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull()
expect(el<HTMLInputElement>('[data-caption-title]').value).toBe('Front')
expect(el<HTMLTextAreaElement>('[data-caption-description]').value).toBe('The front side')
expect(document.body.textContent).toContain(FILE.file_name)
})
it('saves the edited caption through routes.update and closes', async () => {
const saved: FileItem = { ...FILE, title: 'Front cover', description: 'Shown first' }
const routes = routesStub(async () => ok(saved))
const wrapper = await mountModal(routes)
type('[data-caption-title]', 'Front cover')
type('[data-caption-description]', 'Shown first')
await save()
expect(routes.update).toHaveBeenCalledTimes(1)
expect(routes.update).toHaveBeenCalledWith(FILE.id, { title: 'Front cover', description: 'Shown first' })
expect(wrapper.emitted('saved')).toEqual([[saved]])
expect(wrapper.emitted('update:open')).toEqual([[false]])
expect(toasts.value[0]?.tone).toBe('success')
})
it('keeps the dialog open on a 422 and shows the field message', async () => {
const routes = routesStub(async () => ({
data: null,
status: 422,
error: { code: 'validation_failed', message: 'Invalid', details: { title: ['Title is too long.'] } },
}))
const wrapper = await mountModal(routes)
type('[data-caption-title]', 'x'.repeat(300))
await save()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('update:open')).toBeUndefined()
expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull()
expect(el('#gadget-caption-title-error').textContent).toContain('Title is too long.')
expect(el<HTMLInputElement>('[data-caption-title]').getAttribute('aria-invalid')).toBe('true')
expect(toasts.value).toHaveLength(0)
})
it('posts a danger toast on any other failure and keeps the typed input', async () => {
const routes = routesStub(async () => ({
data: null,
status: 500,
error: { code: 'internal', message: 'Server exploded', details: {} },
}))
const wrapper = await mountModal(routes)
type('[data-caption-title]', 'Kept title')
await save()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('update:open')).toBeUndefined()
expect(toasts.value[0]).toMatchObject({ tone: 'danger', text: 'Server exploded' })
expect(el<HTMLInputElement>('[data-caption-title]').value).toBe('Kept title')
})
})