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

737 lines
24 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, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
function createOverlayMock() {
let resolveOpen: ((value: unknown) => void) | null = null
let props: Record<string, unknown> | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: vi.fn() }),
}
})
function buildArticles(count: number): Article[] {
return Array.from({ length: count }, (_, index) => {
const id = index + 1
return {
id,
title: id % PLASTER_EVERY === 0 ? `Lime plaster notes ${id}` : `Article ${id}`,
body: '',
is_online: id % 3 !== 0,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-08-18T09:00:00Z',
}
})
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
type ListHandler = (params: EnvelopeParams, url: string) => Response | Promise<Response>
let articles: Article[]
let listCalls: EnvelopeParams[]
let listHandler: ListHandler
let deleteHandler: (id: number) => Response | Promise<Response>
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
function respondFromFixture(params: EnvelopeParams) {
return mockResponse(200, articleEnvelope(articles, params))
}
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url === '/v1/site/') {
return Promise.resolve(mockResponse(200, oneSite))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
return Promise.resolve(listHandler(params, url))
}
const deleted = url.match(/^\/v1\/article\/(\d+)$/)
if (method === 'DELETE' && deleted) {
return Promise.resolve(deleteHandler(Number(deleted[1])))
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
}) as unknown as typeof fetch
}
async function mountAt(path: string) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
wrapper = mount(ArticleView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function pageButtons() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
).filter((button) => /^\d+$/.test(button.textContent?.trim() ?? ''))
}
function rowIds() {
return new Set(
Array.from(document.body.querySelectorAll('[data-row-title]')).map((row) =>
row.getAttribute('data-row-title'),
),
)
}
function paginatorControls() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
)
}
async function click(button: HTMLElement | null | undefined) {
button?.click()
await flushPromises()
}
function alertText() {
return document.body.querySelector('[role="alert"], [role="status"]')?.textContent ?? ''
}
function pageSizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
localStorage.clear()
articles = buildArticles(TOTAL)
listCalls = []
listHandler = (params) => respondFromFixture(params)
deleteHandler = () => mockResponse(204)
setViewportWidth(DESKTOP_WIDTH)
installFetch()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.restoreAllMocks()
await router.push('/login')
})
describe('French caption', () => {
it('renders the range summary in French and restores English', async () => {
setLocale('fr')
try {
await mountAt('/article')
expect(summary()).toBe('Affichage de 1 à 25 sur 312')
} finally {
setLocale('en')
}
})
})
describe('1 First page', () => {
it('shows the first 25 of 312 with First and Previous disabled and no page or size in the URL', async () => {
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(summary()).toBe('Showing 1–25 of 312')
expect(rowIds().size).toBe(25)
expect(control('First page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
expect(control('Last page')?.disabled).toBe(false)
expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('Disabled controls', () => {
it('carry the 40 % opacity class on every paginator button', async () => {
await mountAt('/article')
const disabled = paginatorControls().filter((button) => button.disabled)
expect(disabled.length).toBeGreaterThan(1)
for (const button of disabled) {
expect(button.className).toContain('disabled:opacity-40')
}
})
it('carry it on the mobile Previous and Next buttons', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article')
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Previous page')?.className).toContain('disabled:opacity-40')
expect(control('Next page')?.className).toContain('disabled:opacity-40')
})
})
describe('2 Middle page', () => {
it('shows page 7 as current with ellipses and every control enabled', async () => {
await mountAt('/article?page=7')
expect(summary()).toBe('Showing 151–175 of 312')
const current = pageButtons().filter((b) => b.getAttribute('aria-current') === 'page')
expect(current.map((b) => b.textContent?.trim())).toEqual(['7'])
expect(current[0]?.className).toContain('bg-(--rc-sand)')
expect(current[0]?.className).toContain('text-(--rc-on-sand)')
expect(current[0]?.className).toContain('border-primary')
expect(document.body.querySelector('nav[aria-label="Pagination"]')?.textContent).toContain(
'…',
)
expect(paginatorControls().every((button) => !button.disabled)).toBe(true)
})
})
describe('3 Last page', () => {
it('shows 301–312 of 312 with Next and Last disabled', async () => {
await mountAt('/article?page=13')
expect(summary()).toBe('Showing 301–312 of 312')
expect(rowIds().size).toBe(12)
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(false)
})
})
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
articles = buildArticles(6).map((article, index) => ({
...article,
is_online: index < 4,
}))
const wrapper = await mountAt('/article')
expect(summary()).toBe('Showing 1–6 of 6')
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(pageSizeTrigger()).not.toBeNull()
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
})
})
describe('5 Loading next page', () => {
it('shows skeleton rows, disables every control, and moves the URL only once the response arrives', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountAt('/article')
let resolvePage2: (() => void) | null = null
listHandler = (params) =>
new Promise((resolve) => {
resolvePage2 = () => resolve(respondFromFixture(params))
})
await click(control('Next page'))
await vi.advanceTimersByTimeAsync(150)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe('Loading page 2…')
expect(paginatorControls().every((button) => button.disabled)).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/article')
resolvePage2?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(summary()).toBe('Showing 26–50 of 312')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article?page=2')
})
})
describe.each([
['resolves with a 500 response', () => mockResponse(500)],
['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))],
])('6 Error loading a page keeps the previous rows (%s)', (_failure, failure) => {
it('keeps rows and URL, shows the notice, and Retry loads the page', async () => {
await mountAt('/article?page=6')
const previousRows = rowIds()
listHandler = () => failure() as Promise<Response>
await click(control('Next page'))
expect(rowIds()).toEqual(previousRows)
expect(router.currentRoute.value.fullPath).toBe('/article?page=6')
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
expect(control('Next page')?.disabled).toBe(false)
listHandler = (params) => respondFromFixture(params)
const retry = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Retry',
)
await click(retry)
expect(listCalls.at(-1)).toMatchObject({ page: 7 })
expect(summary()).toBe('Showing 151–175 of 312')
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
expect(alertText()).not.toContain("Couldn't load page")
})
})
describe('7 Page out of range redirects to the last page with a notice', () => {
it('replaces the URL with the last page and tells the user', async () => {
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 13])
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
expect(push).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.fullPath).toBe('/article?page=13')
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 301–312 of 312')
})
})
describe('7a A shrinking server cannot loop the last-page redirect', () => {
it('redirects once and then settles', async () => {
let calls = 0
listHandler = (params) => {
calls += 1
return mockResponse(200, {
...articleEnvelope(articles, params),
items: [],
total: 25 * (30 - calls),
})
}
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 29])
})
})
describe('7b Out of range with total 0 shows the empty state, no redirect', () => {
it('shows No articles yet and an /article URL', async () => {
articles = []
const wrapper = await mountAt('/article?page=5')
expect(listCalls).toHaveLength(1)
expect(wrapper.text()).toContain('No articles yet')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('8 Search resets to page 1', () => {
it('sends q with page 1, updates the URL on success and leaves the tab counts alone', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?page=7')
listCalls = []
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: 'plaster', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article?q=plaster')
expect(summary()).toBe('Showing 1–15 of 15')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
})
})
describe('9 Empty after search', () => {
it('hides the paginator and the summary and keeps the tab counts', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article')
await wrapper.find('input[type="search"]').setValue('tiles')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(wrapper.text()).toContain('No articles match "tiles"')
expect(wrapper.text()).toContain(
'Check the spelling, or search for a shorter part of the title.',
)
expect(wrapper.text()).toContain('Clear search')
expect(summary()).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
})
})
describe('10 Page size menu', () => {
it.each([
['5 per page', 5, '/article?size=5'],
['25 per page', 25, '/article'],
['50 per page', 50, '/article?size=50'],
['100 per page', 100, '/article?size=100'],
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
await mountAt('/article?page=3')
listCalls = []
pageSizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
expect(option).toBeTruthy()
;(option as HTMLElement).click()
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, size })
expect(router.currentRoute.value.fullPath).toBe(url)
})
})
describe('10b Invalid URL values fall back', () => {
it('requests page 1 and size 25 and replaces the URL', async () => {
await mountAt('/article?size=10&page=abc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('11 Focus-visible', () => {
it('gives every paginator control and the select the 2 px primary outline with a 2 px offset', async () => {
await mountAt('/article?page=7')
const controls = [...paginatorControls(), pageSizeTrigger()]
expect(controls.length).toBeGreaterThan(5)
for (const element of controls) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
})
describe('Keyboard, the page-size list', () => {
it('opens on ArrowDown, selects on Enter and closes on Escape', async () => {
await mountAt('/article')
const trigger = pageSizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
const focused = document.activeElement ?? document.body
focused.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
})
describe('Tabs filter on the server', () => {
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
const wrapper = await mountAt('/article?page=3')
listCalls = []
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Drafts'),
)
tab?.focus()
tab?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: 'draft', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article?status=draft')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
})
})
describe('Mobile, Previous, "Page N of M", Next', () => {
it('shows three controls and no page numbers below md', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article?page=2')
const nav = document.body.querySelector('nav[aria-label="Pagination"]')!
expect(nav.textContent).toContain('Previous')
expect(nav.textContent).toContain('Page 2 of 13')
expect(nav.textContent).toContain('Next')
expect(pageButtons()).toHaveLength(0)
expect(nav.querySelectorAll('button')).toHaveLength(2)
})
})
describe('Deleting the last row of the last page', () => {
it('reloads the previous page and recomputes the counts from the new response', async () => {
articles = buildArticles(26)
await mountAt('/article?page=2')
expect(summary()).toBe('Showing 26–26 of 26')
listCalls = []
const menu = document.body.querySelector<HTMLButtonElement>(
'button[aria-label="Actions for Article 26"]',
)
menu?.click()
await flushPromises()
const remove = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === 'Delete…',
) as HTMLElement
remove.click()
await flushPromises()
const action = overlayMock.getProps()?.action as () => Promise<void>
articles = buildArticles(25)
await action()
overlayMock.resolveOpen(true)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(summary()).toBe('Showing 1–25 of 25')
expect(document.body.textContent).toContain('All 25')
})
})
describe('The envelope is the only shape read', () => {
it('renders rows from items', async () => {
const page: ArticlePage = articleEnvelope(buildArticles(3))
listHandler = () => mockResponse(200, page)
const wrapper = await mountAt('/article')
expect(wrapper.text()).toContain('Article 1')
expect(wrapper.text()).toContain('Article 3')
})
})
function headerButton(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('th button')).find(
(button) => button.textContent?.trim() === label,
)
}
function headerArrow(label: string) {
const direction = headerButton(label)?.querySelector('[data-sort-direction]')
const value = direction?.getAttribute('data-sort-direction')
if (value === 'asc') {
return 'up'
}
return value === 'desc' ? 'down' : 'none'
}
describe('8a Sort headers, server-side, reset to page 1', () => {
it.each([
['Title', 'title:asc'],
['Status', 'status:asc'],
['Created', 'created_at:asc'],
['Updated', 'updated_at:asc'],
])('the first click on %s sends sort=%s and page 1', async (label, sort) => {
const wrapper = await mountAt('/article?page=7')
listCalls = []
await click(headerButton(label))
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort }])
expect(router.currentRoute.value.fullPath).toBe(`/article?sort=${sort}`)
expect(headerArrow(label)).toBe('up')
expect(wrapper.text()).toContain('All 312')
})
it('keeps the URL unchanged until the response arrives', async () => {
await mountAt('/article?page=7')
listHandler = () => new Promise(() => undefined)
await click(headerButton('Title'))
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
})
})
describe('8b Toggling the sorted header', () => {
it('toggles to descending, and returning to updated_at:desc drops sort from the URL', async () => {
await mountAt('/article?sort=title:asc')
listCalls = []
await click(headerButton('Title'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'title:desc', page: 1 })
expect(router.currentRoute.value.fullPath).toBe('/article?sort=title:desc')
expect(headerArrow('Title')).toBe('down')
await click(headerButton('Updated'))
await click(headerButton('Updated'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'updated_at:desc' })
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(headerArrow('Updated')).toBe('down')
})
})
describe('8c Sort is kept across paging, page size and search', () => {
it('carries sort on Next and on a search, and the URL keeps it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?sort=title:asc')
listCalls = []
await click(control('Next page'))
expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({
page: '2',
sort: 'title:asc',
})
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({
page: 1,
q: 'plaster',
sort: 'title:asc',
})
expect(router.currentRoute.value.query).toMatchObject({
q: 'plaster',
sort: 'title:asc',
})
expect(router.currentRoute.value.query.page).toBeUndefined()
})
})
describe('8d A sorted URL on load', () => {
it('sends the sort and shows the Created arrow-down', async () => {
await mountAt('/article?sort=created_at:desc&page=2')
expect(listCalls).toEqual([
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
])
expect(headerArrow('Created')).toBe('down')
})
it('falls back to the default for an invalid sort and replaces the URL', async () => {
await mountAt('/article?sort=body:asc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(headerArrow('Updated')).toBe('down')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('8e No client-side re-sorting', () => {
it('renders the rows in exactly the order the server returned', async () => {
listHandler = (params) => {
const page = articleEnvelope(articles, params)
return mockResponse(200, {
...page,
items: [...page.items].reverse(),
})
}
await mountAt('/article?sort=title:asc')
const rendered = Array.from(document.body.querySelectorAll('table [data-row-title]')).map(
(row) => Number(row.getAttribute('data-row-title')),
)
const expected = articleEnvelope(articles, { sort: 'title:asc' })
.items.map((article) => article.id)
.reverse()
expect(rendered).toEqual(expected)
})
})
describe('8f Caption follows the sort', () => {
it('reads the default caption, then the sorted one', async () => {
await mountAt('/article')
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by last updated, newest first',
)
await click(headerButton('Title'))
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by title, ascending',
)
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
'Title',
)
})
})
describe('8g Mobile card list', () => {
it('has no sort control', async () => {
await mountAt('/article')
expect(document.body.querySelectorAll('ul button[aria-sort], ul [aria-sort]')).toHaveLength(
0,
)
expect(
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
),
).toHaveLength(0)
})
})