test(10-05): bring every SPA module, composable and component under Vitest
- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
152
admin/tests/relation/RelationManager.test.ts
Normal file
152
admin/tests/relation/RelationManager.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, formField, linkedFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, queryOf, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const SCHEMA = `${API}/acme/demo/widgets/schema/relation/members`
|
||||
const LINKED = `${API}/acme/demo/widgets/1/relations/members`
|
||||
const UNLINK = `${LINKED}/unlink`
|
||||
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${SCHEMA}`]: { body: relationSchemaFixture },
|
||||
[`GET ${LINKED}`]: { body: linkedFixture.all },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountManager(overrides: Record<string, Route> = {}, props: Record<string, unknown> = {}) {
|
||||
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())] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
async function selectFirstAndUnlink(wrapper: VueWrapper) {
|
||||
await wrapper.find('tbody [role="checkbox"]').trigger('click')
|
||||
await wrapper.find('[data-action="unlink"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('relation manager (D-05, design screen 5)', () => {
|
||||
it('renders nothing and requests nothing without a saved record or a source', async () => {
|
||||
for (const props of [{ recordId: null }, { source: null }]) {
|
||||
const { wrapper, calls } = await mountManager({}, props)
|
||||
expect(wrapper.find('[data-relation-manager]').exists()).toBe(false)
|
||||
expect(calls).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('is a region labelled by its heading and described by its comment', async () => {
|
||||
const { wrapper } = await mountManager()
|
||||
const region = wrapper.find('[data-relation-manager]')
|
||||
expect(region.element.tagName).toBe('SECTION')
|
||||
expect(region.attributes('id')).toBe('field-members')
|
||||
expect(region.attributes('tabindex')).toBe('-1')
|
||||
expect(region.attributes('aria-labelledby')).toBe('field-members-heading')
|
||||
expect(wrapper.find('#field-members-heading').text()).toBe('Members')
|
||||
expect(region.attributes('aria-describedby')).toBe('field-members-comment')
|
||||
expect(wrapper.find('#field-members-comment').text()).toBe('People who may edit this widget')
|
||||
expect(wrapper.find('[data-action="link"]').attributes('aria-haspopup')).toBe('dialog')
|
||||
})
|
||||
|
||||
it('uses the relation key of the field, not its name, for the endpoints', async () => {
|
||||
const field = { ...formField('members'), name: 'team', relation: 'members' }
|
||||
const { wrapper, calls } = await mountManager({}, { field })
|
||||
expect(wrapper.find('[data-relation-manager]').attributes('data-relation-name')).toBe('members')
|
||||
expect(requestsTo(calls, 'GET', LINKED)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('announces a failed schema or list load as an alert', async () => {
|
||||
const schemaDown = await mountManager({ [`GET ${SCHEMA}`]: failure(500, 'Boom') })
|
||||
expect(schemaDown.wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać powiązanych rekordów.')
|
||||
expect(requestsTo(schemaDown.calls, 'GET', LINKED)).toHaveLength(0)
|
||||
schemaDown.wrapper.unmount()
|
||||
|
||||
const listDown = await mountManager({ [`GET ${LINKED}`]: failure(403, 'Forbidden') })
|
||||
expect(listDown.wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(listDown.wrapper.find('table').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('cycles sorting asc, desc, none through the linked query and resets the page', async () => {
|
||||
const { wrapper, calls } = await mountManager()
|
||||
const header = () => wrapper.find('[data-sort="email"]')
|
||||
await header().trigger('click')
|
||||
await flushPromises()
|
||||
await header().trigger('click')
|
||||
await flushPromises()
|
||||
await header().trigger('click')
|
||||
await flushPromises()
|
||||
const queries = requestsTo(calls, 'GET', LINKED).map((request) => queryOf(request))
|
||||
expect(queries.map((query) => [query.get('sort'), query.get('dir'), query.get('page')])).toEqual([
|
||||
[null, null, '1'],
|
||||
['email', 'asc', '1'],
|
||||
['email', 'desc', '1'],
|
||||
[null, null, '1'],
|
||||
])
|
||||
expect(wrapper.find('th[aria-sort]').attributes('aria-sort')).toBe('none')
|
||||
})
|
||||
|
||||
it('pages the linked rows only when there is more than one page', async () => {
|
||||
expect((await mountManager()).wrapper.find('[data-page="next"]').exists()).toBe(false)
|
||||
|
||||
const paged = clone(linkedFixture.all)
|
||||
paged.meta = { page: 1, per_page: 3, total: 5, last_page: 2 }
|
||||
const { wrapper, calls } = await mountManager({ [`GET ${LINKED}`]: { body: paged } })
|
||||
await wrapper.find('[data-page="next"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', LINKED).at(-1)!).get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('shows no unlink column or button when the view does not declare unlink', async () => {
|
||||
const schema = clone(relationSchemaFixture)
|
||||
schema.data.view.toolbarButtons = ['link']
|
||||
schema.data.view.showSearch = false
|
||||
const { wrapper } = await mountManager({ [`GET ${SCHEMA}`]: { body: schema } })
|
||||
expect(wrapper.find('[data-action="unlink"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-relation-search]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the server message when an unlink is refused and keeps the selection', async () => {
|
||||
const { wrapper } = await mountManager({ [`POST ${UNLINK}`]: failure(409, 'Owner cannot be removed') })
|
||||
await selectFirstAndUnlink(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Owner cannot be removed', tone: 'danger' })
|
||||
expect(wrapper.find('tbody tr').attributes('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('toasts the generic error when the unlink cannot reach the server', async () => {
|
||||
const { wrapper } = await mountManager({
|
||||
[`POST ${UNLINK}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
})
|
||||
await selectFirstAndUnlink(wrapper)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(wrapper.find('[data-action="unlink"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
160
admin/tests/relation/RelationPickerModal.test.ts
Normal file
160
admin/tests/relation/RelationPickerModal.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import RelationPickerModal from '../../src/components/relation/RelationPickerModal.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { candidatesFixture, relationSchemaFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const CANDIDATES = `${API}/acme/demo/widgets/1/relations/members/candidates`
|
||||
const LINK = `${API}/acme/demo/widgets/1/relations/members/link`
|
||||
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${CANDIDATES}`]: (request) => ({
|
||||
body: queryOf(request).get('page') === '2' ? candidatesFixture.page2 : candidatesFixture.page1,
|
||||
}),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openModal(overrides: Record<string, Route> = {}) {
|
||||
const calls = mockApi(routes(overrides))
|
||||
const wrapper = mount(RelationPickerModal, {
|
||||
props: { open: true, source, recordId: 1, relation: 'members', schema: relationSchemaFixture.data },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
return { wrapper, calls }
|
||||
}
|
||||
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-relation-picker]')
|
||||
const options = () => [...(dialog()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])]
|
||||
const confirmButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm-link"]')!
|
||||
|
||||
async function click(element: Element | null | undefined) {
|
||||
;(element as HTMLElement).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('relation picker modal', () => {
|
||||
it('is a titled, described modal dialog with a labelled multi-select listbox', async () => {
|
||||
await openModal()
|
||||
const element = dialog()!
|
||||
expect(element.getAttribute('role')).toBe('dialog')
|
||||
expect(element.getAttribute('aria-modal')).toBe('true')
|
||||
const title = document.getElementById(element.getAttribute('aria-labelledby')!)
|
||||
expect(title?.textContent).toBe('Add member')
|
||||
const description = document.getElementById(element.getAttribute('aria-describedby')!)
|
||||
expect(description?.textContent).toContain('The owner is not listed.')
|
||||
const listbox = element.querySelector('[role="listbox"]')!
|
||||
expect(listbox.getAttribute('aria-multiselectable')).toBe('true')
|
||||
expect(listbox.getAttribute('aria-label')).toBe('Add member')
|
||||
expect(element.querySelector('[data-candidate-search]')!.getAttribute('aria-controls')).toBe(listbox.id)
|
||||
expect(element.querySelector('[data-action="close"]')!.getAttribute('aria-label')).toBe('Zamknij')
|
||||
expect(options().every((option) => option.getAttribute('tabindex') === '0')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows initials and both columns for each candidate as served', async () => {
|
||||
await openModal()
|
||||
const first = options()[0]!
|
||||
expect(first.querySelector('[aria-hidden="true"]')!.textContent).toBe('AT')
|
||||
expect(first.textContent).toContain('alan.turing@example.test')
|
||||
})
|
||||
|
||||
it('disables prev on the first page and next on the last', async () => {
|
||||
await openModal()
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-page="prev"]')!.disabled).toBe(true)
|
||||
await click(dialog()!.querySelector('[data-page="next"]'))
|
||||
expect(dialog()!.querySelector<HTMLButtonElement>('[data-page="next"]')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles with Enter and moves between options with the arrow keys', async () => {
|
||||
await openModal()
|
||||
const [first, second] = options()
|
||||
first!.focus()
|
||||
expect(keydown(first!, 'Enter').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
|
||||
keydown(first!, 'ArrowDown')
|
||||
expect(document.activeElement).toBe(second)
|
||||
keydown(second!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(first)
|
||||
keydown(first!, 'ArrowUp')
|
||||
expect(document.activeElement).toBe(first)
|
||||
keydown(first!, 'Enter')
|
||||
await flushPromises()
|
||||
expect(options()[0]!.getAttribute('aria-selected')).toBe('false')
|
||||
expect(keydown(first!, 'x').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('says so when no candidate is left', async () => {
|
||||
await openModal({ [`GET ${CANDIDATES}`]: { body: candidatesFixture.empty } })
|
||||
expect(dialog()!.querySelector('[data-candidates-empty]')!.textContent?.trim()).toBe('Brak rekordów do dodania.')
|
||||
expect(dialog()!.querySelector('nav')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a failed candidates load as empty', async () => {
|
||||
await openModal({ [`GET ${CANDIDATES}`]: failure(500, 'Boom') })
|
||||
expect(options()).toHaveLength(0)
|
||||
expect(dialog()!.querySelector('[data-candidates-empty]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('toasts the server message when the link is refused and stays open', async () => {
|
||||
const { wrapper, calls } = await openModal({ [`POST ${LINK}`]: failure(422, 'Already a member') })
|
||||
await click(options()[0])
|
||||
await click(confirmButton())
|
||||
expect(requestsTo(calls, 'POST', LINK)).toHaveLength(1)
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Already a member', tone: 'danger' })
|
||||
expect(dialog()).not.toBeNull()
|
||||
expect(wrapper.emitted('linked')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('toasts the generic error when the link cannot reach the server', async () => {
|
||||
await openModal({
|
||||
[`POST ${LINK}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
})
|
||||
await click(options()[1])
|
||||
await click(confirmButton())
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(confirmButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('emits the linked count and closes after a successful link', async () => {
|
||||
const { wrapper } = await openModal({ [`POST ${LINK}`]: { body: { data: {}, meta: {} } } })
|
||||
await click(options()[0])
|
||||
await click(options()[2])
|
||||
await click(confirmButton())
|
||||
expect(wrapper.emitted('update:open')).toEqual([[false]])
|
||||
// Without a server count the selection size is reported.
|
||||
expect(wrapper.emitted('linked')).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('clears the search, page and selection when it closes', async () => {
|
||||
const { wrapper, calls } = await openModal()
|
||||
await click(options()[0])
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
expect(confirmButton().textContent?.trim()).toBe('Dodaj (0)')
|
||||
expect(queryOf(requestsTo(calls, 'GET', CANDIDATES).at(-1)!).get('page')).toBe('1')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user