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 { useSeenImagesStore } from '@/stores/seenImages' import { useSessionStore } from '@/stores/session' import type { Article } from '@/composables/useArticles' import ArticleEditor from '@/components/ArticleEditor.vue' import RootComponent from '@/App.vue' const FIXED_NOW = '2026-09-26T11:02:00Z' function mockResponse(status: number, body: unknown = null, headers: Record = {}) { return { ok: status >= 200 && status < 300, status, headers: { get: (name: string) => headers[name] ?? 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 articleFixture(overrides: Partial
= {}): Article { return { id: 12, title: 'How we price a bathroom renovation', body: 'a'.repeat(4318), images: [ { id: 1, url: '/images/1.png' }, { id: 2, url: '/images/2.png' }, { id: 3, url: '/images/3.png' }, ], is_online: false, created_at: '2026-03-12T09:14:00Z', updated_at: FIXED_NOW, ...overrides, } } 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', () => ({ useOverlay: () => overlayMock, useToast: () => ({ add: toastAdd }), })) let currentWrapper: Awaited> | null = null async function mountEditor( handlers: Record Response | Promise>, path = '/article/12/edit', ) { 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(path) const wrapper = mount(RootComponent, { attachTo: document.body, global: { plugins: [router, uiPlugin], }, }) currentWrapper = wrapper await flushPromises() return wrapper } function saveButton(wrapper: Awaited>) { return wrapper.find('[data-testid="save-button"]') } function articleEditorBody(wrapper: Awaited>) { return wrapper.findComponent(ArticleEditor).props('modelValue') } async function typeBody(wrapper: Awaited>, body: string) { await wrapper.findComponent(ArticleEditor).vm.$emit('update:modelValue', body) await flushPromises() } function paragraph(inner: string) { return `

${inner}

` } function paragraphOfLength(totalLength: number) { return paragraph('a'.repeat(totalLength - 7)) } beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date(FIXED_NOW)) }) afterEach(async () => { currentWrapper?.unmount() currentWrapper = null vi.useRealTimers() sessionStorage.clear() const onLineSpy = vi.spyOn(navigator, 'onLine', 'get') onLineSpy.mockReturnValue(true) window.dispatchEvent(new Event('online')) onLineSpy.mockRestore() await router.push('/login') }) describe('Skeletons appear only after 150 ms, never for a fast response', () => { it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => { let resolveGet: (() => void) | null = null const wrapper = await mountEditor({ 'GET /v1/article/12': () => new Promise((resolve) => { resolveGet = () => resolve(mockResponse(200, articleFixture())) }), }) expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false) await vi.advanceTimersByTimeAsync(150) expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(true) expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(true) expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(true) resolveGet?.() await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false) expect(wrapper.text()).toContain('How we price a bathroom renovation') }) it('never shows a skeleton when the response resolves in under 150 ms', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())), }) await vi.advanceTimersByTimeAsync(0) await flushPromises() expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(false) expect(wrapper.text()).toContain('How we price a bathroom renovation') }) }) describe('Saved — a freshly loaded article, draft or online', () => { it.each([ { is_online: false, badge: 'Draft', publishLabel: 'Publish' }, { is_online: true, badge: 'Online', publishLabel: 'Unpublish' }, ])( 'reads the Saved state for is_online $is_online', async ({ is_online, badge, publishLabel }) => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })), }) expect(wrapper.text()).toContain('Articles') expect(wrapper.text()).toContain('How we price a bathroom renovation') expect(wrapper.text()).toContain(badge) expect(wrapper.text()).toContain('Saved at 11:02') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) expect(wrapper.text()).toContain(publishLabel) expect(wrapper.text()).toContain('4 318 / 10 000') }, ) }) describe('Unsaved changes — any edit marks the article dirty', () => { it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) expect(wrapper.text()).toContain('Unsaved changes') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) expect(wrapper.text()).toContain('Save and publish') }) }) describe('Saving — Save sends the PATCH and disables itself while in flight', () => { it('sends the edited body and shows Saving…', async () => { let patchBody: string | null = null const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) global.fetch = vi.fn((url: string, init?: RequestInit) => { if (routeFor(url, init) === 'PATCH /v1/article/12') { patchBody = init?.body as string return new Promise(() => {}) } throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`) }) as unknown as typeof fetch await saveButton(wrapper).trigger('click') await flushPromises() expect(JSON.parse(patchBody ?? '{}')).toEqual({ body: paragraphOfLength(4320), }) expect(wrapper.text()).toContain('Saving…') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) }) }) describe('Ctrl/⌘ S saves the same way as clicking Save', () => { it('sends the same PATCH on Ctrl+S', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) let patchBody: string | null = null global.fetch = vi.fn((url: string, init?: RequestInit) => { if (routeFor(url, init) === 'PATCH /v1/article/12') { patchBody = init?.body as string return new Promise(() => {}) } throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`) }) as unknown as typeof fetch window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true })) await flushPromises() expect(JSON.parse(patchBody ?? '{}')).toEqual({ body: paragraphOfLength(4320), }) expect(wrapper.text()).toContain('Saving…') }) }) describe('A successful save returns to the Saved state with the new time', () => { it('shows Saved at the current time and disables Save again', async () => { vi.setSystemTime(new Date('2026-09-26T11:30:00Z')) let resolvePatch: (() => void) | null = null const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'PATCH /v1/article/12': () => new Promise((resolve) => { resolvePatch = () => resolve( mockResponse( 200, articleFixture({ body: paragraphOfLength(4320), }), ), ) }), }) await typeBody(wrapper, paragraphOfLength(4320)) await saveButton(wrapper).trigger('click') await flushPromises() resolvePatch?.() await flushPromises() expect(wrapper.text()).toContain('Saved at 11:30') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) }) }) describe('SaveStatus switches to a relative time after an hour', () => { it('reads "Saved n h ago" once more than an hour has passed', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) expect(wrapper.text()).toContain('Saved at 11:02') vi.setSystemTime(new Date('2026-09-26T12:32:00Z')) await vi.advanceTimersByTimeAsync(20000) await flushPromises() expect(wrapper.text()).toContain('Saved 1 h ago') }) }) describe('Save failed — a 500 or a network failure keeps the changes 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 Save failed with Retry for $name', async ({ handler }) => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'PATCH /v1/article/12': handler, }) await typeBody(wrapper, paragraph('Revised opening paragraph.')) await saveButton(wrapper).trigger('click') await flushPromises() expect(wrapper.text()).toContain('Save failed') expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true) expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.')) }) }) describe('Retry resends the same body and can recover', () => { it('resends the same PATCH and shows Saved at the current time', async () => { const patchCall = vi .fn() .mockResolvedValueOnce(mockResponse(500)) .mockResolvedValueOnce( mockResponse( 200, articleFixture({ body: paragraph('Revised opening paragraph.'), }), ), ) const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'PATCH /v1/article/12': patchCall, }) await typeBody(wrapper, paragraph('Revised opening paragraph.')) await saveButton(wrapper).trigger('click') await flushPromises() const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry') await retryButton?.trigger('click') await flushPromises() expect(patchCall).toHaveBeenCalledTimes(2) expect(wrapper.text()).toMatch(/Saved at \d{2}:\d{2}/) }) }) describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => { it('shows the toast and never reads Save failed', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'PATCH /v1/article/12': () => mockResponse(404), }) await typeBody(wrapper, paragraphOfLength(4320)) await saveButton(wrapper).trigger('click') await flushPromises() expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ title: 'That article no longer exists.', }), ) expect(wrapper.text()).not.toContain('Save failed') }) }) describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => { it('shows the warning icon and keeps Save enabled', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(9240)) expect(wrapper.text()).toContain('9 240 / 10 000') expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true) expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) }) }) describe('Too long — over 10000 characters disables Save and Publish until shortened', () => { it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(10214)) expect(wrapper.text()).toContain('10 214 / 10 000') expect(wrapper.text()).toContain( '214 characters over. Shorten the text or remove an image. Formatting counts too.', ) expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) const publishButton = wrapper .findAll('button') .find((button) => button.text() === 'Publish' || button.text() === 'Save and publish') expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true) await typeBody(wrapper, paragraphOfLength(9000)) expect(wrapper.text()).not.toContain('characters over') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) }) }) describe('Preview shows the rendered title and body without the editing surface', () => { it('replaces the editor with a read-only rendering, then restores it unchanged', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture({ body: '

Some body

' })), }) const previewSwitch = wrapper.find('button[role="switch"]') await previewSwitch.trigger('click') await flushPromises() expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false) expect(wrapper.find('[data-testid="article-preview"]').text()).toContain( 'How we price a bathroom renovation', ) expect(wrapper.find('[data-testid="article-preview"]').html()).toContain('Some body') await previewSwitch.trigger('click') await flushPromises() expect(wrapper.findComponent(ArticleEditor).exists()).toBe(true) expect(articleEditorBody(wrapper)).toBe('

Some body

') }) }) describe('Offline blocks Save and Publish, and recovers when the connection returns', () => { it('shows the offline SaveStatus and re-enables Save once back online', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) window.dispatchEvent(new Event('offline')) await flushPromises() expect(wrapper.text()).toContain('Waiting for connection') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) onLineSpy.mockReturnValue(true) window.dispatchEvent(new Event('online')) await flushPromises() expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) expect(wrapper.text()).toContain('Unsaved changes') onLineSpy.mockRestore() }) }) describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => { it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article') expect(articlesLink).toBeTruthy() const navigation = router.push('/article') await flushPromises() const props = overlayMock.getProps() as Record expect(props.title).toBe('Leave without saving?') expect(props.description).toBe( 'Your changes to "How we price a bathroom renovation" haven\'t been saved.', ) expect(props.cancelLabel).toBe('Discard changes') expect(props.confirmLabel).toBe('Keep editing') expect(props.dismissResolvesTo).toBe('confirm') overlayMock.resolveOpen(false) await navigation expect(router.currentRoute.value.path).toBe('/article') }) }) describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => { it('keeps the user on the editor with the unsaved body intact', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article') expect(articlesLink).toBeTruthy() const navigation = router.push('/article') await flushPromises() overlayMock.resolveOpen(true) await navigation expect(router.currentRoute.value.path).toBe('/article/12/edit') expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320)) }) }) describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => { it('keeps the user on /article/12/edit with the unsaved body intact', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) const navigation = router.push('/article') await flushPromises() const props = overlayMock.getProps() as Record expect(props.dismissResolvesTo).toBe('confirm') overlayMock.resolveOpen(true) await navigation expect(router.currentRoute.value.path).toBe('/article/12/edit') expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320)) }) }) describe('Leaving a clean article never prompts', () => { it('navigates directly with no ConfirmDialog', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), }) const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article') await articlesLink?.trigger('click') await flushPromises() expect(overlayMock.getProps()).toBeNull() expect(router.currentRoute.value.path).toBe('/article') }) }) describe('Session expired mid-save keeps the draft and restores it after logging back in', () => { it('mirrors the body to sessionStorage and restores it on the next mount', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleFixture()), 'PATCH /v1/article/12': () => mockResponse(401), }) const body = paragraph('Paragraph added just before the session expired.') await typeBody(wrapper, body) await saveButton(wrapper).trigger('click') await flushPromises() expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body) expect(router.currentRoute.value.path).toBe('/login') expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit') expect(router.currentRoute.value.query.reason).toBe('expired') const session = useSessionStore() session.session = { verified: true, username: 'marta' } session.status = 'ready' global.fetch = fetchRouter({ 'GET /v1/article/12': () => mockResponse( 200, articleFixture({ body: 'Last saved body without that paragraph.', }), ), }) as unknown as typeof fetch await router.push('/article/12/edit') const secondWrapper = mount(RootComponent, { attachTo: document.body, global: { plugins: [router, uiPlugin], }, }) await flushPromises() expect(articleEditorBody(secondWrapper)).toBe(body) expect(secondWrapper.text()).toContain('Unsaved changes') secondWrapper.unmount() }) }) function articleWithImageFixture(overrides: Partial
= {}) { return articleFixture({ body: '

Body

', ...overrides, }) } async function requestRemoveImage(wrapper: Awaited>, imageId = 10) { await wrapper.findComponent(ArticleEditor).vm.$emit('request-remove-image', imageId) await flushPromises() } function requestCalls(wrapper: Awaited>) { void wrapper return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init)) } describe('Requesting removal of an inline image opens the neutral confirm dialog', () => { it('opens ConfirmDialog with the exact copy and sends nothing', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) const props = overlayMock.getProps() as Record expect(props.title).toBe('Remove this image from the article?') expect(props.description).toBe( "It's removed from this article only. The file stays in your library and in any other article that uses it.", ) expect(props.confirmLabel).toBe('Remove image') expect(props.cancelLabel).toBe('Cancel') expect(props.tone).toBe('neutral') expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) expect(articleEditorBody(wrapper)).toContain('data-image-id="10"') }) it('previews the image the session has seen with its filename and size', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), }) useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }]) await requestRemoveImage(wrapper) const props = overlayMock.getProps() as { preview: Record } expect(props.preview).toEqual({ src: '/static/uploadedImages/before.png', filename: 'before.png', size: '3.9 MB', }) }) it('previews only the thumbnail for an image the session has not seen', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) const props = overlayMock.getProps() as { preview: Record } expect(props.preview).toEqual({ src: '/static/uploadedImages/before.png', filename: undefined, size: undefined, }) }) }) describe('Cancelling the confirm changes nothing', () => { it('keeps the image and the draft state when the dialog resolves to cancel', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) overlayMock.resolveOpen(false) await flushPromises() expect(articleEditorBody(wrapper)).toContain('data-image-id="10"') expect(wrapper.text()).not.toContain('Unsaved changes') expect(toastAdd).not.toHaveBeenCalled() expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) }) }) describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => { it('removes the image from the content, reads Unsaved changes and sends no request', async () => { const wrapper = await mountEditor({ 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) const props = overlayMock.getProps() as { action: () => Promise } await props.action() await flushPromises() expect(articleEditorBody(wrapper)).not.toContain('data-image-id="10"') expect(wrapper.text()).toContain('Unsaved changes') expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) expect(toastAdd).toHaveBeenCalledWith({ color: 'success', icon: 'i-lucide-circle-check', title: 'Image removed', description: "It's still in your library.", }) expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) }) }) describe('An unknown or deleted article id replaces the editor with an error state', () => { it('shows the not-found error state and navigates to /article', async () => { const wrapper = await mountEditor( { 'GET /v1/article/9999': () => mockResponse(404), }, '/article/9999/edit', ) expect(wrapper.text()).toContain("This article doesn't exist.") expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false) expect(wrapper.text()).not.toContain('Publish') const allArticlesButton = wrapper .findAll('button') .find((button) => button.text() === 'All articles') await allArticlesButton?.trigger('click') await flushPromises() expect(router.currentRoute.value.path).toBe('/article') }) })