feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)

This commit is contained in:
fsylvain committed 2026-10-05 23:57:50 +02:00
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] })
})
})
@@ -0,0 +1,529 @@
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()
})
})
@@ -15,6 +15,8 @@ import {
stubClipboard,
upToDate,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -41,6 +43,9 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
if (path === STATUS_PATH) {
return Promise.resolve(status)
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
@@ -0,0 +1,466 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import {
INVALID_DAYS_COPY,
OFFLINE_COPY,
PREVIEW_PATH,
SAVE_FAILED_COPY,
SETTINGS_PATH,
bodyOf,
callsTo,
cardEl,
commitByBlur,
commitByEnter,
daysError,
daysInput,
deferredValue,
enabledSettings,
findButton,
goOffline,
lastLine,
mountSettings,
nextLine,
purgeButton,
purgeKit,
purgeSettings,
saveStatus,
switchEl,
switchError,
textOf,
typeInto,
unmountSettings,
type SettingsCall,
} 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 =
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
function patching(onPatch: (call: SettingsCall) => unknown, read = () => enabledSettings()) {
return (call: SettingsCall) => (call.method === 'PATCH' ? onPatch(call) : read())
}
function savedInterval(call: SettingsCall) {
const days = (call.body as { auto_purge_interval_days: number }).auto_purge_interval_days
return enabledSettings({ auto_purge_interval_days: days })
}
beforeEach(() => {
toasts.add.mockClear()
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
})
afterEach(unmountSettings)
describe('Media cleanup card: default and on', () => {
it('Default: off, never run', async () => {
await mountSettings({ settings: () => purgeSettings() })
const card = cardEl()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(1)
expect(textOf(card.querySelector('h2'))).toBe('Media cleanup')
expect(textOf(card)).toContain(DESCRIPTION)
expect(textOf(card)).toContain('Automatic purge')
expect(textOf(card)).toContain('Runs in the background. Off by default.')
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(daysInput()).toBeNull()
expect(textOf(card)).not.toContain('Every')
expect(nextLine()).toBeNull()
expect(lastLine()).toBe('Never run')
expect(card.querySelector('[role="separator"]')).not.toBeNull()
expect(textOf(card)).toContain('Purge now')
expect(textOf(card)).toContain(
'Merge every group of duplicates that has an image in use, right away.',
)
expect(purgeButton().disabled).toBe(false)
expect(findButton('Save', card)).toBeUndefined()
})
it('On: 30 days, last run', async () => {
await mountSettings({ settings: () => enabledSettings() })
const card = cardEl()
expect(switchEl().getAttribute('aria-checked')).toBe('true')
expect(textOf(card)).toContain('Every')
expect(textOf(card)).toContain('Between 1 and 365 days.')
expect(daysInput()!.value).toBe('30')
expect(textOf(card)).toContain('days')
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
})
it.each([
[3, 5, 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'],
[1, 1, 'Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated'],
[0, 0, 'Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated'],
])(
'pluralises the Last purge line for %i merged and %i articles',
async (merged, articles, line) => {
await mountSettings({
settings: () =>
enabledSettings({
last_purge_merged: merged,
last_purge_articles_updated: articles,
}),
})
expect(lastLine()).toBe(line)
},
)
})
describe('Media cleanup card: the switch', () => {
it('turning it on sends exactly the flag, shows Saving, then Saved for 2 s', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const patch = deferredValue<unknown>()
await mountSettings({
settings: patching(
() => patch.promise,
() => purgeSettings(),
),
})
switchEl().click()
await flushPromises()
const [call] = callsTo('PATCH', SETTINGS_PATH)
expect(call![0]).toBe(SETTINGS_PATH)
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: true })
expect(Object.keys(bodyOf(call!))).toEqual(['auto_purge_enabled'])
expect(saveStatus()).toBe('Saving…')
expect(
cardEl()
.querySelector('[data-testid="cleanup-save-status"]')!
.getAttribute('aria-live'),
).toBe('polite')
expect(switchEl().disabled).toBe(false)
patch.resolve(
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
)
await flushPromises()
expect(saveStatus()).toBe('Saved')
expect(daysInput()!.value).toBe('30')
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
vi.advanceTimersByTime(1999)
await flushPromises()
expect(saveStatus()).toBe('Saved')
vi.advanceTimersByTime(1)
await flushPromises()
expect(saveStatus()).toBe('')
})
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
await mountSettings({
settings: patching(() =>
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
),
})
switchEl().click()
await flushPromises()
const [call] = callsTo('PATCH', SETTINGS_PATH)
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: false })
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(daysInput()).toBeNull()
expect(nextLine()).toBeNull()
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
})
})
describe('Media cleanup card: the days field', () => {
it.each([
['45', 'blur', 45],
['45', 'Enter', 45],
['1', 'Enter', 1],
['365', 'blur', 365],
])('saves %s once when it %s commits', async (typed, commit, sent) => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, typed)
await (commit === 'blur' ? commitByBlur(input) : commitByEnter(input))
const calls = callsTo('PATCH', SETTINGS_PATH)
expect(calls).toHaveLength(1)
expect(bodyOf(calls[0]!)).toEqual({ auto_purge_interval_days: sent })
expect(saveStatus()).toBe('Saved')
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
})
it('sends one request when Enter is followed by the blur', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(1)
})
it('sends nothing for an unchanged value', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
input.focus()
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(saveStatus()).toBe('')
})
it.each(['', '0', '366', '400', '1.5'])(
'rejects "%s" without sending anything and keeps the text',
async (typed) => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, typed)
await commitByBlur(input)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(input.value).toBe(typed)
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
expect(daysError()!.querySelector('svg')).not.toBeNull()
expect(saveStatus()).toBe('')
},
)
it('also validates on Enter', async () => {
await mountSettings({ settings: patching(savedInterval) })
await typeInto(daysInput()!, '366')
await commitByEnter(daysInput()!)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
})
it('clears the validation error once the value is valid and sends it', async () => {
await mountSettings({ settings: patching(savedInterval) })
const input = daysInput()!
await typeInto(input, '400')
await commitByBlur(input)
expect(daysError()).not.toBeNull()
await typeInto(input, '60')
await commitByEnter(input)
expect(daysError()).toBeNull()
expect(input.getAttribute('aria-invalid')).toBeNull()
expect(bodyOf(callsTo('PATCH', SETTINGS_PATH)[0]!)).toEqual({
auto_purge_interval_days: 60,
})
})
})
describe('Media cleanup card: save failures', () => {
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network error', () => Promise.reject(new NetworkError())],
])('reverts the days field after %s and shows the message under it', async (_name, failure) => {
await mountSettings({ settings: patching(failure) })
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
expect(input.value).toBe('30')
expect(textOf(daysError())).toBe(SAVE_FAILED_COPY)
expect(saveStatus()).toBe('')
})
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
switchEl().click()
await flushPromises()
expect(switchEl().getAttribute('aria-checked')).toBe('true')
expect(textOf(switchError())).toBe(SAVE_FAILED_COPY)
expect(saveStatus()).toBe('')
})
it('removes the message on the next successful save', async () => {
let failing = true
await mountSettings({
settings: patching((call) =>
failing ? Promise.reject(new ApiError(500)) : savedInterval(call),
),
})
const input = daysInput()!
await typeInto(input, '45')
await commitByEnter(input)
expect(daysError()).not.toBeNull()
failing = false
await typeInto(input, '45')
await commitByEnter(input)
expect(daysError()).toBeNull()
expect(saveStatus()).toBe('Saved')
})
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
const refresh = vi.spyOn(useSessionStore(), 'refresh')
purgeKit.meRole = 'editor'
switchEl().click()
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(1)
expect(toasts.add).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/settings')
expect(switchError()).toBeNull()
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
expect(switchEl().disabled).toBe(true)
})
})
describe('Media cleanup card: read-only', () => {
it('never reads the settings and shows placeholders, disabled, with the info alert', async () => {
await mountSettings({ settings: () => enabledSettings() }, { role: 'editor' })
const card = cardEl()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
expect(switchEl().getAttribute('aria-checked')).toBe('false')
expect(switchEl().disabled).toBe(true)
expect(daysInput()!.value).toBe('30')
expect(daysInput()!.disabled).toBe(true)
expect(lastLine()).toBe('Never run')
expect(nextLine()).toBeNull()
expect(textOf(card.querySelector('[role="status"]'))).toBe(
'Only owners and admins can change this.',
)
expect(card.querySelector('[role="separator"]')).toBeNull()
expect(textOf(card)).not.toContain('Purge now')
expect(findButton('Purge duplicates', card)).toBeUndefined()
switchEl().click()
await commitByBlur(daysInput()!)
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
})
it('treats a missing role the same way', async () => {
await mountSettings({}, { role: undefined })
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
})
})
describe('Media cleanup card: loading and failure to load', () => {
it('shows the skeleton only after 150 ms, with aria-busy and a disabled button', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferredValue<unknown>()
await mountSettings({ settings: () => pending.promise })
vi.advanceTimersByTime(149)
await flushPromises()
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(cardEl().getAttribute('aria-busy')).toBe('true')
expect(cardEl().querySelector('.animate-pulse')).not.toBeNull()
expect(purgeButton().disabled).toBe(true)
pending.resolve(purgeSettings())
await flushPromises()
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
expect(lastLine()).toBe('Never run')
expect(purgeButton().disabled).toBe(false)
})
it.each([
['a 500', () => Promise.reject(new ApiError(500))],
['a network failure', () => Promise.reject(new NetworkError())],
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
let failing = true
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
const alert = cardEl().querySelector('[role="alert"]')!
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
expect(document.body.querySelector('[data-testid="settings-version"]')).not.toBeNull()
failing = false
findButton('Try again', cardEl())!.click()
await flushPromises()
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
expect(lastLine()).toBe('Never run')
})
})
describe('Media cleanup card: offline and 401', () => {
it('shows the banner and disables the controls, sending nothing', async () => {
await mountSettings({ settings: () => enabledSettings() })
await goOffline()
expect(document.body.textContent).toContain(OFFLINE_COPY)
expect(switchEl().disabled).toBe(true)
expect(daysInput()!.disabled).toBe(true)
expect(purgeButton().disabled).toBe(true)
switchEl().click()
purgeButton().click()
await flushPromises()
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
it('goes to the login page on a 401 and shows no copy of its own', async () => {
await mountSettings(
{ settings: () => Promise.reject(new ApiError(401)) },
{ role: 'owner', realApi: true },
)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({
redirect: '/settings',
reason: 'expired',
})
expect(textOf(cardEl())).not.toContain(UNREACHABLE_COPY)
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
})
it('shows no save failure for a 401 on a change', async () => {
await mountSettings(
{ settings: patching(() => Promise.reject(new ApiError(401))) },
{ role: 'owner', realApi: true },
)
switchEl().click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
expect(textOf(cardEl())).not.toContain(SAVE_FAILED_COPY)
})
})
@@ -32,6 +32,8 @@ import {
updateSucceeded,
verifying,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -135,6 +137,9 @@ async function mountSettings(options: MountOptions) {
}
const handler = handlers[path]
if (!handler) {
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
}
return handler()
@@ -262,6 +267,9 @@ function silenceStatusReads() {
if (path === STATUS_PATH) {
return Promise.reject(new NetworkError())
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
}
@@ -650,6 +658,9 @@ describe('Settings, the overlay ends when the site answers', () => {
if (path === STATUS_PATH) {
return answers()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
siteUp = healthy
@@ -21,6 +21,8 @@ import {
startable,
verifying,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -89,6 +91,9 @@ async function mountSettings(options: MountOptions) {
if (path === DOWNLOAD_PATH) {
return downloadHandler()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
@@ -19,6 +19,8 @@ import {
stubClipboard,
upToDate,
visibleButtonLabels,
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -77,6 +79,9 @@ async function mountSettings(options: MountOptions) {
if (path === CHECK_PATH) {
return checkHandler()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
@@ -6,6 +6,7 @@ import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise<unknown>) {
if (path === SETTINGS_PATH) {
return settings()
}
if (path === PURGE_SETTINGS_PATH) {
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
+16 -2
View File
@@ -236,6 +236,8 @@ export interface Handlers {
hash?: (path: string) => unknown
remove?: (id: number) => unknown
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown
}
export function libraryHandlers(
@@ -274,6 +276,12 @@ function dispatcher(handlers: Handlers) {
if (method === 'GET' && path.startsWith('/v1/media/?')) {
return (handlers.list ?? (() => listBody(fixture())))(path)
}
if (method === 'GET' && path === '/v1/media/purge-preview') {
return (handlers.preview ?? (() => undefined))()
}
if (method === 'POST' && path === '/v1/media/purge') {
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
}
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
if (method === 'GET' && usage) {
return (handlers.usage ?? (() => []))(Number(usage[1]))
@@ -316,12 +324,18 @@ export async function mountPage(
handlers: Handlers = {},
url = '/media',
width = DESKTOP_WIDTH,
options: { realApi?: boolean } = {},
options: { realApi?: boolean; role?: string } = {},
) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const dispatch = dispatcher(handlers)
if (options.realApi) {
+351
View File
@@ -0,0 +1,351 @@
import { vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/useMediaPurge'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import SettingsView from '../SettingsView.vue'
export const SETTINGS_PATH = '/v1/media/purge-settings'
export const PREVIEW_PATH = '/v1/media/purge-preview'
export const PURGE_PATH = '/v1/media/purge'
export const STALE_COPY =
'The library changed, so nothing was purged. Check the new summary and try again.'
export const BUSY_COPY = 'A purge is already running.'
export const FAILED_COPY =
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
export const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
export const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
export const OFFLINE_COPY =
"You're offline. Changing automatic purge and purging duplicates need a connection."
export function purgeSettings(overrides: Partial<PurgeSettings> = {}): PurgeSettings {
return {
auto_purge_enabled: false,
auto_purge_interval_days: 30,
last_purge_at: null,
last_purge_merged: 0,
last_purge_articles_updated: 0,
enabled_at: null,
next_purge_at: null,
...overrides,
}
}
export function enabledSettings(overrides: Partial<PurgeSettings> = {}) {
return purgeSettings({
auto_purge_enabled: true,
last_purge_at: '2026-06-14T09:00:00Z',
last_purge_merged: 3,
last_purge_articles_updated: 5,
enabled_at: '2026-06-14T09:00:00Z',
next_purge_at: '2026-07-14T09:00:00Z',
...overrides,
})
}
const GROUPS = [
{
keep_id: 12,
filename: 'floorboards.jpg',
url: '/media/floorboards.jpg',
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 2,
filename: 'kitchen-after.webp',
url: '/media/kitchen-after.webp',
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 11,
filename: 'lime-plaster.jpg',
url: '/media/lime-plaster.jpg',
used_in: 2,
merged: 1,
},
]
export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
return {
groups: 4,
copies: 9,
articles: 12,
list: GROUPS.slice(0, 4),
more: 0,
fingerprint: 'ab12',
...overrides,
}
}
export function manyPreview() {
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
}
export function emptyPreview() {
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
}
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
return previewOf({
groups: 1,
copies,
articles,
list: [{ ...GROUPS[0]!, used_in: usedIn, merged }],
fingerprint: 'one',
})
}
export function purgeResult(overrides: Partial<PurgeResult> = {}): PurgeResult {
return {
groups_total: 4,
groups_purged: 4,
merged: 9,
articles_updated: 12,
failed_groups: 0,
...overrides,
}
}
export interface SettingsCall {
method: string
body: unknown
}
export interface PurgeMocks {
settings?: (call: SettingsCall) => unknown
preview?: () => unknown
purge?: (body: { fingerprint: string }) => unknown
}
export const purgeKit = {
requestMock: vi.fn(),
wrapper: null as VueWrapper | null,
meRole: 'owner' as string | undefined,
getMe: vi.fn(),
}
export function installPurgeApi(mocks: PurgeMocks) {
return (path: string, options?: RequestInit) => {
const method = options?.method ?? 'GET'
if (path === SETTINGS_PATH) {
const body = options?.body ? JSON.parse(String(options.body)) : undefined
return (mocks.settings ?? (() => purgeSettings()))({ method, body })
}
if (path === PREVIEW_PATH && method === 'GET') {
return (mocks.preview ?? (() => previewOf()))()
}
if (path === PURGE_PATH && method === 'POST') {
return (mocks.purge ?? (() => purgeResult()))(JSON.parse(String(options?.body)))
}
return undefined
}
}
function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknown>) {
return vi.fn(async (path: string, init?: RequestInit) => {
const reply = (status: number, body?: unknown) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
text: async () => '',
})
try {
const body = await request(path, init)
return reply(body === undefined ? 204 : 200, body)
} catch (error) {
if (error instanceof ApiError) {
return reply(error.status)
}
throw error
}
})
}
export async function mountSettings(
mocks: PurgeMocks = {},
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
purgeKit.meRole = options.role
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: options.role as never,
}
session.status = 'ready'
const dispatch = installPurgeApi(mocks)
purgeKit.requestMock = vi.fn(async (path: string, init?: RequestInit) => {
if (path === '/v1/settings/') {
return { version: 'v2.2.0-beta' }
}
if (path === '/v1/system/update') {
return new Promise(() => undefined)
}
return dispatch(path, init)
})
purgeKit.getMe = vi.fn(async () => ({
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: purgeKit.meRole,
}))
if (options.realApi) {
const actual =
await vi.importActual<typeof import('@/composables/useApi')>('@/composables/useApi')
vi.mocked(useApi).mockImplementation(actual.useApi)
vi.stubGlobal('fetch', fetchFrom(purgeKit.requestMock))
} else {
vi.mocked(useApi).mockReturnValue({
getMe: purgeKit.getMe,
request: purgeKit.requestMock,
})
}
await router.push('/settings')
purgeKit.wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return purgeKit.wrapper
}
export async function unmountSettings() {
purgeKit.wrapper?.unmount()
purgeKit.wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
await router.push('/login')
document.body.innerHTML = ''
}
export function cardEl() {
return document.body.querySelector<HTMLElement>('[data-testid="media-cleanup-card"]')!
}
export function textOf(element: Element | null | undefined) {
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export function purgeButton() {
return findButton('Purge duplicates', cardEl())!
}
export function switchEl() {
return cardEl().querySelector<HTMLButtonElement>('[role="switch"]')!
}
export function daysInput() {
return cardEl().querySelector<HTMLInputElement>('#auto-purge-days')
}
export function daysError() {
return cardEl().querySelector('[data-testid="cleanup-days-error"]')
}
export function switchError() {
return cardEl().querySelector('[data-testid="cleanup-switch-error"]')
}
export function saveStatus() {
return textOf(cardEl().querySelector('[data-testid="cleanup-save-status"]'))
}
export function lastLine() {
return textOf(cardEl().querySelector('[data-testid="cleanup-last"]'))
}
export function nextLine() {
return cardEl().querySelector('[data-testid="cleanup-next"]')
}
export function dialogEl() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function callsTo(method: string, path: string) {
return purgeKit.requestMock.mock.calls.filter(
([called, options]) => called === path && (options?.method ?? 'GET') === method,
)
}
export function bodyOf(call: unknown[]) {
return JSON.parse(String((call[1] as RequestInit).body))
}
export async function typeInto(input: HTMLInputElement, value: string) {
input.focus()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function commitByBlur(input: HTMLInputElement) {
input.blur()
await flushPromises()
}
export async function commitByEnter(input: HTMLInputElement) {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
export async function goOffline() {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
}
export function pressEscape(target: Element | null) {
;(target ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
}
export async function openDialog() {
const opener = purgeButton()
opener.focus()
opener.click()
await flushPromises()
return opener
}
export function isBefore(first: Element, second: Element) {
return first.compareDocumentPosition(second) === Node.DOCUMENT_POSITION_FOLLOWING
}
export function groupRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="purge-group"]'))
}
export function deferredValue<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
+12
View File
@@ -134,3 +134,15 @@ export function plainRestartDone(): UpdateStatus {
finished_at: RESTART_FINISHED_AT,
})
}
export const PURGE_SETTINGS_PATH = '/v1/media/purge-settings'
export const DEFAULT_PURGE_SETTINGS = {
auto_purge_enabled: false,
auto_purge_interval_days: 30,
last_purge_at: null,
last_purge_merged: 0,
last_purge_articles_updated: 0,
enabled_at: null,
next_purge_at: null,
}