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

+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 }
}