Files
summercms/admin/tests/list/ListView.test.ts
Jakub Zych 1c2a66df45 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
2026-09-27 17:53:57 +02:00

133 lines
5.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { clone, listFixture, listSchemaFixture } from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
[`GET ${BASE}`]: { body: listFixture },
...overrides,
}
}
function schemaWith(change: (schema: typeof listSchemaFixture.data) => void) {
const envelope = clone(listSchemaFixture)
change(envelope.data)
return { body: envelope }
}
async function selectAllAndDelete(wrapper: Awaited<ReturnType<typeof mountApp>>['wrapper']) {
await wrapper.find('th [role="checkbox"]').trigger('click')
await wrapper.find('[data-action="delete"]').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('list view', () => {
it('is the component of the list route', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(wrapper.findComponent(ListView).exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.find('[data-record-count]').text()).toBe('2 widgets')
})
it('sends the URL query to the list endpoint', async () => {
const { calls } = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&page=2&filter[active]=true', routes())
const [request] = requestsTo(calls, 'GET', BASE)
const query = queryOf(request!)
expect(query.get('search')).toBe('blue')
expect(query.get('sort')).toBe('code')
expect(query.get('dir')).toBe('desc')
expect(query.get('page')).toBe('2')
expect(query.get('filter[active]')).toBe('true')
})
it('announces a failed list load as an alert and keeps the toolbar', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.find('[data-list-search]').exists()).toBe(true)
})
it('announces a failed schema load and falls back to the navigation label', async () => {
const { wrapper } = await mountApp('/acme/demo/gadgets', {
[`GET ${API}/acme/demo/gadgets/schema/list`]: serverError,
[`GET ${API}/acme/demo/gadgets`]: { body: listFixture },
})
expect(wrapper.find('h1').text()).toBe('Gadgets')
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('[data-list-search]').exists()).toBe(false)
})
it('shows the create button only when the toolbar declares it', async () => {
const withCreate = await mountApp('/acme/demo/widgets', routes())
expect(withCreate.wrapper.find('[data-action="create"]').attributes('href')).toBe('/acme/demo/widgets/create')
withCreate.wrapper.unmount()
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`GET ${BASE}/schema/list`]: schemaWith((schema) => (schema.toolbarButtons = ['delete'])) }),
)
expect(wrapper.find('[data-action="create"]').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
})
it('links no rows without a recordUrl and shows no checkboxes or sorting when turned off', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`GET ${BASE}/schema/list`]: schemaWith((schema) => {
delete schema.recordUrl
schema.showCheckboxes = false
schema.showSorting = false
}),
}),
)
expect(wrapper.find('tbody a').exists()).toBe(false)
expect(wrapper.find('[role="checkbox"]').exists()).toBe(false)
expect(wrapper.find('[data-sort]').exists()).toBe(false)
})
it('toasts the server message when a bulk delete is refused', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({ [`POST ${BASE}/bulk-delete`]: { status: 403, body: { error: { code: 'forbidden', message: 'Not allowed', details: {} } } } }),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
})
it('toasts the generic failure when the bulk delete cannot reach the server', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets',
routes({
[`POST ${BASE}/bulk-delete`]: () => {
throw new TypeError('offline')
},
}),
{ attach: true },
)
await selectAllAndDelete(wrapper)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się usunąć zaznaczonych rekordów.', tone: 'danger' })
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeUndefined()
})
})