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() 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() 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('[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() 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] }) }) })