555 lines
17 KiB
TypeScript
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()
|
|
})
|
|
})
|