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
This commit is contained in:
2026-10-11 16:13:19 +02:00
parent 42b5cf887f
commit 1541cd4167
5 changed files with 425 additions and 4 deletions

View File

@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import ApiTokenCreateDialog from '../../src/components/account/ApiTokenCreateDialog.vue'
import { useConfirm } from '../../src/components/ui/confirm'
import { toasts } from '../../src/state/useToasts'
import { API, keydown, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
import { API, keydown, mockApi, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
const TOKENS = `${API}/account/tokens`
const SCOPES = `${API}/account/tokens/scopes`
@@ -452,3 +454,41 @@ describe('ApiTokenCreateDialog step 2', () => {
).toBe(true)
})
})
describe('ApiTokenCreateDialog mounted directly', () => {
// The panel mounts the dialog closed and opens it; opening loads the scopes.
async function openDialog() {
const calls = mockApi(tokenRoutes())
const wrapper = mount(ApiTokenCreateDialog, {
props: { open: false, confirm: useConfirm() },
attachTo: document.body,
})
await wrapper.setProps({ open: true })
await flushPromises()
return { wrapper, calls }
}
it('opens with the scopes from one GET', async () => {
const { calls } = await openDialog()
expect(dialog()).toBeTruthy()
expect(requestsTo(calls, 'GET', SCOPES)).toHaveLength(1)
expect(dialog()!.querySelector('[data-scope="*"]')).toBeTruthy()
expect(dialog()!.querySelector('[data-scope="admin:read"]')).toBeTruthy()
})
it('creates a token with one POST, shows the plaintext and emits created', async () => {
const { wrapper, calls } = await openDialog()
await fillName('CI runner')
await pickAdminRead()
await click('[data-action="create-token-submit"]', dialog()!)
const posts = requestsTo(calls, 'POST', TOKENS)
expect(posts).toHaveLength(1)
expect(await posts[0]!.clone().json()).toEqual({ name: 'CI runner', expires: '90d', scopes: ['admin:read'] })
expect(dialog()!.querySelector<HTMLInputElement>('[data-token-plaintext]')!.value).toBe(PLAIN)
expect(wrapper.emitted('created')).toHaveLength(1)
await click('[data-action="hide-token"]', dialog()!)
expect(wrapper.emitted('update:open')).toEqual([[false]])
})
})

View File

@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import ApiTokensPanel from '../../src/components/account/ApiTokensPanel.vue'
import { useConfirm } from '../../src/components/ui/confirm'
import { toasts } from '../../src/state/useToasts'
import { API, keydown, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
import { API, keydown, mockApi, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const TOKENS = `${API}/account/tokens`
const SCOPES = `${API}/account/tokens/scopes`
@@ -471,3 +473,38 @@ describe('ApiTokensPanel', () => {
})
})
})
describe('ApiTokensPanel mounted directly', () => {
async function mountPanel(list: Route) {
const calls = mockApi({ [`GET ${TOKENS}`]: list, [`GET ${SCOPES}`]: envelope(scopes) })
const wrapper = mount(ApiTokensPanel, { props: { confirm: useConfirm() }, attachTo: document.body })
await flushPromises()
return { wrapper, calls }
}
it('lists the tokens from one GET', async () => {
const { wrapper, calls } = await mountPanel(envelope([existingToken]))
const row = wrapper.find('[data-token-row="11"]')
expect(row.exists()).toBe(true)
expect(row.text()).toContain('Blog agent')
expect(wrapper.find('[data-tokens-empty]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1)
})
it('shows the empty block for an empty list', async () => {
const { wrapper, calls } = await mountPanel(envelope([]))
expect(wrapper.find('[data-tokens-empty]').exists()).toBe(true)
expect(wrapper.find('[data-token-row]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1)
})
it('shows the error block when the list fails', async () => {
const { wrapper, calls } = await mountPanel({
status: 500,
body: { error: { code: 'internal', message: 'fail', details: {} } },
})
expect(wrapper.find('[data-tokens-error]').exists()).toBe(true)
expect(wrapper.find('[data-token-row]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', TOKENS)).toHaveLength(1)
})
})

View File

@@ -0,0 +1,138 @@
// 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')
})
})

View File

@@ -0,0 +1,120 @@
// MLField, the locale-switching wrapper of the ML controls, mounted on its own
// with the enabled locales FormView would provide: it edits only the active
// locale, emits the whole map, follows ML_LOCALE_CHANGE from every other ML
// control and hands content-locale only to an inner control that asks for it.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h, markRaw, ref, type Component } from 'vue'
import type { FormField } from '../../src/api/types'
import MLField from '../../src/components/form/fields/MLField.vue'
import TextField from '../../src/components/form/fields/TextField.vue'
import { FORM_ENABLED_LOCALES } from '../../src/components/form/formContext'
import { ML_LOCALE_CHANGE } from '../../src/components/form/mlLocale'
import { resetState } from '../helpers'
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
function field(): FormField {
return { name: 'title', label: 'Title', type: 'mltext' } as FormField
}
function mountML(props: { inner?: Component; contentLocale?: boolean; modelValue?: unknown; controlId?: string } = {}) {
return mount(MLField, {
props: {
field: field(),
modelValue: props.modelValue ?? { en: 'Hello', pl: 'Cześć' },
controlId: props.controlId ?? 'f-title',
// markRaw: test-utils props are reactive, a component prop must not be.
inner: markRaw(props.inner ?? TextField),
innerType: 'text',
mlType: 'mltext',
contentLocale: props.contentLocale,
},
attachTo: document.body,
global: { provide: { [FORM_ENABLED_LOCALES as symbol]: enabledLocales } },
})
}
/** An inner control that declares content-locale and shows what it got. */
const LocaleProbe = defineComponent({
props: {
field: { type: Object, required: true },
modelValue: { type: null, default: '' },
controlId: { type: String, default: '' },
contentLocale: { type: String, default: undefined },
},
setup(props) {
return () => h('span', { 'data-probe': '', 'data-content-locale': props.contentLocale ?? 'none' }, String(props.modelValue))
},
})
beforeEach(() => {
resetState()
enabledLocales.value = ['en', 'pl']
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('MLField mounted directly', () => {
it('renders the ML type, the enabled locales and the first locale text', () => {
const wrapper = mountML()
expect(wrapper.attributes('data-ml-field')).toBe('mltext')
const options = wrapper.findAll('[data-ml-locale] option').map((option) => option.attributes('value'))
expect(options).toEqual(['en', 'pl'])
expect((wrapper.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('en')
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Hello')
})
it('emits the whole map with only the active locale changed', async () => {
const wrapper = mountML()
await wrapper.find('input').setValue('Hi there')
expect(wrapper.emitted('update:modelValue')).toEqual([[{ en: 'Hi there', pl: 'Cześć' }]])
})
it('switches locale from the select and broadcasts it to every other ML control', async () => {
const first = mountML({ controlId: 'f-one' })
const second = mountML({ controlId: 'f-two' })
await first.find('[data-ml-locale]').setValue('pl')
await flushPromises()
expect((first.find('input').element as HTMLInputElement).value).toBe('Cześć')
expect((second.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl')
expect((second.find('input').element as HTMLInputElement).value).toBe('Cześć')
})
it('edits the switched locale only', async () => {
const wrapper = mountML()
await wrapper.find('[data-ml-locale]').setValue('pl')
await wrapper.find('input').setValue('Witaj')
expect(wrapper.emitted('update:modelValue')).toEqual([[{ en: 'Hello', pl: 'Witaj' }]])
})
it('follows a window ML_LOCALE_CHANGE event and ignores an empty one', async () => {
const wrapper = mountML()
window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: 'pl' }))
await flushPromises()
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('Cześć')
window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: '' }))
await flushPromises()
expect((wrapper.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl')
})
it('hands the active locale to an inner control only with contentLocale', async () => {
const withLocale = mountML({ inner: LocaleProbe, contentLocale: true, controlId: 'f-with' })
expect(withLocale.find('[data-probe]').attributes('data-content-locale')).toBe('en')
await withLocale.find('[data-ml-locale]').setValue('pl')
expect(withLocale.find('[data-probe]').attributes('data-content-locale')).toBe('pl')
expect(withLocale.find('[data-probe]').text()).toBe('Cześć')
const without = mountML({ inner: LocaleProbe, controlId: 'f-without' })
expect(without.find('[data-probe]').attributes('data-content-locale')).toBe('none')
})
it('shows an empty input for a locale without text and for a non-map value', () => {
const wrapper = mountML({ modelValue: 'not a map' })
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
})
})

View File

@@ -0,0 +1,86 @@
// mlLocale (Phase 15): the ML field type predicates, the locale -> text map
// sent on save and the window event that keeps every ML control on one locale.
import { afterEach, describe, expect, it } from 'vitest'
import {
ML_LOCALE_CHANGE,
broadcastMLLocale,
isMLField,
isMLFieldType,
localeRecord,
} from '../../src/components/form/mlLocale'
describe('isMLFieldType', () => {
it('accepts the three ML types', () => {
expect(isMLFieldType('mltext')).toBe(true)
expect(isMLFieldType('mltextarea')).toBe(true)
expect(isMLFieldType('mlmarkdown')).toBe(true)
})
it('refuses plain types and a missing type', () => {
expect(isMLFieldType('text')).toBe(false)
expect(isMLFieldType('markdown')).toBe(false)
expect(isMLFieldType(undefined)).toBe(false)
})
})
describe('isMLField', () => {
it('accepts a built-in ML type', () => {
expect(isMLField({ type: 'mltext' })).toBe(true)
})
it('accepts a translatable plugin field with an element', () => {
expect(isMLField({ type: 'acme-color', element: 'acme-color', translatable: true })).toBe(true)
})
it('refuses a translatable field without an element', () => {
expect(isMLField({ type: 'acme-color', translatable: true })).toBe(false)
})
it('refuses a plugin field that is not translatable', () => {
expect(isMLField({ type: 'acme-color', element: 'acme-color' })).toBe(false)
expect(isMLField({ type: 'acme-color', element: 'acme-color', translatable: false })).toBe(false)
})
it('refuses a missing field', () => {
expect(isMLField(undefined)).toBe(false)
})
})
describe('localeRecord', () => {
it('keeps only string values', () => {
expect(localeRecord({ en: 'Hello', pl: 'Cześć', de: 3, fr: null, es: '' })).toEqual({
en: 'Hello',
pl: 'Cześć',
es: '',
})
})
it('returns an empty map for null, an array and a string', () => {
expect(localeRecord(null)).toEqual({})
expect(localeRecord(undefined)).toEqual({})
expect(localeRecord(['Hello'])).toEqual({})
expect(localeRecord('Hello')).toEqual({})
})
})
describe('broadcastMLLocale', () => {
const seen: string[] = []
const listener = (event: Event) => seen.push((event as CustomEvent<string>).detail)
afterEach(() => {
window.removeEventListener(ML_LOCALE_CHANGE, listener)
seen.length = 0
})
it('dispatches the locale as a window event', () => {
window.addEventListener(ML_LOCALE_CHANGE, listener)
broadcastMLLocale('pl')
expect(seen).toEqual(['pl'])
})
it('dispatches nothing for an empty locale', () => {
window.addEventListener(ML_LOCALE_CHANGE, listener)
broadcastMLLocale('')
expect(seen).toEqual([])
})
})