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

459 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { 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)
})
})