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

555 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import {
BUSY_COPY,
FAILED_COPY,
PREVIEW_PATH,
PURGE_PATH,
SETTINGS_PATH,
STALE_COPY,
bodyOf,
callsTo,
deferredValue,
dialogEl,
emptyPreview,
enabledSettings,
findButton,
goOffline,
groupRows,
isBefore,
lastLine,
manyPreview,
mountSettings,
nextLine,
openDialog,
previewOf,
pressEscape,
purgeButton,
purgeResult,
singlePreview,
textOf,
unmountSettings,
type PurgeMocks,
} 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(),
update: vi.fn(),
}))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const DESCRIPTION =
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
const SUMMARY = '4 groups · 9 copies will be deleted · 12 articles will be updated'
const LAST_RUN_LINE = 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'
const AFTER_PURGE_LINE = 'Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated'
async function opened(mocks: PurgeMocks = {}) {
await mountSettings({ settings: () => enabledSettings(), ...mocks })
return openDialog()
}
function buttonLabels() {
return Array.from(dialogEl()!.querySelectorAll('button')).map(textOf)
}
function confirmButton(label: string) {
return findButton(label, dialogEl()!)!
}
function noticeText() {
return textOf(dialogEl()!.querySelector('[role="alert"]'))
}
function afterRefresh(settings = enabledSettings()) {
let reads = 0
return () => (reads++ === 0 ? enabledSettings() : settings)
}
function purgedSettings(overrides = {}) {
return enabledSettings({
last_purge_at: '2026-10-05T09:00:00Z',
last_purge_merged: 9,
last_purge_articles_updated: 12,
next_purge_at: '2026-11-04T09:00:00Z',
...overrides,
})
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountSettings)
describe('Purge dialog: opening', () => {
it('sends the preview request, shows the title, description, focuses Cancel and delays the skeleton', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferredValue<unknown>()
await opened({ preview: () => pending.promise })
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(1)
const box = dialogEl()!
expect(textOf(box)).toContain('Purge duplicates?')
expect(textOf(box)).toContain(DESCRIPTION)
expect(document.activeElement).toBe(findButton('Cancel', box))
vi.advanceTimersByTime(149)
await flushPromises()
expect(box.querySelector('[data-testid="purge-skeleton"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(box.querySelector('[data-testid="purge-skeleton"]')).not.toBeNull()
expect(box.querySelectorAll('.animate-pulse').length).toBeGreaterThan(3)
expect(confirmButton('Purge duplicates').disabled).toBe(true)
expect(buttonLabels()).toEqual(['Cancel', 'Purge duplicates'])
})
it('is not openable while offline', async () => {
await mountSettings({ settings: () => enabledSettings() })
await goOffline()
purgeButton().click()
await flushPromises()
expect(purgeButton().disabled).toBe(true)
expect(dialogEl()).toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
})
})
describe('Purge dialog: the preview', () => {
it('shows the summary, the rows, the link and both buttons', async () => {
await opened()
const box = dialogEl()!
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
const rows = groupRows()
expect(rows).toHaveLength(4)
expect(
rows.map((row) =>
Array.from(row.querySelectorAll('span')).map(textOf).filter(Boolean).join(' '),
),
).toEqual([
'floorboards.jpg Kept · used in 4 articles 2 copies merged',
'IMG_4471.jpg Kept · used in 2 articles 1 copy merged',
'kitchen-after.webp Kept · used in 3 articles 3 copies merged',
'logo-renew.svg Kept · used in 5 articles 3 copies merged',
])
expect(rows[0]!.querySelector('img')!.getAttribute('src')).toBe('/media/floorboards.jpg')
expect(box.querySelector('[data-testid="purge-more"]')).toBeNull()
const link = box.querySelector('a')!
expect(link.getAttribute('href')).toBe('/media?filter=duplicates')
expect(textOf(link)).toBe('Review in Duplicates')
expect(link.querySelector('svg')).not.toBeNull()
expect(buttonLabels()).toEqual(['Cancel', 'Purge 4 groups'])
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
})
it.each([
[previewOf(), SUMMARY, 'Purge 4 groups', 'Kept · used in 4 articles', '2 copies merged'],
[
singlePreview(2, 1, 2),
'1 group · 1 copy will be deleted · 2 articles will be updated',
'Purge 1 group',
'Kept · used in 2 articles',
'1 copy merged',
],
[
singlePreview(1, 1, 1),
'1 group · 1 copy will be deleted · 1 article will be updated',
'Purge 1 group',
'Kept · used in 1 article',
'1 copy merged',
],
])(
'pluralises the summary, the confirm and the rows',
async (preview, summary, confirm, kept, merged) => {
await opened({ preview: () => preview })
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-summary"]'))).toBe(summary)
expect(buttonLabels()).toEqual(['Cancel', confirm])
const row = textOf(groupRows()[0])
expect(row).toContain(kept)
expect(row).toContain(merged)
},
)
it('stops at 5 rows and says how many groups are left', async () => {
await opened({ preview: manyPreview })
expect(groupRows()).toHaveLength(5)
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
'and 3 more groups',
)
expect(buttonLabels()).toEqual(['Cancel', 'Purge 8 groups'])
})
it('says "and 1 more group" for one', async () => {
await opened({ preview: () => ({ ...manyPreview(), more: 1 }) })
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
'and 1 more group',
)
})
it('shows Nothing to purge with a single Close button', async () => {
await opened({ preview: emptyPreview })
const box = dialogEl()!
expect(textOf(box)).toContain('Nothing to purge')
expect(textOf(box)).toContain(
'No duplicate is used in an article, so there is nothing to merge.',
)
expect(box.querySelector('svg')).not.toBeNull()
expect(buttonLabels()).toEqual(['Close'])
expect(box.querySelector('[data-testid="purge-summary"]')).toBeNull()
expect(box.querySelector('a')).toBeNull()
findButton('Close', box)!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the error notice after %s and offers no purge', async (_name, preview) => {
await opened({ preview })
expect(noticeText()).toBe(FAILED_COPY)
expect(confirmButton('Purge duplicates').disabled).toBe(true)
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(false)
expect(dialogEl()!.querySelector('[data-testid="purge-summary"]')).toBeNull()
confirmButton('Purge duplicates').click()
await flushPromises()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
})
})
describe('Purge dialog: leaving', () => {
it('closes with Cancel, sends nothing and returns focus to the opener', async () => {
const opener = await opened()
findButton('Cancel', dialogEl()!)!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('closes with Escape, sends nothing and returns focus to the opener', async () => {
const opener = await opened()
pressEscape(dialogEl())
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('closes with a click on the overlay and returns focus to the opener', async () => {
const opener = await opened()
const overlay = document.body.querySelector('[data-slot="overlay"]')!
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
await flushPromises()
expect(dialogEl()).toBeNull()
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
expect(document.activeElement).toBe(opener)
})
it('follows the review link to the Duplicates tab', async () => {
await opened()
dialogEl()!.querySelector('a')!.click()
await flushPromises()
expect(dialogEl()).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toEqual({
filter: 'duplicates',
})
})
})
describe('Purge dialog: purging', () => {
it('sends exactly the fingerprint of the preview, once', async () => {
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }) })
confirmButton('Purge 4 groups').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(1)
expect(bodyOf(calls[0]!)).toEqual({ fingerprint: 'ab12' })
expect(Object.keys(bodyOf(calls[0]!))).toEqual(['fingerprint'])
})
it('cannot be dismissed while the request runs', async () => {
const pending = deferredValue<unknown>()
await opened({ purge: () => pending.promise })
confirmButton('Purge 4 groups').click()
await flushPromises()
const confirm = confirmButton('Purging…')
expect(confirm.disabled).toBe(true)
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
pressEscape(dialogEl())
document.body.querySelector('[data-slot="overlay"]')?.dispatchEvent(
new MouseEvent('pointerdown', {
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(dialogEl()).not.toBeNull()
pending.resolve(purgeResult())
await flushPromises()
expect(dialogEl()).toBeNull()
})
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
const opener = await opened({
settings: afterRefresh(purgedSettings()),
})
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(dialogEl()).toBeNull()
expect(toasts.add).toHaveBeenCalledTimes(1)
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Duplicates purged',
description: '9 copies merged. 12 articles updated.',
duration: 5000,
}),
)
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(lastLine()).toBe(AFTER_PURGE_LINE)
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
expect(document.activeElement).toBe(opener)
})
it('shows no Next purge when automatic purge is off', async () => {
await opened({
settings: afterRefresh(
purgedSettings({
auto_purge_enabled: false,
next_purge_at: null,
}),
),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(lastLine()).toBe(AFTER_PURGE_LINE)
expect(nextLine()).toBeNull()
})
it('pluralises the toast', async () => {
await opened({
purge: () => purgeResult({ merged: 1, articles_updated: 1 }),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
description: '1 copy merged. 1 article updated.',
}),
)
})
it('emits purged with the counts and then closes', async () => {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn(async (path: string) =>
path === PREVIEW_PATH ? previewOf() : purgeResult(),
),
})
const wrapper = mount(PurgeDuplicatesDialog, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(wrapper.emitted('purged')).toEqual([[purgeResult()]])
expect(wrapper.emitted('close')).toHaveLength(1)
wrapper.unmount()
})
})
describe('Purge dialog: failures', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])(
'keeps the dialog open after %s and retries with the same fingerprint',
async (_name, purge) => {
await opened({
preview: () => previewOf({ fingerprint: 'ab12' }),
purge,
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
const alert = box.querySelector('[role="alert"]')!
expect(textOf(alert)).toBe(FAILED_COPY)
expect(alert.querySelector('svg')).not.toBeNull()
expect(isBefore(alert, box.querySelector('[data-testid="purge-summary"]')!)).toBe(true)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
expect(groupRows()).toHaveLength(4)
expect(findButton('Cancel', box)!.disabled).toBe(false)
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 4 groups').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(2)
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'ab12' })
},
)
it('warns about a partial result, refreshes the preview and the settings, and retries with the new fingerprint', async () => {
let previews = 0
await opened({
settings: afterRefresh(purgedSettings()),
preview: () => (previews++ === 0 ? previewOf() : singlePreview(2, 1, 2)),
purge: () =>
purgeResult({
groups_total: 4,
groups_purged: 3,
merged: 8,
articles_updated: 10,
failed_groups: 1,
}),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
expect(noticeText()).toBe(
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
)
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
'1 group · 1 copy will be deleted · 2 articles will be updated',
)
expect(buttonLabels()).toEqual(['Close', 'Retry'])
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(AFTER_PURGE_LINE)
confirmButton('Retry').click()
await flushPromises()
const calls = callsTo('POST', PURGE_PATH)
expect(calls).toHaveLength(2)
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'one' })
})
it('shows Nothing to purge when a partial result leaves no group', async () => {
let previews = 0
await opened({
preview: () => (previews++ === 0 ? previewOf() : emptyPreview()),
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(textOf(dialogEl())).toContain('Nothing to purge')
expect(buttonLabels()).toEqual(['Close'])
})
it('refreshes the summary after the library changed and confirms with the new fingerprint', async () => {
let previews = 0
const refreshed = previewOf({
groups: 3,
copies: 7,
articles: 10,
list: previewOf().list.slice(0, 3),
fingerprint: 'cd34',
})
await opened({
preview: () => (previews++ === 0 ? previewOf({ fingerprint: 'ab12' }) : refreshed),
purge: () => Promise.reject(new ApiError(409, undefined, STALE_COPY)),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
const box = dialogEl()!
expect(noticeText()).toBe(STALE_COPY)
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
'3 groups · 7 copies will be deleted · 10 articles will be updated',
)
expect(buttonLabels()).toEqual(['Cancel', 'Purge 3 groups'])
expect(toasts.add).not.toHaveBeenCalled()
expect(lastLine()).toBe(LAST_RUN_LINE)
confirmButton('Purge 3 groups').click()
await flushPromises()
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({
fingerprint: 'cd34',
})
})
it('shows the server text when a purge is already running', async () => {
await opened({
purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)),
})
confirmButton('Purge 4 groups').click()
await flushPromises()
expect(noticeText()).toBe(BUSY_COPY)
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
expect(dialogEl()).not.toBeNull()
})
})