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' 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 | null = null let resolveOpen: ((value: unknown) => void) | null = null const create = vi.fn((_component: unknown, options: { props: Record }) => { props = options.props return { open: () => new Promise((resolve) => { resolveOpen = resolve }), } }) return { create, getProps: () => props, resolveOpen: (value: unknown) => resolveOpen?.(value), } } let overlayMock: ReturnType let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() 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 Response | Promise>) { 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: '', 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> | null = null async function mountArticles(handlers: Record Response | Promise>) { 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>, 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>, title: string) { const button = document.body.querySelector( `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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => new Promise((resolve) => { resolveList = () => resolve(mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, []), }) 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({ 'GET /v1/article/?format=html': () => 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 wrapper = await mountArticles({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) 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({ 'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly), }) 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({ 'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly), }) 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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 } 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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({ 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') await chooseMenuItem('Delete…') const props = overlayMock.getProps() as Record 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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 } 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({ 'GET /v1/article/?format=html': () => mockResponse(200, 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 } 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') }) })