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

@@ -4,7 +4,7 @@ import { activateStyles, loadStyles } from '../../src/app/pluginAssets'
import { createAdminRouter, routeControllerId, safeRedirect } from '../../src/app/router'
import { setNavigation } from '../../src/state/useNavigation'
import { navigationFixture } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
import { mockApi, resetState, routerAt, signIn } from '../helpers'
beforeEach(() => {
resetState()
@@ -149,3 +149,37 @@ describe('plugin stylesheets follow the route (D-16, T-10.1-16)', () => {
expect(enabled()).toEqual([])
})
})
describe('preview route (UI-SPEC S3, D-11)', () => {
it('names the preview route for a numeric id only and keeps it inside the shell', async () => {
await signIn()
const preview = await routerAt('/acme/demo/widgets/12/preview')
expect(preview.currentRoute.value.name).toBe('preview')
expect(preview.currentRoute.value.params).toMatchObject({ vendor: 'acme', plugin: 'demo', controller: 'widgets', id: '12' })
expect(preview.currentRoute.value.meta.shell).toBe(true)
for (const path of ['/acme/demo/widgets/abc/preview', '/acme/demo/widgets/1e3/preview', '/acme/demo/widgets/12/preview/extra', '/acme/demo/widgets/-1/preview']) {
expect((await routerAt(path)).currentRoute.value.name, path).toBe('not-found')
}
})
it('counts preview among the controller routes, so plugin assets follow it', async () => {
await signIn()
// CONTROLLER_ROUTES holds list, create, record and preview: each of them
// answers its controller id, and no other route does.
for (const path of ['/acme/demo/widgets', '/acme/demo/widgets/create', '/acme/demo/widgets/12', '/acme/demo/widgets/12/preview']) {
expect(routeControllerId((await routerAt(path)).currentRoute.value), path).toBe('acme.demo.widgets')
}
for (const path of ['/settings', '/settings/mail', '/nowhere', '/acme/demo/widgets/abc/preview']) {
expect(routeControllerId((await routerAt(path)).currentRoute.value), path).toBeFalsy()
}
})
it('requires a signed-in admin for the preview route and returns to it after login', async () => {
mockApi({})
const router = createAdminRouter(createMemoryHistory())
await router.push('/acme/demo/widgets/12/preview')
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets/12/preview')
expect(safeRedirect(router.currentRoute.value.query.redirect)).toBe('/acme/demo/widgets/12/preview')
})
})

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { mapWinterUrl } from '../../src/app/winterUrl'
import { clone, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
import { API, mountApp, resetState } from '../helpers'
const ID = 'acme.demo.widgets'
const LIST = '/acme/demo/widgets'
@@ -59,3 +62,68 @@ describe('mapWinterUrl', () => {
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo.wid gets')).toBe('/')
})
})
describe('preview URLs (UI-SPEC S3, D-11, T-12.1-16)', () => {
const PEOPLE = `${API}/acme/roster/people`
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
it('backstop: mapWinterUrl maps preview/:id to the preview route, and opening the preview route of a form without a preview replaces it with the record route', async () => {
const id = 'acme.roster.people'
expect(mapWinterUrl('acme/roster/people/preview/:id', id, 12)).toBe('/acme/roster/people/12/preview')
expect(mapWinterUrl('acme/roster/people/preview/12', id)).toBe('/acme/roster/people/12/preview')
expect(mapWinterUrl('/Acme/Roster/People/preview/:id/', id, '12')).toBe('/acme/roster/people/12/preview')
const plain = clone(rosterFormSchemaFixture)
delete plain.data.preview
const { wrapper, router, calls } = await mountApp('/acme/roster/people/1/preview', {
[`GET ${PEOPLE}/schema/form`]: { body: plain },
[`GET ${PEOPLE}/1`]: { body: rosterRecordFixture },
})
await flushPromises()
expect(router.currentRoute.value.name).toBe('record')
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
// Replaced, not pushed: going back does not return to the preview URL.
expect(wrapper.find('[data-preview]').exists()).toBe(false)
expect(calls.some((request) => new URL(request.url).pathname.includes('/partials/'))).toBe(false)
wrapper.unmount()
// With a preview block the same URL stays on the preview route.
const kept = await mountApp('/acme/roster/people/1/preview', {
[`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture },
[`GET ${PEOPLE}/1`]: { body: rosterRecordFixture },
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
})
await flushPromises()
expect(kept.router.currentRoute.value.name).toBe('preview')
expect(kept.wrapper.find('[data-preview]').exists()).toBe(true)
kept.wrapper.unmount()
})
it('rejects a foreign controller, a foreign plugin and a foreign vendor', () => {
expect(mapWinterUrl('acme/demo/gadgets/preview/:id', ID, 1)).toBe(LIST)
expect(mapWinterUrl('acme/other/widgets/preview/:id', ID, 1)).toBe(LIST)
expect(mapWinterUrl('evil/demo/widgets/preview/:id', ID, 1)).toBe(LIST)
expect(mapWinterUrl('https://evil.example/acme/demo/widgets/preview/1', ID)).toBe(LIST)
expect(mapWinterUrl('//evil.example/acme/demo/widgets/preview/1', ID)).toBe(LIST)
})
it.each([['abc'], ['1e3'], ['-1'], ['1.5'], ['0x10'], [' 1'], ['1/..'], ['../1'], ['%31'], ['']])('rejects the non-numeric preview id %j', (bad) => {
expect(mapWinterUrl(`acme/demo/widgets/preview/${bad}`, ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, bad)).toBe(LIST)
})
it('accepts no extra segment, no other case of the action and no missing id', () => {
expect(mapWinterUrl('acme/demo/widgets/preview', ID, 1)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/preview/1/edit', ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/Preview/1', ID)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, undefined)).toBe(LIST)
expect(mapWinterUrl('acme/demo/widgets/preview/1?next=//evil.example#x', ID)).toBe(`${LIST}/1/preview`)
})
})