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' import { accessFor } from '@/test/access' 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 { 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 = {}) { 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 { 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 { 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) { 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: '', access: accessFor(options.role), } 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: '', access: accessFor(purgeKit.meRole), })) if (options.realApi) { const actual = await vi.importActual('@/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('[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('button')).find( (button) => textOf(button) === label, ) } export function purgeButton() { return findButton('Purge duplicates', cardEl())! } export function switchEl() { return cardEl().querySelector('[role="switch"]')! } export function daysInput() { return cardEl().querySelector('#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('[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('[data-testid="purge-group"]')) } export function deferredValue() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } }