import { vi } from 'vitest' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import { ApiError, useApi } from '@/composables/useApi' import type { MediaItem } from '@/composables/useMedia' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import MediaView from '../MediaView.vue' type Row = [ number, string, number, number | null, number | null, string, number, string, string, boolean, string, ] const ROWS: Row[] = [ [ 1, 'north-wall.jpg', 4089446, 4032, 3024, 'image/jpeg', 2, 'Marta Alves', '2026-09-12T12:00:00Z', false, '0a', ], [ 2, 'kitchen-after.webp', 1258291, 2400, 1600, 'image/webp', 3, 'Inès Duarte', '2026-09-08T12:00:00Z', false, '1b', ], [ 3, 'kitchen-before.webp', 1153434, 2400, 1600, 'image/webp', 1, 'Inès Duarte', '2026-09-08T11:00:00Z', false, '2c', ], [ 4, 'logo-renew.svg', 8192, null, null, 'image/svg+xml', 5, 'jonas', '2026-09-05T12:00:00Z', false, '3d', ], [ 5, 'floorboards-copy.jpg', 2516582, 3600, 2400, 'image/jpeg', 3, 'Marta Alves', '2026-09-02T12:00:00Z', true, '0e', ], [ 6, 'IMG_2208.jpg', 2516582, 3600, 2400, 'image/jpeg', 2, 'jonas', '2026-08-30T12:00:00Z', true, '0e', ], [ 7, 'IMG_4471.jpg', 3250585, 3600, 2400, 'image/jpeg', 2, 'Marta Alves', '2026-08-29T12:00:00Z', true, '2f', ], [ 8, 'window-frame.jpg', 1887437, 3000, 2000, 'image/jpeg', 0, 'Inès Duarte', '2026-08-28T12:00:00Z', false, '3a', ], [ 9, 'stone-wall-detail.jpg', 3250585, 3600, 2400, 'image/jpeg', 1, 'jonas', '2026-08-21T12:00:00Z', true, '2f', ], [ 10, 'bathroom-tiles.png', 880640, 1800, 1200, 'image/png', 0, 'Marta Alves', '2026-08-04T12:00:00Z', false, '1b', ], [ 11, 'lime-plaster.jpg', 2202009, 3200, 2133, 'image/jpeg', 2, 'Inès Duarte', '2026-07-19T12:00:00Z', false, '1c', ], [ 12, 'floorboards.jpg', 2516582, 3600, 2400, 'image/jpeg', 4, 'Inès Duarte', '2026-06-14T12:00:00Z', true, '0e', ], ] export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 } export const MEGABYTE = 1024 * 1024 export function fixture(ids?: number[]): MediaItem[] { return ROWS.filter(([id]) => !ids || ids.includes(id)).map( ([ id, filename, size, width, height, content_type, used_in, uploaded_by, created_at, duplicate, sha256, ]) => ({ id, filename, url: `/media/${filename}`, size, content_type, width, height, sha256, uploaded_by, created_at, used_in, duplicate, }), ) } export function listBody(items: MediaItem[], counts = FULL_COUNTS) { return { items, next_cursor: null, counts } } export function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } export interface Handlers { list?: (path: string) => unknown usage?: (id: number) => unknown hash?: (path: string) => unknown remove?: (id: number) => unknown merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown preview?: () => unknown purge?: (body: { fingerprint: string }) => unknown } export function libraryHandlers( outcome: (id: number) => unknown = () => undefined, ids?: number[], ): Handlers { const gone = new Set() return { list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))), remove: async (id) => { try { const result = await outcome(id) gone.add(id) return result } catch (error) { if (error instanceof ApiError && error.status === 404) { gone.add(id) } throw error } }, } } export const kit = { requestMock: vi.fn(), wrapper: null as VueWrapper | null, } function dispatcher(handlers: Handlers) { return (path: string, options?: RequestInit) => { const method = options?.method ?? 'GET' if (method === 'GET' && path.startsWith('/v1/media/?hash=')) { return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path) } if (method === 'GET' && path.startsWith('/v1/media/?')) { return (handlers.list ?? (() => listBody(fixture())))(path) } if (method === 'GET' && path === '/v1/media/purge-preview') { return (handlers.preview ?? (() => undefined))() } if (method === 'POST' && path === '/v1/media/purge') { return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body))) } const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path) if (method === 'GET' && usage) { return (handlers.usage ?? (() => []))(Number(usage[1])) } if (method === 'POST' && path === '/v1/media/merge') { return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))( JSON.parse(String(options?.body)), ) } const single = /^\/v1\/media\/(\d+)$/.exec(path) if (method === 'DELETE' && single) { return (handlers.remove ?? (() => undefined))(Number(single[1])) } throw new Error(`Unexpected request: ${method} ${path}`) } } function fetchFrom(dispatch: ReturnType) { return vi.fn(async (path: string, init?: RequestInit) => { const reply = (status: number, body?: unknown) => ({ ok: status >= 200 && status < 300, status, headers: new Headers(), json: async () => body, text: async () => '', }) try { const body = await dispatch(path, init) return reply(body === undefined ? 204 : 200, body) } catch (error) { if (error instanceof ApiError) { return reply(error.status) } throw error } }) } export async function mountPage( handlers: Handlers = {}, url = '/media', width = DESKTOP_WIDTH, options: { realApi?: boolean; role?: string } = {}, ) { setViewportWidth(width) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: (options.role ?? 'owner') as never, } session.status = 'ready' const dispatch = dispatcher(handlers) if (options.realApi) { kit.requestMock = fetchFrom(dispatch) vi.stubGlobal('fetch', kit.requestMock) } else { kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init)) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }), request: kit.requestMock, }) } await router.push(url) kit.wrapper = mount(MediaView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return kit.wrapper } export async function unmountPage() { kit.wrapper?.unmount() kit.wrapper = null vi.useRealTimers() vi.unstubAllGlobals() vi.restoreAllMocks() await router.push('/login') document.body.innerHTML = '' } export function textOf(element: Element | null | undefined) { if (!element) { return '' } const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const parts: string[] = [] while (walker.nextNode()) { const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim() if (text) { parts.push(text) } } return parts.join(' ') } export function cards() { return Array.from(document.body.querySelectorAll('[data-media-card]')) } export function cardFor(filename: string) { return cards().find((card) => textOf(card).includes(filename))! } export function tabs() { return Array.from(document.body.querySelectorAll('[role="tab"]')) } export function tab(label: string) { return tabs().find((element) => textOf(element).startsWith(label))! } export function findButton(label: string, root: ParentNode = document.body) { return Array.from(root.querySelectorAll('button')).find( (button) => textOf(button) === label, ) } export function panel() { return document.body.querySelector('[role="dialog"]') } export function dialog() { return document.body.querySelector('[role="alertdialog"]') } export function listCalls() { return kit.requestMock.mock.calls.filter( ([path]) => typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='), ) } export function lastListPath() { return listCalls().at(-1)![0] as string } export function callsTo(method: string, pattern: RegExp) { return kit.requestMock.mock.calls.filter( ([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path), ) } export function press(element: Element, key: string) { element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) } export function pressOnWindow(key: string) { document.body.dispatchEvent( new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }), ) } export async function settle() { await flushPromises() await new Promise((resolve) => setTimeout(resolve, 10)) await flushPromises() await new Promise((resolve) => setTimeout(resolve, 10)) await flushPromises() } export function announcer() { return textOf(document.body.querySelector('[data-testid="media-announcer"]')) } export function checkboxFor(filename: string) { return document.body.querySelector( `[role="checkbox"][aria-label="Select ${filename}"]`, )! } export function selectAllCheckbox() { return document.body .querySelector('[data-testid="image-count"]')! .parentElement!.querySelector('[role="checkbox"]')! } export function tableHeaderCheckbox() { return document.body.querySelector('[role="checkbox"][aria-label="Select all"]')! } export function bulkBar() { return document.body.querySelector('[data-testid="media-bulk-bar"]') } export function fileWith(name: string, type: string, size?: number, content = name) { const file = new File([content], name, { type }) if (size !== undefined) { Object.defineProperty(file, 'size', { value: size }) } return file } export function hexOf(content: string) { return Array.from(new TextEncoder().encode(content), (byte) => byte.toString(16).padStart(2, '0'), ) .join('') .padEnd(64, '0') .slice(0, 64) } export function stubDigest() { const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => { const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0')) .join('') .padEnd(64, '0') .slice(0, 64) return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer }) vi.stubGlobal('crypto', { subtle: { digest } }) return digest } export class FakeXhr extends EventTarget { static instances: FakeXhr[] = [] upload = new EventTarget() method = '' url = '' status = 0 responseText = '' withCredentials = false body: FormData | null = null aborted = false finished = false headers: Record = {} open(method: string, url: string) { this.method = method this.url = url } send(body: FormData) { this.body = body FakeXhr.instances.push(this) } abort() { this.aborted = true this.finished = true this.dispatchEvent(new Event('abort')) } getResponseHeader(name: string) { return this.headers[name.toLowerCase()] ?? null } progress(loaded: number, total: number) { this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total })) } respond(status: number, headers: Record = {}, text = '') { this.status = status this.headers = Object.fromEntries( Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), ) this.responseText = text this.finished = true this.dispatchEvent(new Event('load')) } fail() { this.finished = true this.dispatchEvent(new Event('error')) } get fileName() { return (this.body?.get('file') as File).name } } export function stubXhr() { FakeXhr.instances = [] vi.stubGlobal('XMLHttpRequest', FakeXhr) } export function inFlight() { return FakeXhr.instances.filter((xhr) => !xhr.finished) } export function xhrFor(name: string) { return FakeXhr.instances.find((xhr) => xhr.fileName === name)! } export function fileInput() { return document.body.querySelector('input[type="file"]')! } export async function chooseFiles(files: File[]) { const input = fileInput() Object.defineProperty(input, 'files', { value: files, configurable: true }) input.dispatchEvent(new Event('change', { bubbles: true })) await settle() } export function dragEvent(type: string, types: string[], files: File[] = []) { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(event, 'dataTransfer', { value: { types, files } }) document.body.dispatchEvent(event) return event } export function uploadPanel() { return document.body.querySelector('[data-testid="media-upload-panel"]') } export function uploadRows() { return Array.from(document.body.querySelectorAll('[data-testid="upload-row"]')) } export function rowTexts() { return uploadRows().map(textOf) }