test(12.1-05): unit tests for the list, form, preview and routing behaviours of Phase 12.1 in the SPA

- list: the bulk menu in the toolbar and on the list view with its three failure rows, row states and invisible columns in the table
- form: the forbidden banner, password and preset on the form view, locked relation options, the save body of password and permission fields
- preview view: context fields, hidden tabs, the status hint, the footer with zero, one and several actions, load failures
- routing: the preview route and mapWinterUrl
- backstops: focus returns to the bulk menu trigger; preview URL mapping and the route replacement; locked relation options
- the slug preset runs on the table the user plugin's slug test uses
This commit is contained in:
Jakub Zych
2026-10-05 15:36:00 +02:00
parent 84efdc09e0
commit aced6c7df3
10 changed files with 1251 additions and 8 deletions

View File

@@ -1,10 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { WIDGET_EVENT } from '../../src/components/form/formContext'
import { clone, extensionFormSchemaFixture, formSchemaFixture, recordFixture } from '../fixtures/typed'
import {
clone,
extensionFormSchemaFixture,
formSchemaFixture,
langFixture,
recordFixture,
rosterFormSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
@@ -288,3 +297,260 @@ describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
})
})
describe('forbidden save on the form (UI-SPEC S6, D-27)', () => {
const FALLBACK = 'You do not have permission to make this change. Nothing was saved.'
const strings = {
'backend::lang.form.forbidden': { other: FALLBACK },
'backend::lang.form.error_title': { other: 'The record could not be saved.' },
'backend::lang.form.error_fields': { one: 'Correct :count field.', other: 'Correct :count fields.' },
}
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
status: 403,
body: { error: { code: 'forbidden', message, details } },
})
const banner = (wrapper: Mounted) => wrapper.find('[data-forbidden-banner]')
const name = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-name')
const code = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-code')
async function save(wrapper: Mounted): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
beforeEach(() => {
setBundle({ ...langFixture.data, ...strings }, 'en')
})
it('shows the banner with the server message, or the framework text without one', async () => {
const withMessage = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Only makers may rename widgets.') }))
await name(withMessage.wrapper).setValue('Renamed')
await save(withMessage.wrapper)
expect(banner(withMessage.wrapper).attributes('role')).toBe('alert')
expect(banner(withMessage.wrapper).text()).toBe('Only makers may rename widgets.')
expect(toasts.value).toHaveLength(0)
withMessage.wrapper.unmount()
const bare = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('') }))
await name(bare.wrapper).setValue('Renamed')
await save(bare.wrapper)
expect(banner(bare.wrapper).text()).toBe(FALLBACK)
expect(bare.wrapper.find('[aria-invalid="true"]').exists()).toBe(false)
})
it('puts details on their fields, focuses the first one and shows no 422 banner', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.', { code: ['This code is reserved.'], name: ['This name is reserved.'] }) }),
{ attach: true },
)
await code(wrapper).setValue('W-99')
await save(wrapper)
expect(banner(wrapper).text()).toBe('Refused.')
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
expect(name(wrapper).attributes('aria-invalid')).toBe('true')
expect(code(wrapper).attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('This name is reserved.')
expect(wrapper.text()).toContain('This code is reserved.')
// name comes first in the form, so it takes the focus.
expect(document.activeElement).toBe(name(wrapper).element)
})
it('keeps the typed values and the dirty state: nothing was saved', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.') }), { attach: true })
await name(wrapper).setValue('Typed by hand')
await save(wrapper)
expect(name(wrapper).element.value).toBe('Typed by hand')
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(1)
// Leaving still asks, because the form is dirty.
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
})
it('clears the banner when the next save starts, and shows nothing after a save that succeeds', async () => {
let attempt = 0
const saved = clone(recordFixture)
saved.data.name = 'Second try'
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`GET ${BASE}/schema/form`]: flatSchema(),
[`PUT ${RECORD}`]: () => {
attempt += 1
return attempt === 1 ? refused('Refused.', { name: ['Reserved.'] }) : { body: saved }
},
}),
)
await name(wrapper).setValue('First try')
await save(wrapper)
expect(banner(wrapper).exists()).toBe(true)
// Editing does not dismiss it: only the next save attempt does.
await name(wrapper).setValue('Second try')
expect(banner(wrapper).exists()).toBe(true)
await save(wrapper)
expect(attempt).toBe(2)
expect(banner(wrapper).exists()).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(name(wrapper).attributes('aria-invalid')).toBeUndefined()
})
it('replaces the banner with the 422 banner when the next save fails validation', async () => {
let attempt = 0
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`GET ${BASE}/schema/form`]: flatSchema(),
[`PUT ${RECORD}`]: () => {
attempt += 1
return attempt === 1
? refused('Refused.')
: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Too short.'] } } } }
},
}),
)
await name(wrapper).setValue('x')
await save(wrapper)
expect(banner(wrapper).exists()).toBe(true)
await save(wrapper)
expect(banner(wrapper).exists()).toBe(false)
expect(wrapper.find('[data-error-banner]').text()).toContain('Correct 1 field.')
})
it('refuses a create the same way and stays on the create route', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/create', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`POST ${BASE}`]: refused('You may not add widgets.') }))
await name(wrapper).setValue('Brand new')
await save(wrapper)
expect(banner(wrapper).text()).toBe('You may not add widgets.')
expect(router.currentRoute.value.name).toBe('create')
expect(name(wrapper).element.value).toBe('Brand new')
})
it('shows a refused delete as a danger toast, not as the banner, and stays on the record', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`DELETE ${RECORD}`]: refused('This widget is in use.') }), { attach: true })
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This widget is in use.', 'danger']])
expect(banner(wrapper).exists()).toBe(false)
expect(router.currentRoute.value.name).toBe('record')
})
})
describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
const PEOPLE = `${API}/acme/roster/people`
const PERSON = `${PEOPLE}/1`
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
const field = (wrapper: Mounted, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}`)
function people(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture },
[`GET ${PERSON}`]: { body: rosterRecordFixture },
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
...overrides,
}
}
async function save(wrapper: Mounted): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
beforeEach(() => {
setBundle(
{ ...langFixture.data, 'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.hide_password': { other: 'Hide password' } },
'en',
)
})
it('loads both password fields empty and masked, and puts no password into the page', async () => {
// The guarantee is the server's: a record response never carries a
// password (the framework test T-12.1-10). The form adds none of its own.
const { wrapper } = await mountApp('/acme/roster/people/1', people())
for (const name of ['password', 'password_confirmation']) {
expect(field(wrapper, name).attributes('type')).toBe('password')
expect(field(wrapper, name).element.value).toBe('')
expect(field(wrapper, name).attributes('autocomplete')).toBe('new-password')
}
expect('password' in rosterRecordFixture.data).toBe(false)
})
it('leaves the password out of an update that does not type one', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
await field(wrapper, 'name').setValue('Ada King')
await save(wrapper)
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
expect(body.name).toBe('Ada King')
expect('password' in body).toBe(false)
expect('password_confirmation' in body).toBe(false)
})
it('sends the typed pair and clears and hides both fields after the save', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
await field(wrapper, 'password').setValue('new-secret-1')
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
await wrapper.find('[data-field="password"] [data-password-toggle]').trigger('click')
expect(field(wrapper, 'password').attributes('type')).toBe('text')
await save(wrapper)
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
expect(body.password).toBe('new-secret-1')
expect(body.password_confirmation).toBe('new-secret-1')
for (const name of ['password', 'password_confirmation']) {
expect(field(wrapper, name).element.value).toBe('')
expect(field(wrapper, name).attributes('type')).toBe('password')
}
// A second save sends no password again.
await field(wrapper, 'name').setValue('Ada King')
await save(wrapper)
const second = (await requestsTo(calls, 'PUT', PERSON)[1]!.clone().json()) as Record<string, unknown>
expect('password' in second).toBe(false)
})
it('keeps the typed password when the save is refused', async () => {
const { wrapper } = await mountApp(
'/acme/roster/people/1',
people({ [`PUT ${PERSON}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Refused.', details: { password: ['Not allowed.'] } } } } }),
)
await field(wrapper, 'password').setValue('new-secret-1')
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
await save(wrapper)
expect(wrapper.find('[data-forbidden-banner]').text()).toBe('Refused.')
expect(field(wrapper, 'password').element.value).toBe('new-secret-1')
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
})
it('on create, sends a password that was typed and cleared as empty, and leaves an untouched one to the server rules', async () => {
const invalid = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { password: ['The password is required.'] } } } }
const { wrapper, calls } = await mountApp('/acme/roster/people/create', people({ [`POST ${PEOPLE}`]: invalid }))
await field(wrapper, 'name').setValue('Grace Hopper')
await save(wrapper)
const untouched = (await requestsTo(calls, 'POST', PEOPLE)[0]!.clone().json()) as Record<string, unknown>
expect(untouched.password ?? '').toBe('')
expect(wrapper.text()).toContain('The password is required.')
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
await field(wrapper, 'password').setValue('typed')
await field(wrapper, 'password').setValue('')
await save(wrapper)
const cleared = (await requestsTo(calls, 'POST', PEOPLE)[1]!.clone().json()) as Record<string, unknown>
expect(cleared.password).toBe('')
})
it('fills a preset target on create only, and stops at the first manual edit', async () => {
const create = await mountApp('/acme/roster/people/create', people())
await field(create.wrapper, 'name').setValue('Zażółć gęślą')
expect(field(create.wrapper, 'slug').element.value).toBe('za-g-l')
await field(create.wrapper, 'slug').setValue('by-hand')
await field(create.wrapper, 'name').setValue('Something Else')
expect(field(create.wrapper, 'slug').element.value).toBe('by-hand')
// Clearing the target by hand is an edit too: it stays empty.
await field(create.wrapper, 'slug').setValue('')
await field(create.wrapper, 'name').setValue('Third Name')
expect(field(create.wrapper, 'slug').element.value).toBe('')
create.wrapper.unmount()
const update = await mountApp('/acme/roster/people/1', people())
await field(update.wrapper, 'name').setValue('Ada King')
expect(field(update.wrapper, 'slug').element.value).toBe('ada-lovelace')
})
})