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] })
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user