feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

@@ -0,0 +1,443 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
announcer,
callsTo,
deferred,
dialog,
findButton,
fixture,
lastListPath,
listBody,
listCalls,
mountPage,
panel,
press,
settle,
tab,
tabs,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
const DUPLICATE_COUNTS = { all: 12, unused: 2, duplicates: 2 }
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
return (path) =>
path.includes('filter=duplicates')
? listBody(fixture(ids), DUPLICATE_COUNTS)
: listBody(fixture())
}
function groups() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="duplicate-group"]'),
)
}
function copiesOf(group: HTMLElement) {
return Array.from(group.querySelectorAll<HTMLElement>('[data-testid="duplicate-copy"]'))
}
function mergeButton(filename: string) {
const copy = copiesOf(groups()[0]!)
.concat(copiesOf(groups()[1] ?? groups()[0]!))
.find((entry) => textOf(entry).includes(filename))!
return findButton('Merge into this one', copy)!
}
async function openMerge(filename: string, handlers: Handlers = {}) {
await mountPage({ list: duplicatesList(), ...handlers }, '/media?filter=duplicates')
mergeButton(filename).click()
await flushPromises()
}
beforeEach(() => {
toasts.add.mockClear()
toasts.remove.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('The Duplicates tab', () => {
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
await mountPage({ list: duplicatesList() })
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
const duplicates = tab('Duplicates 2')
duplicates.focus()
press(duplicates, 'Enter')
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await settle()
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
'2',
)
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
expect(document.body.querySelector('[aria-label="Grid view"]')).not.toBeNull()
expect(
document.body.querySelector('input[placeholder="Search by filename"]'),
).not.toBeNull()
})
it('opens from a deep link on the same view', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(groups()).toHaveLength(2)
})
it('applies the search to the duplicates request', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by filename"]',
)!
input.value = 'floor'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
vi.advanceTimersByTime(120)
await flushPromises()
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
})
})
describe('Duplicates, groups', () => {
it('groups the images by content into bordered cards in the suggested order', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
expect(groups()).toHaveLength(2)
const [first, second] = groups()
expect(textOf(first!.querySelector('header'))).toBe(
'Group 1 3 identical images · 2.4 MB each · 3600 × 2400',
)
expect(textOf(second!.querySelector('header'))).toBe(
'Group 2 2 identical images · 3.1 MB each · 3600 × 2400',
)
const names = (group: HTMLElement) =>
copiesOf(group).map((copy) => textOf(copy).split(' ')[0])
expect(names(first!)).toEqual(['floorboards.jpg', 'floorboards-copy.jpg', 'IMG_2208.jpg'])
expect(names(second!)).toEqual(['IMG_4471.jpg', 'stone-wall-detail.jpg'])
expect(first!.className).toContain('border')
expect(first!.parentElement!.className).toContain('gap-4')
expect(textOf(first!.querySelector('footer'))).toBe(
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.',
)
})
it('shows each copy with its uploader, use, merge button and the suggested styling', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
const [suggested, other] = copiesOf(groups()[0]!)
expect(textOf(suggested)).toContain('Uploaded 14 Jun 2026 by Inès Duarte')
expect(textOf(suggested)).toContain('Used in 4 articles')
expect(textOf(suggested)).toContain('Suggested')
expect(suggested!.hasAttribute('data-suggested')).toBe(true)
expect(suggested!.className).toContain('bg-secondary-200')
expect(suggested!.className).toContain('md:border-primary')
expect(findButton('Merge into this one', suggested)!.className).toContain('bg-primary')
expect(textOf(other)).not.toContain('Suggested')
expect(other!.hasAttribute('data-suggested')).toBe(false)
expect(findButton('Merge into this one', other)!.className).toContain('border-accented')
await vi.waitFor(() =>
expect(
findButton('Merge into this one', suggested)!.querySelector('svg.iconify--lucide'),
).toBeTruthy(),
)
})
it('opens the detail panel from the underlined "Used in" count', async () => {
await mountPage(
{
list: duplicatesList(),
usage: () => [{ id: 10, title: 'How we price', published: true }],
},
'/media?filter=duplicates',
)
copiesOf(groups()[0]!)[0]!
.querySelector<HTMLButtonElement>('[data-testid="duplicate-used-in"]')!
.click()
await flushPromises()
expect(panel()).not.toBeNull()
expect(textOf(panel())).toContain('floorboards.jpg')
})
it('stacks the columns below md', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates', MOBILE_WIDTH)
const grid = copiesOf(groups()[0]!)[0]!.parentElement!
expect(grid.className).toContain('md:grid-cols-(--columns)')
expect(grid.className).not.toMatch(/(^| )grid-cols-/)
})
it('reads the vector size and omits "by" when the uploader is unknown', async () => {
const svg = (id: number, createdAt: string): MediaItem => ({
...fixture([4])[0]!,
id,
filename: `mark-${id}.svg`,
sha256: 'svg',
uploaded_by: '',
created_at: createdAt,
used_in: 0,
duplicate: true,
})
await mountPage(
{
list: () =>
listBody(
[svg(20, '2026-09-01T12:00:00Z'), svg(21, '2026-09-02T12:00:00Z')],
DUPLICATE_COUNTS,
),
},
'/media?filter=duplicates',
)
expect(textOf(groups()[0]!.querySelector('header'))).toBe(
'Group 1 2 identical images · 8 KB each · Vector',
)
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('Uploaded 1 Sep 2026')
expect(textOf(copiesOf(groups()[0]!)[0])).not.toContain(' by ')
})
})
describe('Duplicates, empty', () => {
const noDuplicates = () => listBody([], { all: 12, unused: 2, duplicates: 0 })
it('has no count badge and shows the panel, and Back to all images returns to All', async () => {
await mountPage(
{
list: (path) =>
path.includes('filter=duplicates') ? noDuplicates() : listBody(fixture()),
},
'/media?filter=duplicates',
)
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
expect(textOf(tab('Duplicates'))).toBe('Duplicates')
expect(document.body.textContent).toContain('No duplicates')
expect(document.body.textContent).toContain('Every image in your library is different.')
findButton('Back to all images')!.click()
await settle()
expect(router.currentRoute.value.query).toEqual({})
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(document.body.querySelectorAll('[data-media-card]')).toHaveLength(12)
})
})
describe('Merge', () => {
it('confirms for the suggested copy with the kept image and the article count', async () => {
await openMerge('floorboards.jpg')
const box = dialog()!
expect(textOf(box)).toContain('Merge into floorboards.jpg?')
expect(textOf(box)).toContain(
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.",
)
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now')
expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([
'Cancel',
'Merge 2 copies',
])
expect(callsTo('POST', /merge/)).toHaveLength(0)
})
it('keeps another copy and sums the use of the others', async () => {
await openMerge('IMG_2208.jpg')
expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?')
expect(textOf(dialog())).toContain('7 articles will be updated.')
})
it('uses the singular for a group of two copies', async () => {
await openMerge('IMG_4471.jpg')
expect(textOf(dialog())).toContain('Articles that use the other copy will be switched to')
expect(textOf(dialog())).toContain('the other copy will be deleted.')
expect(findButton('Merge 1 copy', dialog()!)).toBeTruthy()
})
it('sends the ids and locks the dialog while the request is in flight', async () => {
const pending = deferred<unknown>()
await openMerge('floorboards.jpg', { merge: () => pending.promise })
findButton('Merge 2 copies', dialog()!)!.click()
await flushPromises()
const [call] = callsTo('POST', /merge/)
expect(call![0]).toBe('/v1/media/merge')
expect(JSON.parse(call![1].body)).toEqual({ keep_id: 12, merge_ids: [5, 6] })
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
const confirm = findButton('Merging…', dialog()!)!
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(dialog()).not.toBeNull()
pending.resolve({ articles_updated: 5, merged: 2 })
await settle()
})
it('done: the group leaves, a toast and the live region say so, and the list reloads silently', async () => {
let merged = false
await openMerge('floorboards.jpg', {
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(merged ? [7, 9] : DUPLICATE_IDS), {
all: merged ? 10 : 12,
unused: 2,
duplicates: merged ? 1 : 2,
})
: listBody(fixture()),
merge: () => {
merged = true
return { articles_updated: 5, merged: 2 }
},
})
const reloads = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(groups()).toHaveLength(1)
expect(textOf(groups()[0]!.querySelector('header'))).toContain('Group 1')
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('IMG_4471.jpg')
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'media-merged-12',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Copies merged',
description: '2 copies merged. 5 articles updated.',
duration: 5000,
}),
)
expect(announcer()).toBe('2 copies merged. 5 articles updated.')
expect(listCalls().length).toBe(reloads + 1)
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
0,
)
})
it('done with the last group shows "No duplicates" and drops the count badge', async () => {
let merged = false
await openMerge('IMG_4471.jpg', {
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(merged ? [] : [7, 9]), {
all: merged ? 10 : 12,
unused: 2,
duplicates: merged ? 0 : 1,
})
: listBody(fixture()),
merge: () => {
merged = true
return { articles_updated: 1, merged: 1 }
},
})
findButton('Merge 1 copy', dialog()!)!.click()
await settle()
expect(document.body.textContent).toContain('No duplicates')
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
expect(announcer()).toBe('1 copy merged. 1 article updated.')
})
it.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])(
'%s keeps the dialog open with the error alert and enables both buttons',
async (_name, merge) => {
await openMerge('floorboards.jpg', { merge })
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
const box = dialog()!
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
"Couldn't merge the copies. Nothing was changed. Try again in a moment.",
)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(findButton('Merge 2 copies', box)!.disabled).toBe(false)
expect(groups()).toHaveLength(2)
},
)
it('a 409 keeps the dialog open with the conflict alert and reloads the list', async () => {
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(409)) })
const before = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
)
expect(listCalls().length).toBe(before + 1)
})
it('a 404 closes the dialog, shows the info toast and reloads the list', async () => {
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(404)) })
const before = listCalls().length
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'media-not-found-12',
color: 'info',
title: 'That image no longer exists',
}),
)
expect(listCalls().length).toBe(before + 1)
})
it('disables every Merge into this one while offline', async () => {
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === 'Merge into this one',
)
expect(buttons).toHaveLength(5)
expect(buttons.every((button) => button.disabled)).toBe(true)
expect(callsTo('POST', /merge/)).toHaveLength(0)
})
})
@@ -0,0 +1,572 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
announcer,
bulkBar,
callsTo,
cardFor,
cards,
checkboxFor,
deferred,
dialog,
findButton,
fixture,
libraryHandlers,
listBody,
listCalls,
mountPage,
panel,
press,
pressOnWindow,
selectAllCheckbox,
settle,
tab,
tableHeaderCheckbox,
textOf,
unmountPage,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const NAMES = ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']
function select(...names: string[]) {
for (const name of names) {
checkboxFor(name).click()
}
return flushPromises()
}
function state(element: Element) {
return element.getAttribute('data-state')
}
function selectedNames() {
return cards()
.filter((card) => card.hasAttribute('data-selected'))
.map((card) => card.getAttribute('aria-label')!.split(',')[0])
}
function checkboxWrapper(filename: string) {
return checkboxFor(filename).closest<HTMLElement>('[data-testid="card-checkbox"]')!
}
beforeEach(() => {
toasts.add.mockClear()
toasts.remove.mockClear()
toasts.update.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('Selecting images', () => {
it('shows no checkbox until the pointer or keyboard focus reaches a card, then a 36 px one', async () => {
await mountPage()
const wrapper = checkboxWrapper('north-wall.jpg')
expect(wrapper.classList.contains('opacity-100')).toBe(false)
expect(wrapper.classList.contains('md:opacity-0')).toBe(true)
expect(wrapper.className).toContain('md:group-hover/card:opacity-100')
expect(wrapper.className).toContain('md:group-has-[:focus-visible]/card:opacity-100')
expect(wrapper.className).toContain('md:size-9')
expect(checkboxFor('north-wall.jpg').getAttribute('tabindex')).toBe('-1')
})
it('ticking a checkbox selects the card, shows every checkbox and announces it', async () => {
await mountPage()
await select('north-wall.jpg')
const card = cardFor('north-wall.jpg')
expect(card.hasAttribute('data-selected')).toBe(true)
expect(card.className).toContain('bg-secondary-200')
expect(card.className).toContain('border-primary')
expect(card.className).toContain('inset-ring-primary')
expect(state(checkboxFor('north-wall.jpg'))).toBe('checked')
expect(checkboxWrapper('kitchen-after.webp').classList.contains('opacity-100')).toBe(true)
expect(announcer()).toBe('north-wall.jpg selected, 1 selected')
expect(panel()).toBeNull()
})
it('Space on the card toggles it and Enter still opens the detail panel', async () => {
await mountPage()
const card = cardFor('north-wall.jpg')
card.focus()
press(card, ' ')
await flushPromises()
expect(card.hasAttribute('data-selected')).toBe(true)
expect(announcer()).toBe('north-wall.jpg selected, 1 selected')
expect(panel()).toBeNull()
press(card, ' ')
await flushPromises()
expect(card.hasAttribute('data-selected')).toBe(false)
press(card, 'Enter')
await flushPromises()
expect(panel()).not.toBeNull()
})
it('announces the new count when unselecting, and "Selection cleared" for the last one', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
await select('north-wall.jpg')
expect(announcer()).toBe('north-wall.jpg unselected, 2 selected')
await select('kitchen-after.webp')
await select('window-frame.jpg')
expect(announcer()).toBe('Selection cleared')
expect(bulkBar()).toBeNull()
})
it('shows a 44 px checkbox on every card below md, and tapping it does not open the panel', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
const wrapper = checkboxWrapper('north-wall.jpg')
expect(wrapper.className).toContain('max-md:opacity-100')
expect(wrapper.className).toContain('size-11')
await select('north-wall.jpg')
expect(cardFor('north-wall.jpg').hasAttribute('data-selected')).toBe(true)
expect(panel()).toBeNull()
cardFor('kitchen-after.webp').click()
await flushPromises()
expect(panel()).not.toBeNull()
})
it('puts a 44 px checkbox on every row of the card list in list view', async () => {
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
expect(document.body.querySelectorAll('[data-testid="media-card-row"]')).toHaveLength(12)
expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(12)
await select('north-wall.jpg')
expect(
document.body.querySelector('[data-testid="media-card-row"][data-selected]')!.className,
).toContain('bg-secondary-200')
expect(document.body.querySelector('[data-testid="image-count"]')).toBeNull()
})
})
describe('Select all and the partial state', () => {
it('shows Select all with the image count, partial with some selected, then all, then none', async () => {
await mountPage()
const row = document.body.querySelector('[data-testid="image-count"]')!.parentElement!
expect(textOf(row)).toBe('Select all 12 images')
expect(state(selectAllCheckbox())).toBe('unchecked')
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
expect(state(selectAllCheckbox())).toBe('indeterminate')
selectAllCheckbox().click()
await flushPromises()
expect(state(selectAllCheckbox())).toBe('checked')
expect(selectedNames()).toHaveLength(12)
expect(textOf(bulkBar())).toContain('12 selected')
selectAllCheckbox().click()
await flushPromises()
expect(state(selectAllCheckbox())).toBe('unchecked')
expect(bulkBar()).toBeNull()
expect(announcer()).toBe('Selection cleared')
})
it('the table header checkbox does the same in list view at md width or more', async () => {
await mountPage({}, '/media?view=list')
expect(state(tableHeaderCheckbox())).toBe('unchecked')
await select('north-wall.jpg')
expect(state(tableHeaderCheckbox())).toBe('indeterminate')
tableHeaderCheckbox().click()
await flushPromises()
expect(state(tableHeaderCheckbox())).toBe('checked')
expect(textOf(bulkBar())).toContain('12 selected')
tableHeaderCheckbox().click()
await flushPromises()
expect(bulkBar()).toBeNull()
})
it('has no Select all row in the mobile card list', async () => {
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
expect(document.body.textContent).not.toContain('Select all')
await select('north-wall.jpg', 'kitchen-after.webp')
expect(textOf(bulkBar())).toContain('2 selected')
})
})
describe('Escape and Clear', () => {
it('Escape and Clear empty the selection when the panel is closed', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
pressOnWindow('Escape')
await flushPromises()
expect(bulkBar()).toBeNull()
expect(selectedNames()).toEqual([])
await select('north-wall.jpg', 'kitchen-after.webp')
findButton('Clear', bulkBar()!)!.click()
await flushPromises()
expect(bulkBar()).toBeNull()
expect(announcer()).toBe('Selection cleared')
})
it('Escape closes only the panel while it is open over a selection', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
cardFor('window-frame.jpg').click()
await flushPromises()
expect(panel()).not.toBeNull()
panel()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await settle()
expect(panel()).toBeNull()
expect(selectedNames()).toEqual(['north-wall.jpg', 'kitchen-after.webp'])
pressOnWindow('Escape')
await flushPromises()
expect(selectedNames()).toEqual([])
})
})
describe('The selection follows the query', () => {
it('clears when the tab changes', async () => {
await mountPage()
await select('north-wall.jpg')
tab('Unused').focus()
press(tab('Unused'), 'Enter')
await settle()
expect(bulkBar()).toBeNull()
})
it('clears when the sort changes and stays when the view changes', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { view: 'list' } })
await settle()
expect(textOf(bulkBar())).toContain('2 selected')
await router.replace({ query: { view: 'list', sort: 'oldest' } })
await settle()
expect(bulkBar()).toBeNull()
})
it('clears when the search text changes', async () => {
await mountPage()
await select('north-wall.jpg')
await router.replace({ query: { q: 'wall' } })
await settle()
expect(bulkBar()).toBeNull()
})
})
describe('The bulk bar', () => {
it('shows the count, a danger Delete and an inverted Clear, placed per breakpoint', async () => {
await mountPage()
await select(...NAMES)
const bar = bulkBar()!
expect(textOf(bar)).toContain('3 selected')
expect(bar.className).toContain('bg-inverted')
expect(bar.className).toContain('text-inverted')
expect(bar.className).toContain('fixed')
expect(bar.className).toContain('md:w-100')
expect(bar.className).toContain('md:bottom-6')
expect(bar.className).toContain('bottom-4')
expect(bar.className).toContain('max-md:inset-x-4')
const remove = findButton('Delete', bar)!
expect(remove.className).toContain('bg-error')
expect(findButton('Clear', bar)!.className).toContain('border-(--ui-text-inverted)')
await vi.waitFor(() => expect(remove.querySelector('svg.iconify--lucide')).toBeTruthy())
})
it('replaces the mobile pinned Upload button', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
expect(findButton('Upload')).toBeTruthy()
await select('north-wall.jpg')
expect(findButton('Upload')).toBeUndefined()
expect(textOf(bulkBar())).toContain('1 selected')
})
})
describe('Bulk delete', () => {
async function openBulkDialog(names = NAMES) {
await select(...names)
findButton('Delete', bulkBar()!)!.click()
await flushPromises()
}
it('confirms with the summed article count and one row per image, without a usage request', async () => {
await mountPage()
await openBulkDialog()
const box = dialog()!
expect(textOf(box)).toContain('Delete 3 images?')
expect(textOf(box)).toContain(
"2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.",
)
const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf)
expect(rows).toEqual([
'north-wall.jpg Used in 2',
'kitchen-after.webp Used in 3',
'window-frame.jpg Unused',
])
const buttons = Array.from(box.querySelectorAll('button')).map(textOf)
expect(buttons).toEqual(['Cancel', 'Delete 3 images'])
expect(callsTo('GET', /usage/)).toHaveLength(0)
expect(callsTo('DELETE', /media/)).toHaveLength(0)
})
it('uses the singular for one image', async () => {
await mountPage()
await openBulkDialog(['window-frame.jpg'])
expect(textOf(dialog())).toContain('Delete 1 image?')
expect(findButton('Delete 1 image', dialog()!)).toBeTruthy()
})
it('sends one DELETE per image and locks the dialog while they are in flight', async () => {
const pending = deferred<undefined>()
await mountPage({ remove: () => pending.promise })
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await flushPromises()
expect(callsTo('DELETE', /^\/v1\/media\/(1|2|8)$/).map(([path]) => path)).toEqual([
'/v1/media/1',
'/v1/media/2',
'/v1/media/8',
])
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
const confirm = findButton('Deleting…', dialog()!)!
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
dialog()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(dialog()).not.toBeNull()
pending.resolve(undefined)
await settle()
})
it('done: the cards leave, the selection empties, focus moves on and a toast shows', async () => {
let calls = 0
await mountPage({
list: () =>
++calls === 1
? listBody(fixture())
: listBody(
fixture().filter((item) => ![1, 2, 8].includes(item.id)),
{ all: 9, unused: 1, duplicates: 2 },
),
})
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(cards()).toHaveLength(9)
expect(bulkBar()).toBeNull()
expect(document.activeElement).toBe(cardFor('kitchen-before.webp'))
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'media-bulk-deleted',
color: 'success',
icon: 'i-lucide-circle-check',
title: '3 images deleted',
description: "They're gone from your library.",
duration: 5000,
}),
)
expect(listCalls()).toHaveLength(2)
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
0,
)
})
it('done with nothing left moves focus to the page heading', async () => {
await mountPage(libraryHandlers(() => undefined, [1]))
await openBulkDialog(['north-wall.jpg'])
findButton('Delete 1 image', dialog()!)!.click()
await settle()
expect(document.activeElement).toBe(
document.body.querySelector('[data-testid="media-heading"]'),
)
})
it('partly failed: deleted cards leave, the failed one stays selected and an error toast offers Retry', async () => {
await mountPage(
libraryHandlers((id) => (id === 8 ? Promise.reject(new ApiError(500)) : undefined)),
)
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(cardFor('north-wall.jpg')).toBeUndefined()
expect(cardFor('kitchen-after.webp')).toBeUndefined()
expect(selectedNames()).toEqual(['window-frame.jpg'])
expect(textOf(bulkBar())).toContain('1 selected')
const toast = toasts.add.mock.calls.at(-1)![0]
expect(toast).toMatchObject({
id: 'media-bulk-delete-failed',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't delete 1 image",
description:
'2 images were deleted. window-frame.jpg is still in your library. Try again.',
duration: 0,
})
expect(toast.actions).toHaveLength(1)
expect(toast.actions[0]).toMatchObject({ label: 'Retry', icon: 'i-lucide-rotate-cw' })
})
it('Retry sends only the failed DELETE with no dialog and replaces the toast on success', async () => {
let eightAttempts = 0
await mountPage(
libraryHandlers((id) => {
if (id === 8 && ++eightAttempts === 1) {
return Promise.reject(new NetworkError())
}
return undefined
}),
)
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
const failed = toasts.add.mock.calls.at(-1)![0]
const deletesBefore = callsTo('DELETE', /media/).length
failed.actions[0].onClick()
await settle()
expect(callsTo('DELETE', /media/).length).toBe(deletesBefore + 1)
expect(callsTo('DELETE', /media\/8$/)).toHaveLength(2)
expect(dialog()).toBeNull()
expect(cardFor('window-frame.jpg')).toBeUndefined()
expect(toasts.remove).toHaveBeenCalledWith('media-bulk-delete-failed')
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
id: 'media-bulk-deleted',
color: 'success',
title: '3 images deleted',
})
expect(bulkBar()).toBeNull()
})
it('several failures name every file and pluralise the title', async () => {
await mountPage({
remove: (id) => (id === 2 || id === 3 ? Promise.reject(new ApiError(500)) : undefined),
})
await openBulkDialog([
'north-wall.jpg',
'kitchen-after.webp',
'kitchen-before.webp',
'window-frame.jpg',
])
findButton('Delete 4 images', dialog()!)!.click()
await settle()
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
title: "Couldn't delete 2 images",
description:
'2 images were deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
})
})
it('an image that no longer exists counts as deleted', async () => {
await mountPage(
libraryHandlers((id) => (id === 2 ? Promise.reject(new ApiError(404)) : undefined)),
)
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
expect(cardFor('kitchen-after.webp')).toBeUndefined()
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
id: 'media-bulk-deleted',
title: '3 images deleted',
})
})
it.each([
['500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])(
'every request failing with %s keeps the dialog open with an error alert',
async (_name, remove) => {
await mountPage({ remove })
await openBulkDialog()
findButton('Delete 3 images', dialog()!)!.click()
await settle()
const box = dialog()!
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
"Couldn't delete the images, so they're still there. Try again in a moment.",
)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(findButton('Delete 3 images', box)!.disabled).toBe(false)
expect(cards()).toHaveLength(12)
expect(toasts.add).not.toHaveBeenCalled()
},
)
it('disables Delete in the bulk bar while offline but keeps Clear working', async () => {
await mountPage()
await select('north-wall.jpg')
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(findButton('Delete', bulkBar()!)!.disabled).toBe(true)
await select('kitchen-after.webp')
expect(textOf(bulkBar())).toContain('2 selected')
findButton('Clear', bulkBar()!)!.click()
await flushPromises()
expect(bulkBar()).toBeNull()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await select('north-wall.jpg')
expect(findButton('Delete', bulkBar()!)!.disabled).toBe(false)
})
})
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import {
FakeXhr,
chooseFiles,
dialog,
findButton,
checkboxFor,
bulkBar,
fileWith,
fixture,
listBody,
mountPage,
settle,
stubDigest,
stubXhr,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const LOGIN = { path: '/login', query: { redirect: '/media', reason: 'expired' } }
const DUPLICATES_LOGIN = {
path: '/login',
query: { redirect: '/media?filter=duplicates', reason: 'expired' },
}
const PENDING = { path: '/register/pending', query: {} }
async function mountUnverified(handlers: Handlers, url = '/media') {
await mountPage(handlers, url, undefined, { realApi: true })
useSessionStore().session = {
verified: false,
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
}
}
async function expectRedirect(destination: { path: string; query: Record<string, string> }) {
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination.path))
expect(router.currentRoute.value.query).toEqual(destination.query)
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
stubDigest()
stubXhr()
})
afterEach(unmountPage)
describe('Session errors are global for the new calls', () => {
it.each([
['POST /v1/media/merge', 401, DUPLICATES_LOGIN],
['POST /v1/media/merge', 403, PENDING],
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
await mountUnverified(
{
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture([5, 6, 12]))
: listBody(fixture()),
merge: () => Promise.reject(new ApiError(status)),
},
'/media?filter=duplicates',
)
document.body
.querySelector<HTMLButtonElement>(
'[data-testid="duplicate-copy"] button:not([data-testid])',
)!
.click()
await flushPromises()
findButton('Merge 2 copies', dialog()!)!.click()
await expectRedirect(destination)
await settle()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(toasts.add).not.toHaveBeenCalled()
})
it.each([
['DELETE /v1/media/{id}', 403, PENDING],
['DELETE /v1/media/{id}', 401, LOGIN],
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
await mountUnverified({ remove: () => Promise.reject(new ApiError(status)) })
checkboxFor('north-wall.jpg').click()
await flushPromises()
findButton('Delete', bulkBar()!)!.click()
await flushPromises()
findButton('Delete 1 image', dialog()!)!.click()
await expectRedirect(destination)
await settle()
expect(dialog()!.querySelector('[role="alert"]')).toBeNull()
expect(toasts.add).not.toHaveBeenCalled()
})
it('GET /v1/media/?hash= resolving 401 redirects to the login page and sends nothing', async () => {
await mountUnverified({ hash: () => Promise.reject(new ApiError(401)) })
await chooseFiles([fileWith('a.jpg', 'image/jpeg', 1000)])
await expectRedirect(LOGIN)
expect(FakeXhr.instances).toHaveLength(0)
expect(document.body.textContent).not.toContain('Upload failed')
})
it('GET /v1/media/?hash= resolving 403 redirects to the pending page', async () => {
await mountUnverified({ hash: () => Promise.reject(new ApiError(403)) })
await chooseFiles([fileWith('a.jpg', 'image/jpeg', 1000)])
await expectRedirect(PENDING)
expect(FakeXhr.instances).toHaveLength(0)
expect(textOf(document.body.querySelector('[data-testid="upload-row"]'))).not.toContain(
'Upload failed',
)
})
})
+844
View File
@@ -0,0 +1,844 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
FakeXhr,
MEGABYTE,
announcer,
bulkBar,
callsTo,
cards,
chooseFiles,
dragEvent,
deferred,
fileInput,
fileWith,
findButton,
fixture,
hexOf,
inFlight,
listBody,
listCalls,
mountPage,
rowTexts,
settle,
stubDigest,
stubXhr,
tab,
textOf,
unmountPage,
uploadPanel,
uploadRows,
xhrFor,
type Handlers,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
const UNREADABLE_TEXT =
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
function photo(name: string, size = 1000) {
return fileWith(name, 'image/jpeg', size)
}
function hashLookups() {
return callsTo('GET', /\?hash=/)
}
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
return (path) =>
path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([])
}
function headerButton(label: string) {
return findButton(label)
}
beforeEach(() => {
toasts.add.mockClear()
toasts.remove.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
stubDigest()
stubXhr()
})
afterEach(unmountPage)
describe('Upload entry points', () => {
it.each([
['at md width or more', 'Upload', 1440],
['below md width', 'Upload', MOBILE_WIDTH],
])(
'the Default state loaded %s: "%s" opens the file chooser',
async (_context, label, width) => {
await mountPage({}, '/media', width)
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === label,
)
expect(buttons).toHaveLength(1)
buttons[0]!.click()
expect(click).toHaveBeenCalledTimes(1)
expect(click.mock.contexts[0]).toBe(fileInput())
},
)
it('and the library has no image: "Upload an image" opens the file chooser', async () => {
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
findButton('Upload an image')!.click()
expect(click.mock.contexts[0]).toBe(fileInput())
})
it('the chooser accepts several files and offers the four image types', async () => {
await mountPage()
expect(fileInput().multiple).toBe(true)
expect(fileInput().getAttribute('accept')).toBe(
'image/png,image/jpeg,image/webp,image/svg+xml',
)
})
})
describe('Dropping files anywhere on the page', () => {
it('shows the drop area while files are dragged over, then uploads the drop with no crop step', async () => {
await mountPage()
dragEvent('dragenter', ['Files'])
await flushPromises()
const area = document.body.querySelector('[data-testid="media-drop-area"]')!
expect(textOf(area)).toBe('Drop images to upload PNG, JPEG, WEBP, SVG · max 10 MB each')
expect(area.className).toContain('border-dashed')
expect(area.className).toContain('border-primary')
dragEvent('dragleave', ['Files'])
await flushPromises()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
expect(FakeXhr.instances).toHaveLength(0)
expect(uploadPanel()).toBeNull()
dragEvent('dragenter', ['Files'])
dragEvent('drop', ['Files'], [photo('a.jpg'), photo('b.jpg')])
await settle()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg', 'b.jpg'])
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
it('hides the mobile pinned Upload button while the area shows', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
expect(headerButton('Upload')).toBeTruthy()
dragEvent('dragenter', ['Files'])
await flushPromises()
expect(headerButton('Upload')).toBeUndefined()
dragEvent('dragleave', ['Files'])
await flushPromises()
expect(headerButton('Upload')).toBeTruthy()
})
it('does nothing when something that is not a file is dragged over', async () => {
await mountPage()
const event = dragEvent('dragenter', ['text/plain'])
await flushPromises()
expect(event.defaultPrevented).toBe(false)
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
})
})
describe('Browser checks, nothing is sent', () => {
it.each([
['scan.tif', 14.2 * MEGABYTE, 'image/tiff', 'scan.tif is 14.2 MB'],
['notes.pdf', 210 * 1024, 'application/pdf', "notes.pdf isn't an image"],
['photo.png', 10.1 * MEGABYTE, 'image/png', 'photo.png is 10.1 MB'],
['fake.jpg', 40 * 1024, 'text/plain', "fake.jpg isn't an image"],
['logo.gif', 40 * 1024, 'image/gif', "logo.gif isn't an image"],
['pic.jpg', 40 * 1024, 'image/png', "pic.jpg isn't an image"],
])('%s becomes a rejected row', async (name, size, mime, title) => {
await mountPage()
const digest = globalThis.crypto.subtle.digest
await chooseFiles([fileWith(name, mime, size)])
const [row] = uploadRows()
expect(row!.getAttribute('data-status')).toBe('rejected')
expect(textOf(row)).toContain(title)
expect(textOf(row)).toContain(REJECTION_TEXT)
await vi.waitFor(() => expect(row!.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(digest).not.toHaveBeenCalled()
expect(hashLookups()).toHaveLength(0)
expect(FakeXhr.instances).toHaveLength(0)
})
it('accepts exactly 10485760 bytes and rejects one byte more', async () => {
await mountPage()
await chooseFiles([
fileWith('edge.png', 'image/png', 10485760),
fileWith('over.png', 'image/png', 10485761),
])
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'uploading',
'rejected',
])
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['edge.png'])
})
it('checks the size before the type', async () => {
await mountPage()
await chooseFiles([fileWith('huge.tif', 'image/tiff', 11 * MEGABYTE)])
expect(textOf(uploadRows()[0])).toContain('huge.tif is 11.0 MB')
})
it('leaves the panel with "Nothing was uploaded" when every file is rejected', async () => {
await mountPage()
await chooseFiles([
fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE),
fileWith('notes.pdf', 'application/pdf', 210 * 1024),
fileWith('broken.png', 'image/png', 3 * MEGABYTE),
])
xhrFor('broken.png').respond(400)
await settle()
const panel = uploadPanel()!
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe(
'Nothing was uploaded',
)
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe(
'3 files were rejected',
)
expect(findButton('Dismiss', panel)).toBeTruthy()
expect(panel.querySelector('[data-testid="upload-summary"]')).toBeNull()
const [scan, notes, broken] = rowTexts()
expect(scan).toContain(`scan.tif is 14.2 MB ${REJECTION_TEXT}`)
expect(notes).toContain(`notes.pdf isn't an image ${REJECTION_TEXT}`)
expect(broken).toContain(`broken.png couldn't be read ${UNREADABLE_TEXT}`)
})
it('reads "2 files need attention" for a rejected and a failed file, with no summary', async () => {
await mountPage()
await chooseFiles([fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE), photo('roof.jpg')])
xhrFor('roof.jpg').respond(500)
await settle()
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Nothing was uploaded',
)
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe(
'2 files need attention',
)
expect(uploadPanel()!.querySelector('[data-testid="upload-summary"]')).toBeNull()
})
})
describe('The upload panel and its rows', () => {
function sixImages() {
return Array.from({ length: 6 }, (_, index) =>
photo(`img-${index + 1}.jpg`, 2.6 * MEGABYTE),
)
}
it('uploads two at a time, in order, with progress', async () => {
await mountPage()
await chooseFiles(sixImages())
const panel = uploadPanel()!
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe(
'Uploading 6 images',
)
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('0 of 6 done')
expect(findButton('Cancel all', panel)).toBeTruthy()
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'uploading',
'uploading',
'waiting',
'waiting',
'waiting',
'waiting',
])
expect(textOf(uploadRows()[2])).toContain('Waiting')
expect(textOf(uploadRows()[2])).toContain('2.6 MB')
expect(inFlight()).toHaveLength(2)
xhrFor('img-1.jpg').progress(1.1 * MEGABYTE, 2.6 * MEGABYTE)
await flushPromises()
expect(textOf(uploadRows()[0])).toContain('Uploading 1.1 of 2.6 MB')
expect(
uploadRows()[0]!.querySelector('[role="progressbar"]')!.getAttribute('aria-valuenow'),
).toBe('42')
expect(
uploadRows()[0]!.querySelector(`[aria-label="Cancel upload of img-1.jpg"]`),
).toBeTruthy()
xhrFor('img-1.jpg').respond(201)
await settle()
expect(textOf(uploadRows()[0])).toContain('Added to your library')
await vi.waitFor(() =>
expect(uploadRows()[0]!.querySelector('svg.iconify--lucide')).toBeTruthy(),
)
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('1 of 6 done')
expect(uploadRows()[2]!.getAttribute('data-status')).toBe('uploading')
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
'img-1.jpg',
'img-2.jpg',
'img-3.jpg',
])
expect(inFlight()).toHaveLength(2)
expect(FakeXhr.instances[0]!.url).toBe('/v1/media/')
expect(FakeXhr.instances[0]!.method).toBe('POST')
expect(FakeXhr.instances[0]!.withCredentials).toBe(true)
})
it('never has more than two requests in flight while draining the queue', async () => {
await mountPage()
await chooseFiles(sixImages())
let peak = inFlight().length
for (let guard = 0; guard < 6 && inFlight().length > 0; guard++) {
inFlight()[0]!.respond(201)
await settle()
peak = Math.max(peak, inFlight().length)
}
expect(peak).toBe(2)
expect(FakeXhr.instances).toHaveLength(6)
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Upload finished',
)
})
it('counts settled rows in the header', async () => {
await mountPage({ hash: hashHandlerFor('dup.jpg') })
await chooseFiles([
photo('added.jpg'),
photo('dup.jpg'),
fileWith('rej.tif', 'image/tiff', 1000),
photo('fail.jpg'),
photo('up-1.jpg'),
photo('up-2.jpg'),
])
xhrFor('added.jpg').respond(201)
await settle()
xhrFor('fail.jpg').respond(500)
await settle()
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'added',
'duplicate',
'rejected',
'failed',
'uploading',
'uploading',
])
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe(
'4 of 6 done',
)
})
it('uses the singular for one image', async () => {
await mountPage()
await chooseFiles([photo('one.jpg')])
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Uploading 1 image',
)
})
it('appends files added while the panel is open', async () => {
await mountPage()
await chooseFiles([photo('a.jpg'), photo('b.jpg')])
await chooseFiles([photo('c.jpg')])
expect(uploadRows()).toHaveLength(3)
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
'Uploading 3 images',
)
expect(textOf(uploadRows()[2])).toContain('c.jpg')
})
it('refreshes the list in the background after a 201', async () => {
let calls = 0
await mountPage({
list: () =>
++calls === 1
? listBody(fixture())
: listBody(fixture(), { all: 13, unused: 2, duplicates: 2 }),
})
expect(listCalls()).toHaveLength(1)
await chooseFiles([photo('a.jpg')])
xhrFor('a.jpg').respond(201)
await settle()
expect(listCalls()).toHaveLength(2)
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
0,
)
expect(tab('All').textContent).toContain('13')
})
})
describe('A row whose content already exists', () => {
const existingText = 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.'
async function mountWithDuplicate(handlers: Handlers = {}) {
await mountPage({ hash: hashHandlerFor('floorboards-2.jpg'), ...handlers })
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
}
it('looks up the hash before any POST and shows the duplicate row, naming the oldest match', async () => {
await mountWithDuplicate()
expect(hashLookups().map(([path]) => path)).toEqual([
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}`,
])
expect(FakeXhr.instances).toHaveLength(0)
const row = uploadRows()[0]!
expect(row.getAttribute('data-status')).toBe('duplicate')
expect(textOf(row)).toContain('Already in your library')
expect(textOf(row)).toContain(existingText)
expect(findButton('Use existing', row)).toBeTruthy()
expect(findButton('Upload anyway', row)).toBeTruthy()
})
it('hashes inside the concurrency slot while the row reads Waiting', async () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
const answers = [first, second]
await mountPage({ hash: () => (answers.shift() ?? deferred<unknown>()).promise })
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'waiting',
'waiting',
'waiting',
])
expect(hashLookups()).toHaveLength(2)
expect(FakeXhr.instances).toHaveLength(0)
first.resolve(listBody([]))
await settle()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg'])
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
expect(hashLookups()).toHaveLength(2)
xhrFor('a.jpg').respond(201)
await settle()
expect(hashLookups()).toHaveLength(3)
})
it('releases the concurrency slot so the next file starts', async () => {
await mountPage({ hash: hashHandlerFor('dup.jpg') })
await chooseFiles([photo('dup.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')])
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'duplicate',
'uploading',
'uploading',
'waiting',
])
})
it('Use existing drops the row and selects the image', async () => {
await mountWithDuplicate()
findButton('Use existing')!.click()
await settle()
expect(uploadPanel()).toBeNull()
expect(textOf(bulkBar())).toContain('1 selected')
expect(announcer()).toBe('floorboards.jpg selected, 1 selected')
})
it('Use existing resets the search and the tab when the image is not loaded', async () => {
await mountPage(
{
hash: hashHandlerFor('floorboards-2.jpg'),
list: (path) =>
path.includes('filter=unused')
? listBody(fixture([8, 10]))
: listBody(fixture()),
},
'/media?filter=unused',
)
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
findButton('Use existing')!.click()
await settle()
expect(router.currentRoute.value.query).toEqual({})
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(textOf(bulkBar())).toContain('1 selected')
expect(announcer()).toBe('floorboards.jpg selected, 1 selected')
})
it('Use existing drops the row without selecting when the image is still not loaded', async () => {
await mountWithDuplicate({
list: () => listBody(fixture().filter((item) => item.id !== 12)),
})
findButton('Use existing')!.click()
await settle()
expect(uploadPanel()).toBeNull()
expect(bulkBar()).toBeNull()
})
it('Upload anyway stores a second copy and the Duplicates count follows', async () => {
let calls = 0
await mountWithDuplicate({
list: () =>
++calls === 1
? listBody(fixture())
: listBody(fixture(), { all: 13, unused: 2, duplicates: 3 }),
})
findButton('Upload anyway')!.click()
await settle()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['floorboards-2.jpg'])
expect(hashLookups()).toHaveLength(1)
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
FakeXhr.instances[0]!.respond(201)
await settle()
expect(textOf(uploadRows()[0])).toContain('Added to your library')
expect(tab('Duplicates').textContent).toContain('3')
})
it.each([
[
'crypto.subtle is undefined',
() => vi.stubGlobal('crypto', { subtle: undefined }),
undefined,
],
['the lookup fails with 500', () => undefined, () => Promise.reject(new ApiError(500))],
[
'the lookup never reaches the server',
() => undefined,
() => Promise.reject(new NetworkError()),
],
])('skips the duplicate check when %s', async (_name, prepare, hash) => {
prepare()
await mountPage({ hash })
await chooseFiles([photo('a.jpg')])
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg'])
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
})
})
describe('Server answers for an upload that passed the checks', () => {
it.each([
['roof.png', 1.4 * MEGABYTE, 201, 'Added to your library'],
['scan.png', 9.9 * MEGABYTE, 413, `scan.png is 9.9 MB ${REJECTION_TEXT}`],
['notes.png', 210 * 1024, 415, `notes.png isn't an image ${REJECTION_TEXT}`],
['broken.png', 3 * MEGABYTE, 400, `broken.png couldn't be read ${UNREADABLE_TEXT}`],
['roof.png', 1.4 * MEGABYTE, 500, `Upload failed ${FAILED_TEXT} Try again`],
['roof.png', 1.4 * MEGABYTE, 0, `Upload failed ${FAILED_TEXT} Try again`],
])('%s of %i bytes resolving %i reads the right row', async (name, size, status, text) => {
await mountPage()
await chooseFiles([fileWith(name, 'image/png', size)])
if (status === 0) {
FakeXhr.instances[0]!.fail()
} else {
FakeXhr.instances[0]!.respond(status)
}
await settle()
const row = uploadRows()[0]!
expect(textOf(row)).toContain(text)
await vi.waitFor(() => expect(row.querySelector('svg.iconify--lucide')).toBeTruthy())
})
it('Try again retries one file with no new duplicate lookup', async () => {
await mountPage()
await chooseFiles([photo('roof.jpg'), photo('other.jpg')])
xhrFor('roof.jpg').respond(500)
await settle()
expect(hashLookups()).toHaveLength(2)
findButton('Try again')!.click()
await settle()
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
'roof.jpg',
'other.jpg',
'roof.jpg',
])
expect(hashLookups()).toHaveLength(2)
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
})
})
describe('Cancelling one upload or all of them', () => {
it('the row x aborts that request, removes the row and starts the next waiting file', async () => {
await mountPage()
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
document.body
.querySelector<HTMLButtonElement>('[aria-label="Cancel upload of a.jpg"]')!
.click()
await settle()
expect(xhrFor('a.jpg').aborted).toBe(true)
expect(uploadRows().map((row) => textOf(row).split(' ')[0])).toEqual(['b.jpg', 'c.jpg'])
expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg', 'c.jpg'])
})
it('Cancel all aborts what is in flight, removes waiting rows and keeps settled ones', async () => {
await mountPage()
await chooseFiles([
photo('a.jpg'),
photo('b.jpg'),
photo('c.jpg'),
fileWith('bad.tif', 'image/tiff', 10),
])
xhrFor('a.jpg').respond(201)
await settle()
findButton('Cancel all')!.click()
await settle()
expect(xhrFor('b.jpg').aborted).toBe(true)
expect(xhrFor('c.jpg').aborted).toBe(true)
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
'added',
'rejected',
])
})
it('closes the panel when no row is left', async () => {
await mountPage()
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
findButton('Cancel all')!.click()
await settle()
expect(uploadPanel()).toBeNull()
expect(FakeXhr.instances.every((xhr) => xhr.aborted)).toBe(true)
})
})
describe('The panel settles with a summary', () => {
it('settles with the counts, announces the summary and Dismiss removes every row', async () => {
await mountPage({ hash: hashHandlerFor('dup.jpg') })
await chooseFiles([
photo('one.jpg'),
photo('two.jpg'),
photo('dup.jpg'),
fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE),
photo('roof.jpg'),
])
xhrFor('one.jpg').respond(201)
xhrFor('two.jpg').respond(201)
await settle()
xhrFor('roof.jpg').respond(500)
await settle()
const panel = uploadPanel()!
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe('Upload finished')
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('5 images')
expect(findButton('Dismiss', panel)).toBeTruthy()
expect(findButton('Cancel all', panel)).toBeUndefined()
expect(textOf(panel.querySelector('[data-testid="upload-summary"]'))).toBe(
'2 uploaded · 1 already in your library · 2 need attention',
)
expect(announcer()).toBe('2 uploaded · 1 already in your library · 2 need attention')
findButton('Dismiss', panel)!.click()
await flushPromises()
expect(uploadPanel()).toBeNull()
expect(uploadRows()).toHaveLength(0)
})
})
describe('Too many uploads (429)', () => {
async function rateLimit(retryAfter?: string, width = 1440) {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountPage({}, '/media', width)
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')])
xhrFor('a.jpg').respond(429, retryAfter === undefined ? {} : { 'Retry-After': retryAfter })
await settle()
}
it('shows the alert, counts down in a disabled button and pauses the queue', async () => {
await rateLimit('42')
const alert = Array.from(document.body.querySelectorAll('[role="status"]')).find(
(element) => textOf(element).includes('Too many uploads'),
)
expect(textOf(alert)).toBe(
'Too many uploads For your security, wait a moment before uploading again.',
)
const button = findButton('Try again in 0:42')!
expect(button.disabled).toBe(true)
await vi.waitFor(() => expect(button.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(findButton('Upload')).toBeUndefined()
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('waiting')
expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg'])
expect(FakeXhr.instances).toHaveLength(2)
})
it('resumes by itself when the countdown reaches 0:00, keeping Added rows', async () => {
await rateLimit('42')
xhrFor('b.jpg').respond(201)
await settle()
expect(FakeXhr.instances).toHaveLength(2)
vi.advanceTimersByTime(1000)
await flushPromises()
expect(findButton('Try again in 0:41')).toBeTruthy()
vi.advanceTimersByTime(41000)
await settle()
expect(document.body.textContent).not.toContain('Too many uploads')
expect(findButton('Upload')!.disabled).toBe(false)
expect(uploadRows()[1]!.getAttribute('data-status')).toBe('added')
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
'a.jpg',
'b.jpg',
'a.jpg',
'c.jpg',
])
})
it('counts 60 seconds when Retry-After is missing or unreadable', async () => {
await rateLimit(undefined)
expect(findButton('Try again in 1:00')).toBeTruthy()
})
it('disables the mobile pinned button too', async () => {
await rateLimit('42', MOBILE_WIDTH)
const pinned = findButton('Try again in 0:42')!
expect(pinned.disabled).toBe(true)
expect(pinned.className).toContain('h-12')
})
it('ignores drops and the drop area while counting', async () => {
await rateLimit('42')
const before = FakeXhr.instances.length
dragEvent('dragenter', ['Files'])
await flushPromises()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
dragEvent('drop', ['Files'], [photo('z.jpg')])
await settle()
expect(FakeXhr.instances).toHaveLength(before)
expect(uploadRows()).toHaveLength(4)
})
})
describe('Offline', () => {
async function goOffline() {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
}
it('disables the Upload controls and ignores drops, then enables them again', async () => {
await mountPage()
await goOffline()
expect(document.body.textContent).toContain("You're offline.")
expect(findButton('Upload')!.disabled).toBe(true)
dragEvent('dragenter', ['Files'])
await flushPromises()
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
dragEvent('drop', ['Files'], [photo('z.jpg')])
await settle()
expect(FakeXhr.instances).toHaveLength(0)
expect(cards()).toHaveLength(12)
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(findButton('Upload')!.disabled).toBe(false)
})
it('disables the mobile pinned button and "Upload an image"', async () => {
await mountPage({}, '/media', MOBILE_WIDTH)
await goOffline()
expect(findButton('Upload')!.disabled).toBe(true)
await unmountPage()
stubDigest()
stubXhr()
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
await goOffline()
expect(findButton('Upload an image')!.disabled).toBe(true)
})
})
describe('Session errors on the upload calls are global', () => {
it.each([
[401, '/login', { redirect: '/media', reason: 'expired' }],
[403, '/register/pending', {}],
])(
'an XHR upload resolving %i sends the user to %s with no local copy',
async (status, path, query) => {
await mountPage()
useSessionStore().session = {
verified: false,
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
}
await chooseFiles([photo('a.jpg')])
FakeXhr.instances[0]!.respond(status)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(path))
await settle()
expect(router.currentRoute.value.path).toBe(path)
expect(router.currentRoute.value.query).toEqual(query)
expect(document.body.textContent).not.toContain('Upload failed')
expect(toasts.add).not.toHaveBeenCalled()
},
)
})
+22 -27
View File
@@ -18,7 +18,7 @@ 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 }) }
return { ...actual, useToast: () => ({ add: toastAdd, remove: vi.fn() }) }
})
type Row = [
@@ -455,32 +455,28 @@ describe('Default, many images', () => {
expect(document.body.querySelector('[data-testid="image-count"]')?.textContent).toBe(
'12 images',
)
expect(document.body.textContent).not.toContain('Select all')
expect(document.body.querySelector('input[type="checkbox"], [role="checkbox"]')).toBeNull()
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 disabled and inert', async () => {
it('renders the header Upload button enabled', async () => {
await mountPage()
const upload = findButton('Upload')!
expect(upload.disabled).toBe(true)
upload.click()
await flushPromises()
expect(panel()).toBeNull()
expect(findButton('Upload')!.disabled).toBe(false)
})
it('renders the mobile pinned Upload button disabled, full width, 48 px', async () => {
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(true)
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(
@@ -614,7 +610,7 @@ describe('The query string is the source of truth', () => {
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'],
['?view=list', 'filter=all&sort=newest&limit=100'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'],
['?filter=duplicates', 'filter=all&sort=newest&limit=100'],
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'],
])('opening /media%s requests %s', async (url, request) => {
await mountPage({}, `/media${url}`)
@@ -638,7 +634,7 @@ describe('The query string is the source of truth', () => {
document.body.innerHTML = ''
await mountPage({}, '/media?filter=duplicates')
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
})
it('drops unknown values from the URL', async () => {
@@ -703,23 +699,20 @@ describe('The query string is the source of truth', () => {
})
})
describe('The disabled Duplicates tab', () => {
it('does not become selected, does not navigate and sends no request', async () => {
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(true)
expect(duplicates.hasAttribute('data-disabled')).toBe(true)
expect(duplicates.hasAttribute('disabled')).toBe(false)
duplicates.focus()
duplicates.click()
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
press(duplicates, 'Enter')
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await flushPromises()
expect(duplicates.getAttribute('aria-selected')).toBe('false')
expect(tab('All').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({})
expect(listCalls()).toHaveLength(1)
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
})
})
@@ -799,7 +792,7 @@ describe('Empty library and no results', () => {
expect(document.body.textContent).toContain(
'Upload your first image. You can use it in any article.',
)
expect(findButton('Upload an image')!.disabled).toBe(true)
expect(findButton('Upload an image')!.disabled).toBe(false)
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB each')
})
@@ -885,7 +878,7 @@ describe('Hover, focus and keyboard', () => {
expect(document.activeElement).toBe(list[2])
})
it('opens the panel on Enter and does nothing on Space', async () => {
it('opens the panel on Enter and Space does not open it', async () => {
await mountPage()
cards()[1]!.focus()
@@ -1000,7 +993,7 @@ describe('Detail panel', () => {
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 disables Review duplicates', async () => {
it('duplicate: looks up the hash, excludes the image itself and Review duplicates opens the tab', async () => {
await mountPage()
await clickCard('floorboards.jpg')
@@ -1012,10 +1005,12 @@ describe('Detail panel', () => {
'floorboards-copy.jpg and IMG_2208.jpg have the same content.',
)
const review = findButton('Review duplicates', alert)!
expect(review.disabled).toBe(true)
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 () => {
+587
View File
@@ -0,0 +1,587 @@
import { vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import MediaView from '../MediaView.vue'
type Row = [
number,
string,
number,
number | null,
number | null,
string,
number,
string,
string,
boolean,
string,
]
const ROWS: Row[] = [
[
1,
'north-wall.jpg',
4089446,
4032,
3024,
'image/jpeg',
2,
'Marta Alves',
'2026-09-12T12:00:00Z',
false,
'0a',
],
[
2,
'kitchen-after.webp',
1258291,
2400,
1600,
'image/webp',
3,
'Inès Duarte',
'2026-09-08T12:00:00Z',
false,
'1b',
],
[
3,
'kitchen-before.webp',
1153434,
2400,
1600,
'image/webp',
1,
'Inès Duarte',
'2026-09-08T11:00:00Z',
false,
'2c',
],
[
4,
'logo-renew.svg',
8192,
null,
null,
'image/svg+xml',
5,
'jonas',
'2026-09-05T12:00:00Z',
false,
'3d',
],
[
5,
'floorboards-copy.jpg',
2516582,
3600,
2400,
'image/jpeg',
3,
'Marta Alves',
'2026-09-02T12:00:00Z',
true,
'0e',
],
[
6,
'IMG_2208.jpg',
2516582,
3600,
2400,
'image/jpeg',
2,
'jonas',
'2026-08-30T12:00:00Z',
true,
'0e',
],
[
7,
'IMG_4471.jpg',
3250585,
3600,
2400,
'image/jpeg',
2,
'Marta Alves',
'2026-08-29T12:00:00Z',
true,
'2f',
],
[
8,
'window-frame.jpg',
1887437,
3000,
2000,
'image/jpeg',
0,
'Inès Duarte',
'2026-08-28T12:00:00Z',
false,
'3a',
],
[
9,
'stone-wall-detail.jpg',
3250585,
3600,
2400,
'image/jpeg',
1,
'jonas',
'2026-08-21T12:00:00Z',
true,
'2f',
],
[
10,
'bathroom-tiles.png',
880640,
1800,
1200,
'image/png',
0,
'Marta Alves',
'2026-08-04T12:00:00Z',
false,
'1b',
],
[
11,
'lime-plaster.jpg',
2202009,
3200,
2133,
'image/jpeg',
2,
'Inès Duarte',
'2026-07-19T12:00:00Z',
false,
'1c',
],
[
12,
'floorboards.jpg',
2516582,
3600,
2400,
'image/jpeg',
4,
'Inès Duarte',
'2026-06-14T12:00:00Z',
true,
'0e',
],
]
export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 }
export const MEGABYTE = 1024 * 1024
export function fixture(ids?: number[]): MediaItem[] {
return ROWS.filter(([id]) => !ids || ids.includes(id)).map(
([
id,
filename,
size,
width,
height,
content_type,
used_in,
uploaded_by,
created_at,
duplicate,
sha256,
]) => ({
id,
filename,
url: `/media/${filename}`,
size,
content_type,
width,
height,
sha256,
uploaded_by,
created_at,
used_in,
duplicate,
}),
)
}
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
return { items, next_cursor: null, counts }
}
export function deferred<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 }
}
export interface Handlers {
list?: (path: string) => unknown
usage?: (id: number) => unknown
hash?: (path: string) => unknown
remove?: (id: number) => unknown
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
}
export function libraryHandlers(
outcome: (id: number) => unknown = () => undefined,
ids?: number[],
): Handlers {
const gone = new Set<number>()
return {
list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))),
remove: async (id) => {
try {
const result = await outcome(id)
gone.add(id)
return result
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
gone.add(id)
}
throw error
}
},
}
}
export const kit = {
requestMock: vi.fn(),
wrapper: null as VueWrapper | null,
}
function dispatcher(handlers: Handlers) {
return (path: string, options?: RequestInit) => {
const method = options?.method ?? 'GET'
if (method === 'GET' && path.startsWith('/v1/media/?hash=')) {
return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path)
}
if (method === 'GET' && path.startsWith('/v1/media/?')) {
return (handlers.list ?? (() => listBody(fixture())))(path)
}
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
if (method === 'GET' && usage) {
return (handlers.usage ?? (() => []))(Number(usage[1]))
}
if (method === 'POST' && path === '/v1/media/merge') {
return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))(
JSON.parse(String(options?.body)),
)
}
const single = /^\/v1\/media\/(\d+)$/.exec(path)
if (method === 'DELETE' && single) {
return (handlers.remove ?? (() => undefined))(Number(single[1]))
}
throw new Error(`Unexpected request: ${method} ${path}`)
}
}
function fetchFrom(dispatch: ReturnType<typeof dispatcher>) {
return vi.fn(async (path: string, init?: RequestInit) => {
const reply = (status: number, body?: unknown) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
text: async () => '',
})
try {
const body = await dispatch(path, init)
return reply(body === undefined ? 204 : 200, body)
} catch (error) {
if (error instanceof ApiError) {
return reply(error.status)
}
throw error
}
})
}
export async function mountPage(
handlers: Handlers = {},
url = '/media',
width = DESKTOP_WIDTH,
options: { realApi?: boolean } = {},
) {
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)
if (options.realApi) {
kit.requestMock = fetchFrom(dispatch)
vi.stubGlobal('fetch', kit.requestMock)
} else {
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: kit.requestMock,
})
}
await router.push(url)
kit.wrapper = mount(MediaView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return kit.wrapper
}
export async function unmountPage() {
kit.wrapper?.unmount()
kit.wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
await router.push('/login')
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function cards() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-media-card]'))
}
export function cardFor(filename: string) {
return cards().find((card) => textOf(card).includes(filename))!
}
export function tabs() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
}
export function tab(label: string) {
return tabs().find((element) => textOf(element).startsWith(label))!
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export function panel() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function listCalls() {
return kit.requestMock.mock.calls.filter(
([path]) =>
typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='),
)
}
export function lastListPath() {
return listCalls().at(-1)![0] as string
}
export function callsTo(method: string, pattern: RegExp) {
return kit.requestMock.mock.calls.filter(
([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path),
)
}
export function press(element: Element, key: string) {
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
export function pressOnWindow(key: string) {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
}
export async function settle() {
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 10))
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 10))
await flushPromises()
}
export function announcer() {
return textOf(document.body.querySelector('[data-testid="media-announcer"]'))
}
export function checkboxFor(filename: string) {
return document.body.querySelector<HTMLElement>(
`[role="checkbox"][aria-label="Select ${filename}"]`,
)!
}
export function selectAllCheckbox() {
return document.body
.querySelector('[data-testid="image-count"]')!
.parentElement!.querySelector<HTMLElement>('[role="checkbox"]')!
}
export function tableHeaderCheckbox() {
return document.body.querySelector<HTMLElement>('[role="checkbox"][aria-label="Select all"]')!
}
export function bulkBar() {
return document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')
}
export function fileWith(name: string, type: string, size?: number, content = name) {
const file = new File([content], name, { type })
if (size !== undefined) {
Object.defineProperty(file, 'size', { value: size })
}
return file
}
export function hexOf(content: string) {
return Array.from(new TextEncoder().encode(content), (byte) =>
byte.toString(16).padStart(2, '0'),
)
.join('')
.padEnd(64, '0')
.slice(0, 64)
}
export function stubDigest() {
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0'))
.join('')
.padEnd(64, '0')
.slice(0, 64)
return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer
})
vi.stubGlobal('crypto', { subtle: { digest } })
return digest
}
export class FakeXhr extends EventTarget {
static instances: FakeXhr[] = []
upload = new EventTarget()
method = ''
url = ''
status = 0
responseText = ''
withCredentials = false
body: FormData | null = null
aborted = false
finished = false
headers: Record<string, string> = {}
open(method: string, url: string) {
this.method = method
this.url = url
}
send(body: FormData) {
this.body = body
FakeXhr.instances.push(this)
}
abort() {
this.aborted = true
this.finished = true
this.dispatchEvent(new Event('abort'))
}
getResponseHeader(name: string) {
return this.headers[name.toLowerCase()] ?? null
}
progress(loaded: number, total: number) {
this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total }))
}
respond(status: number, headers: Record<string, string> = {}, text = '') {
this.status = status
this.headers = Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
)
this.responseText = text
this.finished = true
this.dispatchEvent(new Event('load'))
}
fail() {
this.finished = true
this.dispatchEvent(new Event('error'))
}
get fileName() {
return (this.body?.get('file') as File).name
}
}
export function stubXhr() {
FakeXhr.instances = []
vi.stubGlobal('XMLHttpRequest', FakeXhr)
}
export function inFlight() {
return FakeXhr.instances.filter((xhr) => !xhr.finished)
}
export function xhrFor(name: string) {
return FakeXhr.instances.find((xhr) => xhr.fileName === name)!
}
export function fileInput() {
return document.body.querySelector<HTMLInputElement>('input[type="file"]')!
}
export async function chooseFiles(files: File[]) {
const input = fileInput()
Object.defineProperty(input, 'files', { value: files, configurable: true })
input.dispatchEvent(new Event('change', { bubbles: true }))
await settle()
}
export function dragEvent(type: string, types: string[], files: File[] = []) {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types, files } })
document.body.dispatchEvent(event)
return event
}
export function uploadPanel() {
return document.body.querySelector<HTMLElement>('[data-testid="media-upload-panel"]')
}
export function uploadRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="upload-row"]'))
}
export function rowTexts() {
return uploadRows().map(textOf)
}