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() 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() 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('[data-testid="duplicate-group"]'), ) } function copiesOf(group: HTMLElement) { return Array.from(group.querySelectorAll('[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 at size 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&page=1&size=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( '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&page=1&size=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('[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 places will be updated. This can't be undone.", ) expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 places 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 places 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() 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) }) })