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

373 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { Article } from '@/composables/useArticles'
import {
articleOf,
bylines,
callsTo,
createMetaBackend,
failWith,
mountMeta,
renewSites,
textOf,
typeInto,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SEARCH_WAIT_MS = 320
beforeEach(() => {
localStorage.clear()
})
afterEach(unmountMeta)
const FIVE = ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine']
function backendWith(sites: 'one' | 'several', articles: Article[]): MetaBackend {
const backend = createMetaBackend({
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
})
backend.articles = articles
return backend
}
function withAuthors(id: number, names: string[], overrides: Partial<Article> = {}) {
return articleOf(id, `Article ${id}`, {
authors: bylines(...names),
...overrides,
})
}
function bodyRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
}
function cell(row: HTMLElement, index: number) {
return row.children[index] as HTMLElement
}
function headers() {
return Array.from(document.body.querySelectorAll('thead th')).map(textOf)
}
function siteSelect() {
return document.body.querySelector<HTMLElement>('button[aria-label="Site"]')
}
function listCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
}
async function chooseSite(label: string) {
siteSelect()!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item) === label)!
.click()
await flushPromises()
}
function currentQuery() {
return { ...router.currentRoute.value.query }
}
describe('Articles: the Authors column on every frame', () => {
it.each([
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
{
authors: 'one author',
names: FIVE.slice(0, 1),
avatars: 1,
more: '',
text: '',
},
{
authors: 'three',
names: FIVE.slice(0, 3),
avatars: 3,
more: '',
text: '',
},
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, names)]),
})
const authorsCell = cell(bodyRows()[0]!, 1)
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
expect(
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
).toBe(more)
if (text) {
expect(textOf(authorsCell)).toBe(text)
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
}
expect(headers()[1]).toBe('Authors')
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
})
it('draws avatars with the first name and no site on the mobile cards', async () => {
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith('several', [
withAuthors(1, FIVE.slice(0, 3), { site_id: 2 }),
withAuthors(2, []),
]),
})
const cards = Array.from(
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
).map((title) => title.closest('li') as HTMLElement)
expect(textOf(cards[0])).toContain('Inès Duarte +2')
expect(
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(3)
expect(textOf(cards[1])).toContain('No authors')
expect(textOf(cards[0])).not.toContain('Journal')
})
})
describe('Articles: the Site column and the Site filter', () => {
it('has neither with one site', async () => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
})
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(siteSelect()).toBeNull()
expect(textOf(document.body)).not.toContain('All sites')
})
it.each(['owner', 'admin', 'verified'] as const)(
'shows a read-only Site column and a Site select for a %s with several sites',
async (role) => {
const backend = backendWith('several', [
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
withAuthors(2, ['Inès Duarte'], { site_id: 1 }),
])
backend.me.role = role === 'verified' ? undefined : role
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual([
'Title',
'Authors',
'Site',
'Status',
'Created',
'Updated',
'',
])
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
siteSelect()!.click()
await flushPromises()
expect(
Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf),
).toEqual(['All sites', 'Renew', 'Journal', 'Projects', 'Archive'])
},
)
it('drops the Site column and filter when the sites cannot be loaded', async () => {
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
backend.responders['GET /v1/site/'] = failWith(500)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(siteSelect()).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe('Articles: choosing a site', () => {
function journalAndRenew(count: number) {
return Array.from({ length: count }, (_, index) =>
withAuthors(index + 1, ['Inès Duarte'], {
site_id: index % 2 === 0 ? 2 : 1,
is_online: index % 3 !== 0,
}),
)
}
it('puts site=journal in the URL without the page, and asks for that site with the same q, status and sort', async () => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
backend,
})
const before = textOf(document.body.querySelector('[role="tablist"]'))
await chooseSite('Journal')
expect(currentQuery()).toEqual({
site: 'journal',
size: '5',
status: 'online',
sort: 'title:asc',
})
const last = listCalls(backend).at(-1)!
expect(last.query.get('site')).toBe('journal')
expect(last.query.get('page')).toBe('1')
expect(last.query.get('status')).toBe('online')
expect(last.query.get('sort')).toBe('title:asc')
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
expect(siteSelect()!.textContent).toContain('Journal')
})
it('goes back to the full list and page 1 with All sites', async () => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
backend,
})
await chooseSite('All sites')
expect(currentQuery()).toEqual({ size: '5' })
const last = listCalls(backend).at(-1)!
expect(last.query.has('site')).toBe(false)
expect(last.query.get('page')).toBe('1')
})
it.each(['__all', 'all'])('filters by a real site whose key is %s', async (key) => {
const backend = backendWith('several', journalAndRenew(4))
backend.sites = backend.sites.map((site) =>
site.key === 'journal' ? { ...site, key } : site,
)
await mountMeta('articles', '/article', { backend })
await chooseSite('Journal')
expect(currentQuery()).toEqual({ site: key })
expect(listCalls(backend).at(-1)!.query.get('site')).toBe(key)
expect(siteSelect()!.textContent).toContain('Journal')
})
it('reads ?site=journal&page=2 on arrival and shows Journal in the filter', async () => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
backend,
})
expect(siteSelect()!.textContent).toContain('Journal')
const first = listCalls(backend)[0]!
expect(first.query.get('site')).toBe('journal')
expect(first.query.get('page')).toBe('2')
})
it.each([
{ query: '?site=nope', sites: 'several' },
{ query: '?site=renew', sites: 'one' },
] as const)('drops $query silently with $sites site(s)', async ({ query, sites }) => {
const backend = backendWith(sites, journalAndRenew(8))
await mountMeta('articles', `/article${query}`, { backend })
expect(currentQuery()).toEqual({})
expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true)
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
expect(bodyRows().length).toBeGreaterThan(0)
})
it.each([
{
action: 'searches for "plaster"',
act: async () => {
await typeInto(
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
'plaster',
)
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
},
},
{
action: 'changes the tab',
act: async () => {
const tab = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
).find((item) => textOf(item).startsWith('Drafts'))!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
},
},
{
action: 'changes the sort',
act: async () => {
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.click()
await flushPromises()
},
},
{
action: 'goes to page 2',
act: async () => {
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
},
},
{
action: 'changes the page size',
act: async () => {
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item) === '50 per page')!
.click()
await flushPromises()
},
},
])('keeps ?site=journal when the user $action', async ({ act }) => {
const backend = backendWith('several', journalAndRenew(40))
await mountMeta('articles', '/article?site=journal&size=5', { backend })
await act()
expect(currentQuery().site).toBe('journal')
expect(listCalls(backend).at(-1)!.query.get('site')).toBe('journal')
})
it('shows the empty state of a site and returns to the full list with Show all sites', async () => {
const backend = backendWith('several', journalAndRenew(6))
await mountMeta('articles', '/article?site=archive', { backend })
const text = textOf(document.body)
expect(text).toContain('No articles on "Archive"')
expect(text).toContain('Move articles here from the editor, or choose another site.')
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
.find((item) => textOf(item) === 'Show all sites')!
.click()
await flushPromises()
expect(currentQuery()).toEqual({})
expect(listCalls(backend).at(-1)!.query.has('site')).toBe(false)
expect(bodyRows().length).toBeGreaterThan(0)
})
it('keeps the existing No results and No drafts states when a search or a tab is active on a site', async () => {
const backend = backendWith('several', journalAndRenew(6))
await mountMeta('articles', '/article?site=archive&q=tiles', {
backend,
})
expect(textOf(document.body)).toContain('No articles match "tiles"')
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
await unmountMeta()
await mountMeta('articles', '/article?site=archive&status=draft', {
backend,
})
expect(textOf(document.body)).toContain('No drafts')
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
})
})