385 lines
9.6 KiB
TypeScript
385 lines
9.6 KiB
TypeScript
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> = {}): 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: '',
|
|
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<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 }
|
|
}
|