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() 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() 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() 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() 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() }) })