1518 lines
46 KiB
TypeScript
1518 lines
46 KiB
TypeScript
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()
|
||
})
|
||
})
|