feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)

This commit is contained in:
fsylvain committed 2026-10-05 23:57:50 +02:00
1 parent 1a55a39425
commit 2303b12377
26 files changed
+3856 -83

No files matched your search

@@ -0,0 +1,217 @@
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 { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
deferred,
dialog,
findButton,
fixture,
listBody,
listCalls,
mountPage,
settle,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
import { FAILED_COPY, previewOf, purgeResult } from './purgeTestKit'
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 DUPLICATES = '/media?filter=duplicates'
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
const COUNTS = { all: 12, unused: 2, duplicates: 2 }
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
return (path) =>
path.includes('filter=duplicates')
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
: listBody(fixture())
}
function purgeButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="purge-duplicates"]')
}
async function openFromMedia(handlers: Handlers = {}) {
await mountPage({ list: duplicatesList(), preview: () => previewOf(), ...handlers }, DUPLICATES)
purgeButton()!.focus()
purgeButton()!.click()
await flushPromises()
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountPage)
describe('The Duplicates toolbar button', () => {
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
expect(purgeButton()).not.toBeNull()
expect(textOf(purgeButton())).toBe('Purge duplicates')
expect(purgeButton()!.querySelector('svg')).not.toBeNull()
})
it.each([
['the filter is All', '/media', 'owner', duplicatesList()],
['the filter is Unused', '/media?filter=unused', 'owner', duplicatesList()],
['there is no group', DUPLICATES, 'owner', duplicatesList([])],
['the role is not owner or admin', DUPLICATES, 'editor', duplicatesList()],
])('is hidden when %s', async (_name, url, role, list) => {
await mountPage({ list }, url, undefined, { role })
expect(purgeButton()).toBeNull()
})
it('is disabled offline, opens nothing and keeps the existing banner', async () => {
await mountPage({ list: duplicatesList(), preview: () => previewOf() }, DUPLICATES)
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
purgeButton()!.click()
await flushPromises()
expect(purgeButton()!.disabled).toBe(true)
expect(document.body.textContent).toContain("You're offline.")
expect(dialog()).toBeNull()
expect(callsTo('GET', /purge-preview/)).toHaveLength(0)
})
it('sits between the sort menu and the view toggle, full width and 44 px high when stacked', async () => {
await mountPage({ list: duplicatesList() }, DUPLICATES, MOBILE_WIDTH)
const sort = document.body.querySelector('[aria-label="Sort"]')!
const toggle = document.body.querySelector('[role="group"][aria-label="View"]')!
const button = purgeButton()!
expect(sort.compareDocumentPosition(button)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(button.compareDocumentPosition(toggle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(button.className).toContain('h-11')
expect(button.className).toContain('w-full')
})
})
describe('The purge dialog from Media', () => {
it('opens in the loading state and sends the preview request', async () => {
const pending = deferred<unknown>()
await openFromMedia({ preview: () => pending.promise })
expect(callsTo('GET', /purge-preview/)).toHaveLength(1)
expect(textOf(dialog())).toContain('Purge duplicates?')
expect(findButton('Purge duplicates', dialog()!)!.disabled).toBe(true)
})
it('confirms with the preview fingerprint and shows the same summary', async () => {
await openFromMedia()
expect(textOf(dialog()!.querySelector('[data-testid="purge-summary"]'))).toBe(
'4 groups · 9 copies will be deleted · 12 articles will be updated',
)
findButton('Purge 4 groups', dialog()!)!.click()
await flushPromises()
const [call] = callsTo('POST', /\/v1\/media\/purge$/)
expect(JSON.parse(call![1].body)).toEqual({ fingerprint: 'ab12' })
})
it('shows the error notice and keeps the dialog open on a failure', async () => {
await openFromMedia({ purge: () => Promise.reject(new ApiError(500)) })
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(FAILED_COPY)
})
it('toasts, reloads the list and counts, and drops the button with the last group', async () => {
let purged = false
await openFromMedia({
list: (path) =>
path.includes('filter=duplicates')
? listBody(fixture(purged ? [] : DUPLICATE_IDS), {
all: 12,
unused: 2,
duplicates: purged ? 0 : 2,
})
: listBody(fixture()),
purge: () => {
purged = true
return purgeResult()
},
})
const reloads = listCalls().length
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Duplicates purged',
description: '9 copies merged. 12 articles updated.',
duration: 5000,
}),
)
expect(listCalls().length).toBe(reloads + 1)
expect(document.body.textContent).toContain('No duplicates')
expect(purgeButton()).toBeNull()
})
it('reloads the list when Close follows a partial result', async () => {
await openFromMedia({
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
})
findButton('Purge 4 groups', dialog()!)!.click()
await settle()
expect(toasts.add).not.toHaveBeenCalled()
const reloads = listCalls().length
findButton('Close', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(listCalls().length).toBe(reloads + 1)
})
it('only closes the dialog when Review in Duplicates is followed', async () => {
await openFromMedia()
const before = router.currentRoute.value.fullPath
dialog()!.querySelector('a')!.click()
await settle()
expect(dialog()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe(before)
})
})
describe('Merge into this one stays', () => {
it('still sends the merge request with the ids', async () => {
const merge = vi.fn(() => ({ articles_updated: 5, merged: 2 }))
await mountPage({ list: duplicatesList(), merge }, DUPLICATES)
const first = findButton('Merge into this one')!
first.click()
await flushPromises()
findButton('Merge 2 copies', dialog()!)!.click()
await settle()
expect(merge).toHaveBeenCalledWith({ keep_id: 12, merge_ids: [5, 6] })
})
})