feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)
This commit is contained in:
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] })
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user