import { afterEach, 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 ArticleEditor from '@/components/ArticleEditor.vue' import RootComponent from '@/App.vue' import { articleMeta, oneSite, readyCheck } from './editorMetaKit' import { fixture } from './mediaTestKit' import { ownerAccess } from '@/test/access' const FIXED_NOW = '2026-09-26T11:02:00Z' const BODY_WITH_TWO_IMAGES = '

Intro

' 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 articleFixture(body = BODY_WITH_TWO_IMAGES): Article { return { id: 12, title: 'How we price a bathroom renovation', body, is_online: false, ...articleMeta, created_at: '2026-03-12T09:14:00Z', updated_at: FIXED_NOW, } } function libraryPage() { const items = fixture().map((item) => item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item, ) return { items, total: 48, page: 1, size: 25, counts: { all: 48, unused: 2, duplicates: 2 }, } } let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', () => ({ useOverlay: () => ({ create: vi.fn() }), useToast: () => ({ add: toastAdd }), })) let currentWrapper: Awaited> | null = null async function mountEditor(body = BODY_WITH_TWO_IMAGES) { setActivePinia(createPinia()) toastAdd = vi.fn() const session = useSessionStore() session.session = { verified: true, access: ownerAccess(), username: 'marta' } session.status = 'ready' global.fetch = vi.fn(async (url: string) => { if (url === '/v1/article/12?format=html') { return mockResponse(200, articleFixture(body)) } if (url === '/v1/site/') { return mockResponse(200, oneSite) } if (url === '/v1/article/12/seo/check') { return mockResponse(200, readyCheck) } if (url === '/v1/media/?page=1&size=25') { return mockResponse(200, libraryPage()) } throw new Error(`Unexpected fetch call: ${url}`) }) as unknown as typeof fetch await router.push('/article/12/edit') const wrapper = mount(RootComponent, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) currentWrapper = wrapper await flushPromises() return wrapper } function findButton(text: string) { return Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === text, ) } async function openPicker(wrapper: Awaited>) { await wrapper .findAll('button') .find((button) => button.text() === 'Image') ?.trigger('click') await flushPromises() } function tile(filename: string) { return Array.from(document.body.querySelectorAll('[data-picker-tile]')).find( (element) => element.getAttribute('aria-label')?.startsWith(`${filename},`), )! } function taggedIds() { return Array.from(document.body.querySelectorAll('[data-picker-tile]')) .filter((element) => element.textContent?.includes('In this article')) .map((element) => element.getAttribute('data-image-id')) } afterEach(async () => { currentWrapper?.unmount() currentWrapper = null document.body.innerHTML = '' await router.push('/login') }) describe('An image is in this article when its node is in the current content', () => { it('tags images 1 and 4 and no other when the dialog opens', async () => { const wrapper = await mountEditor() await openPicker(wrapper) expect(taggedIds()).toEqual(['1', '4']) }) it('counts an insertion that has not been saved yet', async () => { const wrapper = await mountEditor('

Intro

') wrapper.findComponent(ArticleEditor).vm.insertImage('/static/uploadedImages/2.webp', '', 2) await flushPromises() await openPicker(wrapper) expect(taggedIds()).toEqual(['2']) }) it('no longer tags an image that was removed from the body', async () => { const wrapper = await mountEditor() wrapper.findComponent(ArticleEditor).vm.removeImage(1) await flushPromises() await openPicker(wrapper) expect(taggedIds()).toEqual(['4']) }) }) describe('Insert puts the chosen image in the body with its alt text', () => { it('inserts it at the cursor, marks the draft dirty, toasts and closes the dialog', async () => { const wrapper = await mountEditor('

Intro

') await openPicker(wrapper) tile('kitchen-after.webp').click() await flushPromises() const alt = document.body.querySelector( 'input[placeholder="e.g. North wall of the kitchen before plastering"]', )! alt.value = 'A kitchen with new oak worktops and white tiles' alt.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() findButton('Insert image')?.click() await flushPromises() const body = wrapper.findComponent(ArticleEditor).props('modelValue') expect(body).toContain( 'A kitchen with new oak worktops and white tiles', ) expect(wrapper.text()).toContain('Unsaved changes') expect( (wrapper.find('[data-testid="save-button"]').element as HTMLButtonElement).disabled, ).toBe(false) expect(toastAdd).toHaveBeenCalledWith({ color: 'success', icon: 'i-lucide-circle-check', title: 'Image added', description: 'Save to keep it in the article.', }) expect(document.body.querySelector('[role="dialog"]')).toBeNull() const methods = vi .mocked(global.fetch) .mock.calls.filter(([url]) => !String(url).endsWith('/seo/check')) .map(([, init]) => (init as RequestInit | undefined)?.method) expect(methods.every((method) => method === undefined)).toBe(true) }) it('leaves the content and the draft alone when the dialog is cancelled', async () => { const wrapper = await mountEditor('

Intro

') await openPicker(wrapper) tile('kitchen-after.webp').click() await flushPromises() findButton('Cancel')?.click() await flushPromises() expect(wrapper.findComponent(ArticleEditor).props('modelValue')).not.toContain('