Files
RenewCMS/web/src/views/__tests__/MediaView.spec.ts
T

1518 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
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, total: items.length, page: 1, size: 25, 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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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<typeof vi.fn>
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<HTMLElement>('[data-media-card]'))
}
function cardFor(filename: string) {
return cards().find((card) => textOf(card).includes(filename))!
}
function tabs() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[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<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
function panel() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
function dialog() {
return document.body.querySelector<HTMLElement>('[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<HTMLInputElement>('input[placeholder="Search by filename"]')!
}
function ariaLabel(label: string) {
return document.body.querySelector<HTMLElement>(`[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&page=1&size=25',
])
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(2)
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
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<HTMLElement>('[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&page=1&size=25'],
['?sort=largest', 'filter=all&sort=largest&page=1&size=25'],
['?sort=name', 'filter=all&sort=name&page=1&size=25'],
['?sort=oldest', 'filter=all&sort=oldest&page=1&size=25'],
['?q=wall', 'q=wall&filter=all&sort=newest&page=1&size=25'],
['?view=list', 'filter=all&sort=newest&page=1&size=25'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&page=1&size=25'],
['?filter=duplicates', 'filter=duplicates&sort=newest&page=1&size=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&page=1&size=25')
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&page=1&size=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<unknown>()
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<unknown>().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<unknown>()
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&page=1&size=25')
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<unknown>()
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&page=1&size=100'),
).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<HTMLInputElement>('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<unknown>()
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<unknown>()
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<unknown>()
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<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
const media = links[2]!
expect(media.getAttribute('aria-current')).toBe('page')
expect(media.querySelector('svg.iconify--lucide')).toBeTruthy()
})
})