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 ArticleEditor from '@/components/ArticleEditor.vue' import RootComponent from '@/App.vue' import { articleMeta, oneSite, readyCheck } from './editorMetaKit' import { ownerAccess } from '@/test/access' const FIXED_NOW = '2026-09-26T11:02:00Z' vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)), })) vi.mock('@/lib/imageCrop', async (importOriginal) => { const actual = await importOriginal() return { ...actual, readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024, })), renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])), } }) class FakeXMLHttpRequest { static instances: FakeXMLHttpRequest[] = [] method = '' url = '' status = 0 responseText = '' withCredentials = false sentBody: FormData | null = null private listeners: Record void>> = {} private uploadListeners: Record void> = {} upload = { addEventListener: ( type: string, cb: (event: { loaded: number; total: number }) => void, ) => { this.uploadListeners[type] = cb }, } constructor() { FakeXMLHttpRequest.instances.push(this) } open(method: string, url: string) { this.method = method this.url = url } send(body: FormData) { this.sentBody = body } abort() { this.dispatch('abort') } addEventListener(type: string, cb: (event?: unknown) => void) { this.listeners[type] = this.listeners[type] || [] this.listeners[type].push(cb) } dispatch(type: string, event?: unknown) { ;(this.listeners[type] || []).forEach((cb) => cb(event)) } resolve(status: number, body: string) { this.status = status this.responseText = body this.dispatch('load') } } function latestXHR() { return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1] } function mib(value: number) { return Math.round(value * 1024 * 1024) } function fileFixture(name: string, type: string, sizeMB: number) { const file = new File(['x'], name, { type }) Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true, }) return file } 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>) { const routes = { 'GET /v1/site/': () => mockResponse(200, oneSite), 'POST /v1/article/12/seo/check': () => mockResponse(200, readyCheck), ...handlers, } return vi.fn((url: string, init?: RequestInit) => { const key = routeFor(url, init) const handler = routes[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

', is_online: false, ...articleMeta, created_at: '2026-03-12T09:14:00Z', updated_at: FIXED_NOW, ...overrides, } } let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', () => ({ useOverlay: () => ({ create: vi.fn() }), useToast: () => ({ add: toastAdd }), })) let currentWrapper: Awaited> | null = null async function mountEditor( handlers: Record Response | Promise>, path = '/article/12/edit', ) { setActivePinia(createPinia()) toastAdd = vi.fn() const session = useSessionStore() session.session = { verified: true, access: ownerAccess(), 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 findButton(text: string) { return Array.from(document.body.querySelectorAll('button')).find( (button) => button.textContent?.trim() === text, ) } function toolbarImageButton(wrapper: Awaited>) { return wrapper.findAll('button').find((button) => button.text() === 'Image') } const RealXMLHttpRequest = global.XMLHttpRequest beforeEach(() => { FakeXMLHttpRequest.instances = [] global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest }) afterEach(async () => { currentWrapper?.unmount() currentWrapper = null sessionStorage.clear() global.XMLHttpRequest = RealXMLHttpRequest await router.push('/login') }) const EMPTY_LIBRARY = { items: [], total: 0, page: 1, size: 25, counts: { all: 0, unused: 0, duplicates: 0 }, } function articleHandlers(extra: Record Response | Promise> = {}) { return { 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), 'GET /v1/media/?page=1&size=25': () => mockResponse(200, EMPTY_LIBRARY), ...extra, } } describe("Opening the uploader from the toolbar's image button on an empty library shows Upload", () => { it('opens ImageUploader on the idle Upload tab and sends no POST', async () => { const wrapper = await mountEditor(articleHandlers()) await toolbarImageButton(wrapper)?.trigger('click') await flushPromises() expect(document.body.textContent).toContain('Add an image') expect(document.body.textContent).toContain('Drop an image here, or choose a file') expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG ยท max 10 MB') expect(findButton('Cancel')).toBeTruthy() expect(findButton('Insert image')).toBeUndefined() expect(FakeXMLHttpRequest.instances).toHaveLength(0) }) }) describe('Dropping a valid file onto the editor opens the uploader on the crop step', () => { it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => { const wrapper = await mountEditor( articleHandlers({ [`GET /v1/media/?hash=${'a'.repeat(64)}&page=1&size=100`]: () => mockResponse(200, EMPTY_LIBRARY), }), ) const file = fileFixture('before.png', 'image/png', 3.9) const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) Object.defineProperty(dropEvent, 'dataTransfer', { value: { files: { 0: file, length: 1 } }, }) wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent) await flushPromises() expect(document.body.textContent).toContain('Crop and resize') expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain( '/v1/media/?page=1&size=25', ) findButton('Upload image')?.click() await flushPromises() expect(FakeXMLHttpRequest.instances).toHaveLength(1) expect(latestXHR().url).toBe('/v1/media/') expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png') }) }) describe('A successful upload inserts the image at the cursor and marks the draft dirty', () => { it('inserts the image with its id, marks the draft dirty and toasts', async () => { const wrapper = await mountEditor(articleHandlers()) await toolbarImageButton(wrapper)?.trigger('click') await flushPromises() const fileInput = document.body.querySelector('input[type="file"]')! const file = fileFixture('north-wall.jpg', 'image/jpeg', 3.9) Object.defineProperty(fileInput, 'files', { value: { 0: file, length: 1 }, configurable: true, }) fileInput.dispatchEvent(new Event('change')) await flushPromises() findButton('Upload image')?.click() await flushPromises() expect(FakeXMLHttpRequest.instances).toHaveLength(1) latestXHR().resolve( 201, JSON.stringify({ id: 13, filename: 'north-wall.jpg', url: '/static/uploadedImages/13.jpg', size: 4089446, }), ) await flushPromises() const insertedBody = wrapper.findComponent(ArticleEditor).props('modelValue') expect(insertedBody).toContain( '', ) 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 added', description: 'Save to keep it in the article.', }) }) })