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
This commit is contained in:
Jakub Zych
2026-10-02 20:51:36 +02:00
parent 5b06b20ac3
commit d5494faa81
11 changed files with 1856 additions and 4 deletions

View File

@@ -1,10 +1,13 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import { createMemoryHistory } from 'vue-router'
import RelationManager from '../../src/components/relation/RelationManager.vue'
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
import { createAdminRouter } from '../../src/app/router'
import { SESSION_HEADER } from '../../src/app/sessionKey'
import { toasts } from '../../src/state/useToasts'
import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
import { clone, deferredRelationSchemaFixture, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
@@ -21,12 +24,19 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
}
}
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
async function mountManager(
overrides: Record<string, Route> = {},
props: Record<string, unknown> = {},
session: FormSession | null = null,
) {
const calls = mockApi(routes(overrides))
const wrapper = mount(RelationManager, {
props: { field: formField('members'), modelValue: undefined, controlId: 'field-members', source, recordId: 1, ...props },
// DataTable injects the router for its row links.
global: { plugins: [createAdminRouter(createMemoryHistory())] },
global: {
plugins: [createAdminRouter(createMemoryHistory())],
provide: session ? { [FORM_SESSION as symbol]: session } : {},
},
attachTo: document.body,
})
await flushPromises()
@@ -224,3 +234,134 @@ describe('relation manager child editing (Phase 12.2, D-11, D-12)', () => {
expect(wrapper.find('tbody [role="checkbox"]').attributes('aria-checked')).toBe('true')
})
})
describe('relation manager row actions and deferral (Phase 12.2, D-03, D-12, D-14)', () => {
const members = deferredRelationSchemaFixture.members
const RECORDS = `${LINKED}/records`
const PIVOT = `${LINKED}/pivot`
const DELETE = `${LINKED}/delete`
const first = linkedFixture.all.data[0]!
function withButtons(buttons: string[], extra: Record<string, unknown> = {}) {
const schema = clone(members)
schema.data.view.toolbarButtons = buttons
Object.assign(schema.data, extra)
return { body: schema }
}
function session(recordId: number): FormSession & { markDirty: ReturnType<typeof vi.fn> } {
return {
key: 'f'.repeat(43),
recordId,
routes: () => {
throw new Error('no file routes in this test')
},
markDirty: vi.fn(),
pendingChanges: ref(0),
revision: ref(0),
}
}
it('opens the pivot modal on a row click when update is not declared', async () => {
const { calls } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
})
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
await flushPromises()
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
expect(document.body.querySelector('[data-relation-child-modal]')).toBeNull()
expect(requestsTo(calls, 'GET', `${PIVOT}/${first.id}`)).toHaveLength(1)
})
it('opens the view form read-only without update and without a pivot form', async () => {
const { calls } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [] }),
[`GET ${RECORDS}/${first.id}`]: { body: { data: { ...first }, meta: { labels: {} } } },
})
;(document.body.querySelector('[data-row-open]') as HTMLButtonElement).click()
await flushPromises()
const modal = document.body.querySelector<HTMLElement>('[data-relation-child-modal]')!
expect(modal.dataset.mode).toBe('preview')
expect(requestsTo(calls, 'GET', `${RECORDS}/${first.id}`)).toHaveLength(1)
})
it('offers no row action without update, a pivot form or a view form', async () => {
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: withButtons(['link'], { pivotForm: [], viewForm: [] }) })
expect(wrapper.find('[data-row-open]').exists()).toBe(false)
})
it('adds a trailing pivot button next to update on a belongsToMany with a pivot form', async () => {
const { wrapper } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['update', 'link'], { manageForm: [{ name: 'email', label: 'E-mail', type: 'text' }] }),
[`GET ${PIVOT}/${first.id}`]: { body: { data: { id: first.id, note: 'Lead' }, meta: {} } },
})
const buttons = wrapper.findAll('[data-action="edit-pivot"]')
expect(buttons).toHaveLength(linkedFixture.all.data.length)
expect(buttons[0]!.attributes('aria-label')).toBe(`Edit link details: ${first.email}`)
await buttons[0]!.trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-relation-pivot-modal]')).not.toBeNull()
})
it('confirms a delete with the plural count and toasts the plural result', async () => {
const { wrapper } = await mountManager({
[`GET ${SCHEMA}`]: withButtons(['delete']),
[`POST ${DELETE}`]: { body: { data: { deleted: 2 }, meta: {} } },
})
const boxes = wrapper.findAll('tbody [role="checkbox"]')
await boxes[0]!.trigger('click')
await boxes[1]!.trigger('click')
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')!.textContent).toContain('Delete the selected (2)? This cannot be undone.')
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value.at(-1)).toMatchObject({ text: 'Deleted 2 records', tone: 'success' })
})
it('works on owner 0 with the form key on the create screen and marks the form dirty', async () => {
const field = { ...formField('members'), deferrable: true }
const create = session(0)
const ZERO = `${API}/acme/demo/widgets/0/relations/members`
const { wrapper, calls } = await mountManager(
{
[`GET ${SCHEMA}`]: withButtons(['link', 'unlink']),
[`GET ${ZERO}`]: { body: linkedFixture.all },
[`POST ${ZERO}/unlink`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } },
},
{ field, recordId: null },
create,
)
const manager = wrapper.find('[data-relation-manager]')
expect(manager.attributes('data-deferred')).toBe('true')
expect(manager.find('[data-relation-pending]').text()).toContain('backend::lang.relation.pending_note')
const [list] = requestsTo(calls, 'GET', ZERO)
expect(list!.headers.get(SESSION_HEADER)).toBe(create.key)
await selectFirstAndUnlink(wrapper)
const [unlink] = requestsTo(calls, 'POST', `${ZERO}/unlink`)
expect(unlink!.headers.get(SESSION_HEADER)).toBe(create.key)
expect(create.markDirty).toHaveBeenCalledTimes(1)
})
it('stays hidden on the create screen when the field is not deferrable', async () => {
const field = { ...formField('members'), deferrable: false }
const { wrapper, calls } = await mountManager({}, { field, recordId: null }, session(0))
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
expect(calls).toHaveLength(0)
})
it('sends no session key and leaves the form clean on a saved record', async () => {
const update = session(1)
const { wrapper, calls } = await mountManager(
{ [`GET ${SCHEMA}`]: withButtons(['link', 'unlink']), [`POST ${UNLINK}`]: { body: { data: { linked: 0, unlinked: 1 }, meta: {} } } },
{ field: { ...formField('members'), deferrable: true } },
update,
)
expect(wrapper.find('[data-relation-pending]').exists()).toBe(false)
expect(requestsTo(calls, 'GET', LINKED)[0]!.headers.get(SESSION_HEADER)).toBeNull()
await selectFirstAndUnlink(wrapper)
expect(requestsTo(calls, 'POST', UNLINK)[0]!.headers.get(SESSION_HEADER)).toBeNull()
expect(update.markDirty).not.toHaveBeenCalled()
})
})