import { afterEach, beforeEach, describe, expect, it, 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, NetworkError, useApi } from '@/composables/useApi' import type { MediaItem, MediaUsageItem } from '@/composables/useMedia' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import MediaView from '../MediaView.vue' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd, remove: vi.fn() }) } }) 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', ], ] 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, }), ) } function listBody(items: MediaItem[], counts = { all: 12, unused: 2, duplicates: 2 }) { return { items, next_cursor: null, counts } } function listWithout(id: number) { let calls = 0 return () => ++calls === 1 ? listBody(fixture()) : listBody( fixture().filter((item) => item.id !== id), { all: 11, unused: 1, duplicates: 2 }, ) } const USAGE: MediaUsageItem[] = [ { id: 10, title: 'How we price a bathroom renovation', published: true }, { id: 11, title: 'Winter checklist for timber windows', published: false }, ] 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 } } interface Handlers { list?: (path: string) => unknown usage?: (id: number) => unknown hash?: (path: string) => unknown rename?: (id: number, body: { filename: string }) => unknown remove?: (id: number) => unknown } let requestMock: ReturnType let wrapper: VueWrapper | null = 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(fixture([12, 5, 6]))))(path) } if (method === 'GET' && path.startsWith('/v1/media/?')) { return (handlers.list ?? (() => listBody(fixture())))(path) } const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path) if (method === 'GET' && usage) { return (handlers.usage ?? (() => USAGE))(Number(usage[1])) } const single = /^\/v1\/media\/(\d+)$/.exec(path) if (method === 'PATCH' && single) { const body = JSON.parse(String(options?.body)) as { filename: string } const id = Number(single[1]) return ( handlers.rename ?? ((_id, payload) => ({ ...fixture([id])[0], filename: `${payload.filename}.jpg`, })) )(id, body) } if (method === 'DELETE' && single) { return (handlers.remove ?? (() => undefined))(Number(single[1])) } throw new Error(`Unexpected request: ${method} ${path}`) } } async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTOP_WIDTH) { setViewportWidth(width) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' } session.status = 'ready' const dispatch = dispatcher(handlers) requestMock = vi.fn(async (path: string, options?: RequestInit) => dispatch(path, options)) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }), request: requestMock, }) await router.push(url) wrapper = mount(MediaView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } 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(' ') } function cards() { return Array.from(document.body.querySelectorAll('[data-media-card]')) } function cardFor(filename: string) { return cards().find((card) => textOf(card).includes(filename))! } function tabs() { return Array.from(document.body.querySelectorAll('[role="tab"]')) } function tab(label: string) { return tabs().find((element) => textOf(element).startsWith(label))! } function findButton(label: string, root: ParentNode = document.body) { return Array.from(root.querySelectorAll('button')).find( (button) => textOf(button) === label, ) } function panel() { return document.body.querySelector('[role="dialog"]') } function dialog() { return document.body.querySelector('[role="alertdialog"]') } function listCalls() { return requestMock.mock.calls.filter( ([path]) => typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='), ) } function callsTo(method: string, pattern: RegExp) { return requestMock.mock.calls.filter( ([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path), ) } function lastListPath() { return listCalls().at(-1)![0] as string } function activate(element: Element) { element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })) element.dispatchEvent(new MouseEvent('click', { bubbles: true })) } async function clickCard(filename: string) { cardFor(filename).click() await flushPromises() } async function settleFocus() { await flushPromises() await new Promise((resolve) => setTimeout(resolve, 5)) await flushPromises() } function press(element: Element, key: string) { element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) } function setInput(input: HTMLInputElement, value: string) { input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) } function searchInput() { return document.body.querySelector('input[placeholder="Search by filename"]')! } function ariaLabel(label: string) { return document.body.querySelector(`[aria-label="${label}"]`)! } beforeEach(() => { toastAdd = vi.fn() vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) }) afterEach(async () => { wrapper?.unmount() wrapper = null vi.useRealTimers() vi.restoreAllMocks() await router.push('/login') document.body.innerHTML = '' }) describe('Default, many images', () => { it('shows the header, the tabs, the 12 cards in fixture order and the image count', async () => { await mountPage() expect(document.body.querySelector('nav[aria-label="breadcrumb"]')?.textContent).toContain( 'Media', ) expect(document.body.textContent).toContain( "Every image you've uploaded, ready to use in any article.", ) expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Media') expect(listCalls().map(([path]) => path)).toEqual([ '/v1/media/?filter=all&sort=newest&limit=100', ]) expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2']) expect(tab('All').getAttribute('aria-selected')).toBe('true') expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe( '2', ) expect(cards().map((card) => card.getAttribute('data-media-id'))).toEqual( fixture().map((item) => String(item.id)), ) expect(textOf(cards()[0])).toBe('north-wall.jpg 3.9 MB · 4032 × 3024 Used in 2') expect(textOf(cards()[3])).toBe('logo-renew.svg 8 KB · Vector Used in 5') expect(document.body.querySelector('[data-testid="image-count"]')?.textContent).toBe( '12 images', ) expect(document.body.textContent).toContain('Select all') expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(13) expect(searchInput()).toBeTruthy() expect(textOf(ariaLabel('Sort'))).toBe('Newest') expect(ariaLabel('Grid view').getAttribute('aria-pressed')).toBe('true') expect(ariaLabel('List view').getAttribute('aria-pressed')).toBe('false') }) it('renders the header Upload button enabled', async () => { await mountPage() expect(findButton('Upload')!.disabled).toBe(false) }) it('renders the mobile pinned Upload button enabled, full width, 48 px', async () => { await mountPage({}, '/media', MOBILE_WIDTH) const buttons = Array.from(document.body.querySelectorAll('button')).filter( (button) => textOf(button) === 'Upload', ) expect(buttons).toHaveLength(1) expect(buttons[0]!.disabled).toBe(false) expect(buttons[0]!.className).toContain('h-12') expect(buttons[0]!.className).toContain('inset-x-4') expect(document.body.querySelector('nav[aria-label="breadcrumb"]')?.className).toContain( 'hidden', ) }) }) describe('Badges and accessible names', () => { it.each([ ['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles', ['Used in 2']], ['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article', ['Used in 1']], ['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused', ['Unused']], [ 'floorboards.jpg', 'floorboards.jpg, 2.4 MB, used in 4 articles', ['Duplicate', 'Used in 4'], ], ['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 articles', ['Used in 5']], ])('card %s reads %s with badges %j', async (filename, name, badges) => { await mountPage() const card = cards().find( (element) => element.getAttribute('data-media-id') === String(fixture().find((item) => item.filename === filename)!.id), )! expect(card.tagName).toBe('BUTTON') expect(card.getAttribute('aria-label')).toBe(name) expect( Array.from(card.querySelectorAll('[data-kind]')).map((badge) => textOf(badge)), ).toEqual(badges) }) }) describe('Grid, few images', () => { it('keeps three cards at the grid size, a checkerboard behind the SVG and no Duplicates badge', async () => { await mountPage({ list: () => listBody(fixture([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }), }) expect(cards()).toHaveLength(3) expect(tabs().map(textOf)).toEqual(['All 3', 'Unused 1', 'Duplicates']) expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull() const thumbs = Array.from(document.body.querySelectorAll('[data-testid="media-thumb"]')) expect(thumbs[1]!.className).toContain('media-thumb-checker') expect(thumbs[0]!.className).not.toContain('media-thumb-checker') expect(document.body.querySelector('.grid.grid-cols-2')!.className).toContain( 'md:grid-cols-5', ) }) }) describe('List view', () => { it('is a table on desktop with 12 rows, the Used in cell and the Unused badge', async () => { await mountPage({}, '/media?view=list') const header = document.body.querySelector( '[data-testid="media-row"]', )!.previousElementSibling! expect(textOf(header)).toBe('Filename Dimensions Size Type Used in Uploaded by Uploaded') const rows = Array.from(document.body.querySelectorAll('[data-testid="media-row"]')) expect(rows).toHaveLength(12) expect(rows.map((row) => row.getAttribute('data-media-id'))).toEqual( fixture().map((item) => String(item.id)), ) expect(textOf(rows[0])).toBe( 'north-wall.jpg 4032 × 3024 3.9 MB JPEG 2 articles Marta Alves 12 Sep 2026', ) expect(textOf(rows[4])).toContain('Duplicate') expect(rows[0]!.querySelector('[data-testid="media-used-in"]')!.className).toContain( 'underline', ) expect(textOf(rows[7])).toContain('Unused') expect(textOf(rows[9])).toContain('Unused') expect(rows[7]!.querySelector('[data-testid="media-used-in"]')).toBeNull() expect(ariaLabel('List view').getAttribute('aria-pressed')).toBe('true') expect(document.body.querySelector('[data-testid="image-count"]')).toBeNull() }) it('switches to the list through the toggle and writes ?view=list', async () => { await mountPage() activate(ariaLabel('List view')) await flushPromises() expect(router.currentRoute.value.query).toEqual({ view: 'list' }) expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12) expect(listCalls()).toHaveLength(1) activate(ariaLabel('Grid view')) await flushPromises() expect(router.currentRoute.value.query).toEqual({}) }) it('is a card list on mobile and a tap opens the detail panel', async () => { await mountPage({}, '/media?view=list', MOBILE_WIDTH) const rows = Array.from(document.body.querySelectorAll('[data-testid="media-card-row"]')) expect(rows).toHaveLength(12) expect(textOf(rows[0])).toBe( 'north-wall.jpg 3.9 MB · 4032 × 3024 · JPEG Used in 2 Marta Alves · 12 Sep 2026', ) expect(rows[0]!.querySelector('svg, [class*="chevron-right"]')).toBeTruthy() rows[0]!.querySelector('button')!.click() await flushPromises() expect(panel()).not.toBeNull() expect(textOf(panel())).toContain('north-wall.jpg') }) it('opens the detail panel from the Used in count', async () => { await mountPage({}, '/media?view=list') const row = document.body.querySelector('[data-testid="media-row"]')! row.querySelector('[data-testid="media-used-in"]')!.click() await flushPromises() expect(textOf(panel())).toContain('Used in 2 articles') expect(textOf(panel())).toContain('How we price a bathroom renovation') }) }) describe('The query string is the source of truth', () => { it.each([ ['?filter=unused', 'filter=unused&sort=newest&limit=100'], ['?sort=largest', 'filter=all&sort=largest&limit=100'], ['?sort=name', 'filter=all&sort=name&limit=100'], ['?sort=oldest', 'filter=all&sort=oldest&limit=100'], ['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'], ['?view=list', 'filter=all&sort=newest&limit=100'], ['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'], ['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'], ])('opening /media%s requests %s', async (url, request) => { await mountPage({}, `/media${url}`) expect(lastListPath()).toBe(`/v1/media/?${request}`) }) it('reflects each control', async () => { await mountPage({}, '/media?filter=unused') expect(tab('Unused').getAttribute('aria-selected')).toBe('true') wrapper?.unmount() document.body.innerHTML = '' await mountPage({}, '/media?sort=largest') expect(textOf(ariaLabel('Sort'))).toBe('Largest') wrapper?.unmount() document.body.innerHTML = '' await mountPage({}, '/media?q=wall') expect(searchInput().value).toBe('wall') wrapper?.unmount() document.body.innerHTML = '' await mountPage({}, '/media?filter=duplicates') expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true') }) it('drops unknown values from the URL', async () => { await mountPage({}, '/media?filter=bogus&sort=zzz&view=grid') expect(router.currentRoute.value.query).toEqual({}) expect(tab('All').getAttribute('aria-selected')).toBe('true') expect(textOf(ariaLabel('Sort'))).toBe('Newest') }) it('writes the URL and reloads the list when a control changes', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await mountPage() setInput(searchInput(), 'wall') await flushPromises() vi.advanceTimersByTime(119) await flushPromises() expect(listCalls()).toHaveLength(1) vi.advanceTimersByTime(1) await flushPromises() expect(router.currentRoute.value.query).toEqual({ q: 'wall' }) expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&limit=100') tab('Unused').focus() press(tab('Unused'), 'Enter') await flushPromises() expect(router.currentRoute.value.query).toEqual({ q: 'wall', filter: 'unused' }) expect(listCalls()).toHaveLength(3) ariaLabel('Sort').dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) ariaLabel('Sort').click() await flushPromises() const largest = Array.from( document.body.querySelectorAll('[role="menuitemcheckbox"]'), ).find((item) => textOf(item) === 'Largest')! activate(largest) await flushPromises() expect(router.currentRoute.value.query).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', }) expect(listCalls()).toHaveLength(4) tab('All').focus() press(tab('All'), 'Enter') await flushPromises() expect(router.currentRoute.value.query).toEqual({ q: 'wall', sort: 'largest' }) }) it('removes q from the URL when the search is emptied', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await mountPage({}, '/media?q=wall') setInput(searchInput(), '') await flushPromises() vi.advanceTimersByTime(120) await flushPromises() expect(router.currentRoute.value.query).toEqual({}) }) }) describe('The Duplicates tab', () => { it('becomes selected, writes the filter to the URL and requests the duplicates', async () => { await mountPage() const duplicates = tab('Duplicates') expect(duplicates.hasAttribute('disabled')).toBe(false) duplicates.focus() press(duplicates, 'Enter') duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) await flushPromises() expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true') expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' }) expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100') }) }) describe('Loading', () => { it('draws no cards for 150 ms, then 10 skeleton cards with aria-busy, then the images', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const pending = deferred() await mountPage({ list: () => pending.promise }) vi.advanceTimersByTime(149) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(document.body.querySelector('[data-testid="media-skeleton-card"]')).toBeNull() expect(cards()).toHaveLength(0) vi.advanceTimersByTime(1) await flushPromises() expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength( 10, ) expect( document.body .querySelector('[data-testid="media-skeleton"]')! .getAttribute('aria-busy'), ).toBe('true') expect(searchInput()).toBeTruthy() expect(cards()).toHaveLength(0) pending.resolve(listBody(fixture())) await flushPromises() expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(cards()).toHaveLength(12) }) it('draws 6 skeleton cards on mobile', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await mountPage({ list: () => deferred().promise }, '/media', MOBILE_WIDTH) vi.advanceTimersByTime(150) await flushPromises() expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength( 6, ) }) it('shows the skeleton in list view and on a change of tab', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const second = deferred() let calls = 0 await mountPage( { list: () => (++calls === 1 ? listBody(fixture()) : second.promise) }, '/media?view=list', ) expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12) tab('Unused').focus() press(tab('Unused'), 'Enter') await flushPromises() vi.advanceTimersByTime(150) await flushPromises() expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength( 10, ) expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(0) }) }) describe('Empty library and no results', () => { it('shows one call to action and hides the toolbar and tabs', async () => { await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) }) expect(tabs()).toHaveLength(0) expect(searchInput()).toBeNull() expect(document.body.textContent).toContain('No images yet') expect(document.body.textContent).toContain( 'Upload your first image. You can use it in any article.', ) expect(findButton('Upload an image')!.disabled).toBe(false) expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB each') }) it('shows the no results panel and Clear filters restores the cards', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await mountPage({ list: (path) => path.includes('q=wall') ? listBody([], { all: 12, unused: 2, duplicates: 2 }) : listBody(fixture()), }) tab('Unused').focus() press(tab('Unused'), 'Enter') await flushPromises() setInput(searchInput(), 'wall') await flushPromises() vi.advanceTimersByTime(120) await flushPromises() expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&limit=100') expect(document.body.textContent).toContain('No images match "wall"') expect(document.body.textContent).toContain( 'Nothing matches in Unused. Check the spelling, or clear the filters.', ) expect(cards()).toHaveLength(0) findButton('Clear filters')!.click() await flushPromises() vi.advanceTimersByTime(120) await flushPromises() expect(searchInput().value).toBe('') expect(tab('All').getAttribute('aria-selected')).toBe('true') expect(router.currentRoute.value.query).toEqual({}) expect(cards()).toHaveLength(12) }) }) describe('Hover, focus and keyboard', () => { it('gives every card the hover and focus classes and makes it one button', async () => { await mountPage() for (const card of cards()) { expect(card.tagName).toBe('BUTTON') expect(card.className).toContain('hover:border-accented') expect(card.className).toContain('hover:shadow-(--shadow-popover)') expect(card.className).toContain('focus-visible:outline-primary') expect(card.className).toContain('focus-visible:outline-offset-2') } }) it('moves focus with the arrow keys and stops at the edges', async () => { await mountPage() const list = cards() list[2]!.focus() press(list[2]!, 'ArrowRight') expect(document.activeElement).toBe(list[3]) press(list[3]!, 'ArrowLeft') expect(document.activeElement).toBe(list[2]) press(list[2]!, 'ArrowDown') expect(document.activeElement).toBe(list[7]) press(list[7]!, 'ArrowUp') expect(document.activeElement).toBe(list[2]) press(list[2]!, 'ArrowUp') expect(document.activeElement).toBe(list[2]) list[0]!.focus() press(list[0]!, 'ArrowLeft') expect(document.activeElement).toBe(list[0]) list[11]!.focus() press(list[11]!, 'ArrowRight') press(list[11]!, 'ArrowDown') expect(document.activeElement).toBe(list[11]) }) it('moves by two per row on mobile', async () => { await mountPage({}, '/media', MOBILE_WIDTH) const list = cards() list[0]!.focus() press(list[0]!, 'ArrowDown') expect(document.activeElement).toBe(list[2]) }) it('opens the panel on Enter and Space does not open it', async () => { await mountPage() cards()[1]!.focus() press(cards()[1]!, ' ') cards()[1]!.dispatchEvent( new KeyboardEvent('keyup', { key: ' ', bubbles: true, cancelable: true }), ) await flushPromises() expect(panel()).toBeNull() expect(requestMock.mock.calls.filter(([path]) => /usage/.test(path))).toHaveLength(0) press(cards()[1]!, 'Enter') await flushPromises() expect(panel()).not.toBeNull() expect(textOf(panel())).toContain('kitchen-after.webp') }) it('closes the panel with Escape and returns focus to the card', async () => { await mountPage() cards()[0]!.focus() press(cards()[0]!, 'Enter') await flushPromises() expect(panel()).not.toBeNull() panel()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), ) await settleFocus() expect(panel()).toBeNull() expect(document.activeElement).toBe(cardFor('north-wall.jpg')) }) }) describe('Detail panel', () => { it('in use: shows the preview, actions, metadata and the article rows', async () => { await mountPage() await clickCard('north-wall.jpg') expect(callsTo('GET', /\/usage$/)).toHaveLength(1) expect(callsTo('GET', /\/v1\/media\/1\/usage$/)).toHaveLength(1) const content = panel()! expect(textOf(content.querySelector('[data-slot="title"]'))).toBe('north-wall.jpg') expect(content.querySelector('[data-testid="media-thumb"]')!.className).toContain( 'md:aspect-16/10', ) expect( ['Copy URL', 'Rename', 'Delete'].map((label) => findButton(label, content)!.tagName), ).toEqual(['BUTTON', 'BUTTON', 'BUTTON']) expect(findButton('Delete', content)!.className).toContain('text-error') const terms = Array.from(content.querySelectorAll('dt')).map(textOf) expect(terms).toEqual(['Type', 'Size', 'Dimensions', 'Uploaded', 'Uploaded by', 'URL']) expect(Array.from(content.querySelectorAll('dd')).map(textOf)).toEqual([ 'JPEG', '3.9 MB', '4032 × 3024', '12 Sep 2026', 'Marta Alves', `${window.location.host}/media/north-wall.jpg`, ]) expect(content.querySelector('dd.font-mono')).toBeTruthy() expect(textOf(content.querySelector('h3'))).toBe('Used in 2 articles') const rows = Array.from(content.querySelectorAll('[data-testid="usage-row"]')) expect(rows.map(textOf)).toEqual([ 'How we price a bathroom renovation Online', 'Winter checklist for timber windows Draft', ]) expect(rows.map((row) => row.querySelector('a')!.getAttribute('href'))).toEqual([ '/article/10/edit', '/article/11/edit', ]) expect(rows[0]!.querySelector('a')!.className).toContain('underline') expect(rows[0]!.querySelector('[data-kind="online"]')).toBeTruthy() expect(rows[1]!.querySelector('[data-kind="draft"]')).toBeTruthy() expect(textOf(content)).toContain( 'Counted from the text of your articles each time you open this page.', ) expect(content.querySelector('[data-testid="duplicate-alert"]')).toBeNull() }) it('shows the heading before the usage rows have loaded', async () => { const pending = deferred() await mountPage({ usage: () => pending.promise }) await clickCard('north-wall.jpg') expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0) pending.resolve(USAGE) await flushPromises() expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(2) }) it('shows no rows when the usage request fails', async () => { await mountPage({ usage: () => Promise.reject(new ApiError(500)) }) await clickCard('north-wall.jpg') expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0) expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') }) it('unused: sends no usage request and says it is safe to delete', async () => { await mountPage() await clickCard('window-frame.jpg') expect(callsTo('GET', /\/usage$/)).toHaveLength(0) expect(textOf(panel()!.querySelector('h3'))).toBe('Not used in any article') expect(textOf(panel())).toContain('This image is safe to delete. No article shows it.') }) it('duplicate: looks up the hash, excludes the image itself and Review duplicates opens the tab', async () => { await mountPage() await clickCard('floorboards.jpg') expect(requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e')).toBe(true) const alert = panel()!.querySelector('[data-testid="duplicate-alert"]')! expect(textOf(alert)).toContain('Identical to 2 other images') expect(textOf(alert)).toContain( 'floorboards-copy.jpg and IMG_2208.jpg have the same content.', ) const review = findButton('Review duplicates', alert)! expect(review.disabled).toBe(false) review.click() await flushPromises() expect(router.currentRoute.value.path).toBe('/media') expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' }) expect(panel()).toBeNull() }) it('duplicate: uses the singular for one other copy', async () => { await mountPage({ hash: () => listBody(fixture([12, 5])) }) await clickCard('floorboards.jpg') const alert = panel()!.querySelector('[data-testid="duplicate-alert"]')! expect(textOf(alert)).toContain('Identical to 1 other image') expect(textOf(alert)).toContain('floorboards-copy.jpg has the same content.') }) it('shows the back arrow and a full-width layout on mobile', async () => { await mountPage({}, '/media', MOBILE_WIDTH) await clickCard('north-wall.jpg') expect(panel()!.className).toContain('max-w-none') expect(panel()!.querySelector('[data-testid="media-thumb"]')!.className).toContain( 'aspect-4/3', ) expect(panel()!.querySelector('[data-slot="close"] svg')).toBeTruthy() }) }) describe('Copy URL', () => { it('writes the absolute address to the clipboard and toasts', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) await mountPage() await clickCard('north-wall.jpg') findButton('Copy URL', panel()!)!.click() await flushPromises() expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/media/north-wall.jpg`) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ id: 'media-url-copied-1', icon: 'i-lucide-circle-check', title: 'URL copied', description: 'Paste it wherever you need the image address.', duration: 5000, }), ) }) }) describe('Rename', () => { async function openRename(filename = 'north-wall.jpg') { await clickCard(filename) findButton('Rename', panel()!)!.click() await flushPromises() } function stemInput() { return dialog()!.querySelector('input')! } async function type(value: string) { setInput(stemInput(), value) await flushPromises() } it('opens the dialog with the stem, the fixed extension and the help', async () => { await mountPage() await openRename() const content = dialog()! expect(textOf(content.querySelector('[data-slot="title"]'))).toBe('Rename image') expect(textOf(content.querySelector('[data-slot="description"]'))).toBe( 'Only the filename changes. The URL stays the same, so articles that use this image keep working.', ) expect(textOf(content.querySelector('label'))).toBe('Filename') expect(stemInput().value).toBe('north-wall') expect(content.querySelector('span.font-mono')!.textContent).toBe('.jpg') expect(textOf(content)).toContain( 'Letters, numbers, dashes and underscores. The .jpg ending stays.', ) const buttons = Array.from(content.querySelectorAll('button')).map(textOf).filter(Boolean) expect(buttons).toEqual(['Cancel', 'Rename']) }) it.each(['', '---', '!!!'])( 'shows "Enter a filename." for %j on submit and sends nothing', async (value) => { await mountPage() await openRename() await type(value) findButton('Rename', dialog()!)!.click() await flushPromises() expect(textOf(dialog())).toContain('Enter a filename.') expect(textOf(dialog())).not.toContain('Letters, numbers') expect(dialog()!.querySelector('[data-slot="error"] svg')).toBeTruthy() expect(callsTo('PATCH', /.*/)).toHaveLength(0) expect(dialog()).not.toBeNull() }, ) it('shows the error when the field is left', async () => { await mountPage() await openRename() await type('!!!') stemInput().dispatchEvent(new Event('blur', { bubbles: true })) await flushPromises() expect(textOf(dialog())).toContain('Enter a filename.') expect(callsTo('PATCH', /.*/)).toHaveLength(0) }) it('sends the stem only and locks the dialog while saving', async () => { const pending = deferred() await mountPage({ rename: () => pending.promise }) await openRename() await type('north-wall-01') findButton('Rename', dialog()!)!.click() await flushPromises() const [path, options] = callsTo('PATCH', /.*/)[0]! expect(path).toBe('/v1/media/1') expect(JSON.parse(options.body)).toEqual({ filename: 'north-wall-01' }) expect(stemInput().disabled).toBe(true) expect(findButton('Cancel', dialog()!)!.disabled).toBe(true) const confirm = findButton('Renaming…', dialog()!)! expect(confirm.disabled).toBe(true) expect(confirm.querySelector('svg')).toBeTruthy() dialog()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), ) await flushPromises() expect(dialog()).not.toBeNull() findButton('Cancel', dialog()!)!.click() await flushPromises() expect(dialog()).not.toBeNull() pending.resolve({ ...fixture([1])[0], filename: 'north-wall-01.jpg' }) await flushPromises() }) it.each([ ['a 500 response', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ['a 400 response', () => Promise.reject(new ApiError(400))], ])('keeps the dialog and the text after %s', async (_name, failure) => { await mountPage({ rename: failure }) await openRename() await type('north-wall-01') findButton('Rename', dialog()!)!.click() await flushPromises() expect(dialog()).not.toBeNull() expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe( "Couldn't rename the image. Your text is still here. Try again in a moment.", ) expect(stemInput().value).toBe('north-wall-01') expect(findButton('Cancel', dialog()!)!.disabled).toBe(false) expect(findButton('Rename', dialog()!)!.disabled).toBe(false) }) it('updates the panel and the card in place and toasts on success', async () => { await mountPage() await openRename() await type('north-wall-01') findButton('Rename', dialog()!)!.click() await flushPromises() expect(dialog()).toBeNull() expect(textOf(panel()!.querySelector('[data-slot="title"]'))).toBe('north-wall-01.jpg') expect(cards()[0]!.getAttribute('data-media-id')).toBe('1') expect(textOf(cards()[0])).toContain('north-wall-01.jpg') expect(cards().map((card) => card.getAttribute('data-media-id'))).toEqual( fixture().map((item) => String(item.id)), ) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ id: 'media-renamed-1', icon: 'i-lucide-circle-check', title: 'Image renamed', description: 'It\'s now "north-wall-01.jpg". The URL is the same.', duration: 5000, }), ) expect(textOf(panel())).toContain('/media/north-wall.jpg') }) }) describe('Delete', () => { async function openDelete(filename: string) { await clickCard(filename) findButton('Delete', panel()!)!.click() await flushPromises() } it('unused: asks a plain question with no body', async () => { await mountPage() await openDelete('window-frame.jpg') const content = dialog()! expect(textOf(content.querySelector('[data-slot="title"]'))).toBe('Delete this image?') expect(textOf(content.querySelector('[data-slot="description"]'))).toBe( '"window-frame.jpg" will be deleted from your library. No article uses it. This can\'t be undone.', ) expect(content.querySelectorAll('[data-testid="affected-article"]')).toHaveLength(0) expect(Array.from(content.querySelectorAll('button')).map(textOf).filter(Boolean)).toEqual([ 'Cancel', 'Delete image', ]) }) it('in use: lists the affected articles with their status badges', async () => { await mountPage() await openDelete('north-wall.jpg') const content = dialog()! expect(textOf(content.querySelector('[data-slot="title"]'))).toBe( "Delete an image that's in use?", ) expect(textOf(content.querySelector('[data-slot="description"]'))).toBe( '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', ) expect( Array.from(content.querySelectorAll('[data-testid="affected-article"]')).map(textOf), ).toEqual([ 'How we price a bathroom renovation Online', 'Winter checklist for timber windows Draft', ]) expect( Array.from( content.querySelectorAll('[data-testid="affected-article"] [data-kind]'), ).map((badge) => badge.getAttribute('data-kind')), ).toEqual(['online', 'draft']) }) it('in progress: sends DELETE and locks the dialog', async () => { const pending = deferred() await mountPage({ remove: () => pending.promise }) await openDelete('window-frame.jpg') findButton('Delete image', dialog()!)!.click() await flushPromises() expect(callsTo('DELETE', /^\/v1\/media\/8$/)).toHaveLength(1) expect(findButton('Cancel', dialog()!)!.disabled).toBe(true) const confirm = findButton('Deleting…', dialog()!)! expect(confirm.disabled).toBe(true) expect(dialog()!.getAttribute('aria-busy')).toBe('true') dialog()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), ) await flushPromises() expect(dialog()).not.toBeNull() pending.resolve(undefined) await flushPromises() }) it.each([ ['a 500 response', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ])('failed after %s: the dialog stays and the card is untouched', async (_name, failure) => { await mountPage({ remove: failure }) await openDelete('window-frame.jpg') findButton('Delete image', dialog()!)!.click() await flushPromises() expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe( "Couldn't delete the image, so it's still there. Try again in a moment.", ) expect(findButton('Cancel', dialog()!)!.disabled).toBe(false) expect(findButton('Delete image', dialog()!)!.disabled).toBe(false) expect(cards()).toHaveLength(12) }) it('done: removes the card, closes both layers, moves focus on, toasts and refreshes silently', async () => { let calls = 0 await mountPage({ list: () => ++calls === 1 ? listBody(fixture()) : listBody( fixture().filter((item) => item.id !== 8), { all: 11, unused: 1, duplicates: 2, }, ), }) await openDelete('window-frame.jpg') findButton('Delete image', dialog()!)!.click() await settleFocus() expect(dialog()).toBeNull() expect(panel()).toBeNull() expect(cards()).toHaveLength(11) expect(cards().some((card) => textOf(card).includes('window-frame.jpg'))).toBe(false) expect(document.activeElement).toBe(cardFor('stone-wall-detail.jpg')) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ id: 'media-deleted-8', icon: 'i-lucide-circle-check', title: 'Image deleted', description: '"window-frame.jpg" is gone from your library.', duration: 5000, }), ) expect(listCalls()).toHaveLength(2) expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(tabs().map(textOf)).toEqual(['All 11', 'Unused 1', 'Duplicates 2']) }) it('done on the last card: focus moves to the page heading', async () => { await mountPage({ list: listWithout(12) }) await openDelete('floorboards.jpg') findButton('Delete image', dialog()!)!.click() await settleFocus() expect(cards()).toHaveLength(11) expect(document.activeElement).toBe( document.body.querySelector('[data-testid="media-heading"]'), ) }) }) describe('Image already gone', () => { const gone = () => Promise.reject(new ApiError(404)) function expectGone() { expect(panel()).toBeNull() expect(dialog()).toBeNull() expect(cards().some((card) => textOf(card).includes('window-frame.jpg'))).toBe(false) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ id: 'media-not-found-8', color: 'info', icon: 'i-lucide-info', title: 'That image no longer exists', description: 'It was deleted somewhere else, so we removed it from the library.', }), ) expect(listCalls()).toHaveLength(2) } it('DELETE 404 closes everything and removes the image', async () => { await mountPage({ remove: gone, list: listWithout(8) }) await clickCard('window-frame.jpg') findButton('Delete', panel()!)!.click() await flushPromises() findButton('Delete image', dialog()!)!.click() await settleFocus() expectGone() }) it('PATCH 404 closes everything and removes the image', async () => { await mountPage({ rename: gone, list: listWithout(8) }) await clickCard('window-frame.jpg') findButton('Rename', panel()!)!.click() await flushPromises() setInput(dialog()!.querySelector('input')!, 'window-frame-2') await flushPromises() findButton('Rename', dialog()!)!.click() await settleFocus() expectGone() }) it('usage 404 on open closes the panel and removes the image', async () => { await mountPage({ usage: gone, list: listWithout(2) }, '/media') const used = cardFor('kitchen-after.webp') used.click() await settleFocus() expect(panel()).toBeNull() expect(cards().some((card) => textOf(card).includes('kitchen-after.webp'))).toBe(false) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ id: 'media-not-found-2', color: 'info' }), ) }) }) describe('List failed to load', () => { it.each([ ['a 500 response', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ])('shows the error alert after %s and Try again recovers', async (_name, failure) => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) let calls = 0 await mountPage({ list: () => (++calls === 1 ? failure() : listBody(fixture())) }) const alert = document.body.querySelector('[role="alert"]')! expect(textOf(alert)).toBe( "Couldn't load your images Check your connection and try again. Try again", ) expect(searchInput()).toBeTruthy() expect(cards()).toHaveLength(0) findButton('Try again')!.click() await flushPromises() vi.advanceTimersByTime(0) expect(document.body.querySelector('[role="alert"]')).toBeNull() expect(listCalls()).toHaveLength(2) expect(cards()).toHaveLength(12) }) it('shows the skeleton when Try again is pending', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const retry = deferred() let calls = 0 await mountPage({ list: () => (++calls === 1 ? Promise.reject(new ApiError(500)) : retry.promise), }) findButton('Try again')!.click() await flushPromises() vi.advanceTimersByTime(150) await flushPromises() expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength( 10, ) retry.resolve(listBody(fixture())) await flushPromises() expect(cards()).toHaveLength(12) }) }) describe('Offline', () => { it('shows the media banner and disables Upload, Rename and Delete, then restores them', async () => { await mountPage() await clickCard('north-wall.jpg') expect(findButton('Rename', panel()!)!.disabled).toBe(false) vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) window.dispatchEvent(new Event('offline')) await flushPromises() expect(document.body.textContent).toContain( "You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.", ) expect(document.body.textContent).not.toContain('You can keep writing') expect(findButton('Upload')!.disabled).toBe(true) expect(findButton('Rename', panel()!)!.disabled).toBe(true) expect(findButton('Delete', panel()!)!.disabled).toBe(true) expect(findButton('Copy URL', panel()!)!.disabled).toBe(false) expect(cards()).toHaveLength(12) vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) window.dispatchEvent(new Event('online')) await flushPromises() expect(document.body.textContent).not.toContain("You're offline") expect(findButton('Rename', panel()!)!.disabled).toBe(false) expect(findButton('Delete', panel()!)!.disabled).toBe(false) }) }) describe('Session errors are global', () => { it.each([401, 403])('a %i from the list shows no local media copy', async (status) => { await mountPage({ list: () => Promise.reject(new ApiError(status)) }) expect(document.body.querySelector('[role="alert"]')).toBeNull() expect(cards()).toHaveLength(0) expect(document.body.textContent).not.toContain('No images yet') }) }) describe('Sidebar', () => { it('lists Media between Articles and Users with the images icon, active on /media', async () => { await mountPage() const links = Array.from( document.body.querySelectorAll('nav[aria-label="Main"] a'), ) expect(links.map((link) => link.getAttribute('href'))).toEqual([ '/home', '/article', '/media', '/users', '/integration', '/settings', ]) const media = links[2]! expect(media.getAttribute('aria-current')).toBe('page') expect(media.querySelector('svg.iconify--lucide')).toBeTruthy() }) })