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 = {}): Record { return { [`GET ${SCHEMA}`]: { body: relationSchemaFixture }, [`GET ${LINKED}`]: { body: linkedFixture.all }, ...overrides, } } async function mountManager(overrides: Record = {}, props: Record = {}) { 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() }) })