import { afterEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import type { Author } from '@/composables/useAuthors' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { authorsFixture, button, callsTo, click, createBackend, dialog, failWith, mountDirectory, networkFailure, openRowMenu, pending, rowTexts, rows, textOf, typeInto, unmountDirectory, } from './directoryTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const SKELETON_DELAY_MS = 150 const SEARCH_DEBOUNCE_MS = 120 const OWNER_DESCRIPTION = "The names readers see on your articles. An author doesn't need an account." const READ_ONLY_DESCRIPTION = 'The names readers see on your articles. Only an owner or admin can change authors.' afterEach(unmountDirectory) function listCalls(backend: ReturnType) { return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/') } function manyAuthors(count: number): Author[] { return Array.from({ length: count }, (_, index) => ({ ...authorsFixture()[1]!, id: 500 + index, name: `Writer ${String(index).padStart(2, '0')}`, slug: `writer-${index}`, })) } describe.each(['owner', 'admin'])('Authors: default for %s', (role) => { it('shows the header, the search field, the columns and the rows', async () => { const backend = await mountDirectory('authors', '/authors', { role }) expect(document.body.querySelector('h1')?.textContent).toBe('Authors') expect(document.body.textContent).toContain(OWNER_DESCRIPTION) expect(button('Add author')).toBeTruthy() expect( document.body.querySelector('input[placeholder="Search by name or slug"]'), ).toBeTruthy() const header = document.body.querySelector( '[data-testid="directory-row"]', )!.previousElementSibling! expect(textOf(header)).toBe('Author Slug Account Articles') expect(rowTexts()).toEqual([ 'ID Inès Duarte ines-duarte Linked 9 articles', 'CR Camille Roche camille-roche Guest 3 articles', 'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article', 'LF Léa Fontaine lea-fontaine Guest No articles', ]) expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25') }) it('puts Authors between Media and Users in the sidebar, current on /authors', async () => { await mountDirectory('authors', '/authors', { role }) const links = Array.from( document.body.querySelectorAll('nav[aria-label="Main"] a'), ) const hrefs = links.map((link) => link.getAttribute('href')) expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users']) expect(links[3]!.getAttribute('aria-current')).toBe('page') }) it('shows a long name in full on hover', async () => { await mountDirectory('authors', '/authors', { role }) const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) => textOf(span).startsWith('Dr. Alexandra'), )! expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield') }) }) describe('Authors: loading', () => { it('draws skeleton rows after the delay and marks the list busy', async () => { vi.useFakeTimers() const backend = createBackend() const hold = pending() backend.responders['GET /v1/author/'] = () => hold.promise await mountDirectory('authors', '/authors', { backend }) expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull() expect( document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'), ).toHaveLength(6) }) }) describe.each([ ['status 500', failWith(500)], ['a network error', networkFailure], ])('Authors: error on load with %s', (_name, responder) => { it('shows the alert and requests the list again on Try again', async () => { const backend = createBackend() backend.responders['GET /v1/author/'] = responder await mountDirectory('authors', '/authors', { backend }) const alert = textOf(document.body.querySelector('[role="alert"]')) expect(alert).toContain("Couldn't load authors") expect(alert).toContain('Check your connection and try again.') delete backend.responders['GET /v1/author/'] await click('Try again') expect(listCalls(backend)).toHaveLength(2) expect(rows()).toHaveLength(4) }) }) describe.each([ ['owner', true], ['admin', true], ['member', false], ])('Authors: empty, first run for %s', (role, canAdd) => { it('hides the toolbar and shows the empty state', async () => { const backend = createBackend() backend.authors = [] await mountDirectory('authors', '/authors', { backend, role }) expect( document.body.querySelector('input[placeholder="Search by name or slug"]'), ).toBeNull() expect(document.body.textContent).toContain('No authors yet') expect(document.body.textContent).toContain( "Authors are the names readers see on articles. They don't need an account.", ) const emptyButton = Array.from(document.body.querySelectorAll('button')).filter( (element) => textOf(element) === 'Add author', ) expect(emptyButton.length > 0).toBe(canAdd) }) }) describe('Authors: no results', () => { it('names the search and clears it from the URL', async () => { const backend = await mountDirectory('authors', '/authors?q=zoe') expect(document.body.textContent).toContain('No authors match "zoe"') expect(document.body.textContent).toContain( 'Check the spelling, or search for a shorter part of the name.', ) await click('Clear search') await flushPromises() expect(router.currentRoute.value.query.q).toBeUndefined() expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25') }) }) describe('Authors: search and pagination in the URL', () => { it('debounces the search, requests it and keeps it in the URL', async () => { vi.useFakeTimers() const backend = await mountDirectory('authors', '/authors') const input = document.body.querySelector( 'input[placeholder="Search by name or slug"]', )! await typeInto(input, 'cam') expect(listCalls(backend)).toHaveLength(1) await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS) await flushPromises() expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25') expect(router.currentRoute.value.query.q).toBe('cam') }) it('requests the page and size found in the URL', async () => { const backend = createBackend() backend.authors = manyAuthors(130) await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend }) expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50') expect(document.body.textContent).toContain('Showing 51–100 of 130') }) it('shows the paginator only when the total exceeds 25', async () => { await mountDirectory('authors', '/authors') expect(document.body.textContent).not.toContain('per page') await unmountDirectory() const backend = createBackend() backend.authors = manyAuthors(40) await mountDirectory('authors', '/authors', { backend }) expect(document.body.textContent).toContain('Showing 1–25 of 40') }) }) describe.each([['size=30'], ['page=abc'], ['page=0']])( 'Authors: invalid value in the URL (%s)', (query) => { it('replaces the value by its default with no notice and never reaches the API', async () => { const backend = await mountDirectory('authors', `/authors?${query}`) await flushPromises() expect(router.currentRoute.value.query.size).toBeUndefined() expect(router.currentRoute.value.query.page).toBeUndefined() expect(listCalls(backend).map((call) => call.raw)).toEqual([ '/v1/author/?page=1&size=25', ]) expect(document.body.querySelector('[role="alert"]')).toBeNull() }) }, ) describe('Authors: row menu for an owner or admin', () => { it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => { await mountDirectory('authors', '/authors') await openRowMenu('Camille Roche') expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual( ['Edit', 'Link user', 'Delete…'], ) }) it('lists Edit and Delete… only for a linked author', async () => { await mountDirectory('authors', '/authors') await openRowMenu('Inès Duarte') expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual( ['Edit', 'Delete…'], ) }) it('opens a bottom sheet below md', async () => { await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH }) await openRowMenu('Camille Roche') expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche') expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3) }) }) describe('Authors: read-only list for other roles', () => { it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => { await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH }) expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION) expect(button('Add author')).toBeUndefined() expect( Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')), ).toHaveLength(0) expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4) }) it('keeps search working and shows the Authors sidebar entry on desktop', async () => { vi.useFakeTimers() const backend = await mountDirectory('authors', '/authors', { role: 'member' }) expect( document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'), ).not.toBeNull() const input = document.body.querySelector( 'input[placeholder="Search by name or slug"]', )! await typeInto(input, 'lea') await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS) await flushPromises() expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25') }) }) describe('Authors: read-only detail', () => { async function openDetail() { await mountDirectory('authors', '/authors', { role: 'member' }) await click('View Inès Duarte') } it('shows the author with Close as the only action and no email', async () => { await openDetail() const text = textOf(dialog()) for (const copy of [ 'Inès Duarte', 'Linked', '9 articles', 'Bio', 'Website', 'Only an owner or admin can change this author.', ]) { expect(text).toContain(copy) } expect(text).not.toContain('ines.duarte@renew.example') expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0) const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf) expect(actions.filter((label) => label !== '')).toEqual(['Close']) }) it('opens the same panel from /authors?edit=ines-duarte', async () => { await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' }) expect(textOf(dialog())).toContain('Only an owner or admin can change this author.') expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0) }) }) void callsTo