325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
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
|