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

667 lines
22 KiB
TypeScript

import { afterEach, 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 { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | 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>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}
})
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function iso(offsetMs: number) {
return new Date(NOW.getTime() - offsetMs).toISOString()
}
function sixArticleFixture(): Article[] {
return [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: iso(2 * HOUR_MS),
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: iso(1 * DAY_MS),
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: iso(3 * DAY_MS),
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '<img src="/i/10.png"><img src="/i/11.png"><img src="/i/12.png">',
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: iso(5 * DAY_MS),
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticles(handlers: Record<string, () => Response | Promise<Response>>) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push('/article')
const wrapper = mount(ArticleView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === label)
}
async function clickTab(label: string) {
const button = Array.from(document.body.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
async function openRowMenu(wrapper: Awaited<ReturnType<typeof mount>>, title: string) {
const button = document.body.querySelector<HTMLButtonElement>(
`button[aria-label="Actions for ${title}"]`,
)
button?.click()
await flushPromises()
}
async function chooseMenuItem(label: string) {
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === label,
)
;(item as HTMLElement | undefined)?.click()
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
const titles = sixArticleFixture().map((a) => a.title)
const rendered = wrapper.text()
for (const title of titles) {
expect(rendered).toContain(title)
}
expect(wrapper.find('caption').text()).toBe(
'Articles, sorted by last updated, newest first',
)
const firstLink = wrapper.find('[data-row-title="1"]')
expect(firstLink.attributes('href')).toBe('/article/1/edit')
})
})
describe('Loading — skeleton rows while the first request is in flight', () => {
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
[articleListRoute()]: () =>
new Promise((resolve) => {
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
resolveList?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((el) =>
el.textContent?.trim(),
)
expect(items).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
})
})
describe('Copy API URL succeeds — the absolute URL is copied and confirmed', () => {
it('writes the URL to the clipboard and raises a success toast', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Copy API URL')
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/v1/article/4`)
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-link-copied-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description:
'The API URL for "Winter checklist for timber windows" is on your clipboard.',
})
})
})
describe('Copy API URL fails — the clipboard write itself is rejected', () => {
it('raises an error toast with no text line', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Copy API URL')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-link-copy-failed-4',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
})
})
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
})
expect(wrapper.text()).toContain('All 0')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('No articles yet')
expect(wrapper.text()).toContain(
'Write your first one. It stays a draft until you publish it.',
)
await findButton(wrapper, 'New article')?.trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
})
})
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
})
})
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const all = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
[articleListRoute({ q: 'tiles' })]: () =>
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
})
const searchInput = wrapper.find('input[type="search"]')
await searchInput.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('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
await findButton(wrapper, 'Clear search')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
const inputEl = document.querySelector('input[placeholder="Search by title"]')
expect(inputEl).toBe(document.activeElement)
})
})
describe('An empty tab shows its own EmptyState, while the other tab still counts correctly', () => {
it('shows No drafts when every article is online', async () => {
const onlineOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
[articleListRoute({ status: 'draft' })]: () =>
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
})
await clickTab('Drafts 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('No drafts')
expect(wrapper.text()).toContain("Everything you've written is online.")
})
it('shows Nothing is online yet when every article is a draft', async () => {
const draftsOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
[articleListRoute({ status: 'online' })]: () =>
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
})
await clickTab('Online 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 4')
expect(wrapper.text()).toContain('Nothing is online yet')
expect(wrapper.text()).toContain('Publish a draft and it will appear on your website.')
})
})
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
expect(wrapper.text()).toContain('Publishing…')
expect(wrapper.text()).toContain('Choosing lime plaster for old stone walls')
})
})
describe('Published — a 200 updates the row in place and raises a toast', () => {
it('shows the online badge, "Just now" and the success toast', async () => {
const updated = {
...sixArticleFixture()[3],
is_online: true,
updated_at: iso(0),
}
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-published-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article published',
description: '"Winter checklist for timber windows" is now online.',
})
})
})
describe('Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': patchCall,
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
await flushPromises()
expect(wrapper.text()).toContain('Winter checklist for timber windows')
expect(wrapper.text()).not.toContain('Publishing…')
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
id: 'article-publish-failed-4',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't publish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
}),
)
const call = toastAdd.mock.calls.find(([arg]) => arg.id === 'article-publish-failed-4')
expect(patchCall).toHaveBeenCalledTimes(1)
call?.[0].actions[0].onClick()
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
})
})
describe('Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
await chooseMenuItem('Unpublish')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
id: 'article-unpublish-failed-3',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
}),
)
const call = toastAdd.mock.calls.find(([arg]) => arg.id === 'article-unpublish-failed-3')
expect(patchCall).toHaveBeenCalledTimes(1)
call?.[0].actions[0].onClick()
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
})
})
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
await chooseMenuItem('Publish')
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/2': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
await chooseMenuItem('Delete…')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
})
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
await chooseMenuItem('Unpublish')
await flushPromises()
expect(wrapper.text()).not.toContain('Before and after: the Rue Verte kitchen')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 3')
expect(wrapper.text()).toContain('Drafts 2')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-3',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
})
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Delete this article?')
expect(props.description).toBe(
'"Winter checklist for timber windows" will be deleted. Its images stay in your library. This can\'t be undone.',
)
expect(props.confirmLabel).toBe('Delete article')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Deleting…')
expect(props.tone).toBe('danger')
})
})
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(204),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
overlayMock.resolveOpen(true)
await flushPromises()
expect(wrapper.text()).not.toContain('Winter checklist for timber windows')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-deleted-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: '"Winter checklist for timber windows" is gone.',
})
})
})
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(500),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't delete the article, so it's still there. Try again in a moment.",
)
expect(wrapper.text()).toContain('Winter checklist for timber windows')
})
})