Files
RenewCMS/web/src/views/__tests__/AuthorsView.spec.ts
T

325 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof import('@/composables/useApi')>()
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<typeof createBackend>) {
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<HTMLAnchorElement>('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<unknown>()
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<HTMLInputElement>(
'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<HTMLInputElement>(
'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