459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
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 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<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&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<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 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<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)
|
||
})
|
||
})
|