import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import type { CacheStats } from '@/composables/useVariantCache' import { initializeLocale } from '@/i18n' import { useSessionStore } from '@/stores/session' import CachePage from '../CachePage.vue' import { accessFor, customAccess } from '@/test/access' import type { Access } from '@/lib/permissions' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const toasts = vi.hoisted(() => ({ add: vi.fn() })) vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => toasts } }) const STATS = 'GET /v1/image-variants/cache' const PURGE = 'POST /v1/image-variants/cache/purge' const MB = 1048576 type Handler = (body?: Record) => Promise const TOP_IMAGES: [string, number, number][] = [ ['north-wall.jpg', 6, 6082765], ['lime-plaster.jpg', 6, 5138022], ['kitchen-after.webp', 5, 4613734], ['kitchen-before.webp', 5, 4089446], ['floorboards.jpg', 5, 3774873], ['IMG_4471.jpg', 4, 3250585], ['stone-wall-detail.jpg', 4, 2936012], ['bathroom-tiles.png', 4, 2726297], ['roof-lantern.jpg', 3, 2411724], ['chimney-stack.jpg', 3, 2202009], ] function stats(overrides: Partial = {}): CacheStats { return { generated_files: 1284, disk_used: 222298112, variants_defined: 6, variants_enabled: 5, variants_disabled: 1, rejected_requests: 37, rejected_window_days: 7, unused: { files: 312, bytes: 42991616, days: 30 }, by_variant: [ { name: 'hero', enabled: true, files: 244, bytes: 88080384 }, { name: 'card', enabled: true, files: 318, bytes: 60817408 }, { name: 'print', enabled: false, files: 74, bytes: 22020096 }, { name: 'thumb', enabled: true, files: 412, bytes: 22020096 }, { name: 'square', enabled: true, files: 140, bytes: 19922944 }, { name: 'og', enabled: true, files: 96, bytes: 9437184 }, ], top_images: TOP_IMAGES.map(([filename, files, bytes], index) => ({ id: index + 1, filename, url: `/static/uploadedImages/${filename}`, files, bytes, })), ...overrides, } } function emptyStats() { return stats({ generated_files: 0, disk_used: 0, unused: { files: 0, bytes: 0, days: 30 }, by_variant: [], top_images: [], }) } function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } let requestMock: ReturnType let wrapper: VueWrapper | null = null function setViewport(desktop: boolean) { window.matchMedia = ((query: string) => ({ matches: desktop, media: query, onchange: null, addEventListener: () => undefined, removeEventListener: () => undefined, addListener: () => undefined, removeListener: () => undefined, dispatchEvent: () => false, })) as unknown as typeof window.matchMedia } async function mountPage( options: { desktop?: boolean role?: string | null access?: Access routes?: Record } = {}, ) { const { desktop = true, role = 'owner', routes = {} } = options setViewport(desktop) setActivePinia(createPinia()) const session = useSessionStore() const sessionRole = role ?? undefined session.session = { verified: true, username: 'marta', id: 1, displayName: 'Marta Alves', access: options.access ?? accessFor(sessionRole), } session.status = 'ready' requestMock = vi.fn((path: string, init?: RequestInit) => { const key = `${init?.method ?? 'GET'} ${path}` const handler = routes[key] ?? (key === STATS ? () => Promise.resolve(stats()) : null) if (!handler) { throw new Error(`Unexpected request: ${key}`) } return handler( init?.body ? (JSON.parse(String(init.body)) as Record) : undefined, ) }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, access: accessFor(sessionRole), }), request: requestMock, }) await router.push('/settings/media/cache') const Host = defineComponent({ render: () => h(UApp, null, () => h(CachePage)), }) wrapper = mount(Host, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } function calls(key: string) { return requestMock.mock.calls.filter( ([path, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${path}` === key, ) } function bodyOf(key: string, index = 0) { return JSON.parse(String((calls(key)[index]![1] as RequestInit).body)) as Record< string, unknown > } function buttonIn(root: ParentNode, text: string) { return Array.from(root.querySelectorAll('button')).find( (button) => button.textContent?.trim() === text, ) } function findButton(text: string) { return buttonIn(document.body, text) } function buttonByLabel(label: string) { return document.body.querySelector(`button[aria-label="${label}"]`) } async function click(element: HTMLElement | null | undefined) { element?.click() await flushPromises() } function textOf(element: Element | null | undefined) { if (!element) { return '' } const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const parts: string[] = [] while (walker.nextNode()) { const text = walker.currentNode.textContent?.trim() if (text) { parts.push(text) } } return parts.join(' ') } function alertDialog() { return document.body.querySelector('[role="alertdialog"]') } function tiles() { return Array.from(document.body.querySelectorAll('[data-testid="cache-tile"]')).map((tile) => textOf(tile), ) } function toastCalls() { return toasts.add.mock.calls.map(([source]) => source as Record) } async function typeWord(value: string) { const input = alertDialog()!.querySelector('input')! input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() } function confirmButton() { return buttonIn(alertDialog()!, 'Purge all variants')! } async function unmountPage() { wrapper?.unmount() wrapper = null vi.useRealTimers() document.body.innerHTML = '' await router.push('/login') } beforeEach(() => { toasts.add.mockClear() localStorage.clear() initializeLocale() vi.mocked(useApi).mockReset() }) afterEach(async () => { await unmountPage() localStorage.clear() initializeLocale() }) describe('Rule: Cache screen, access and header', () => { it.each([ ['no role', null], ['role owner but the API answers 403', 'owner'], ])( 'A session whose role is not owner or admin gets the Forbidden state: %s', async (_, role) => { await mountPage({ role, routes: role ? { [STATS]: () => Promise.reject(new ApiError(403)) } : {}, }) expect(document.body.textContent).toContain("You don't have access to this page") expect(document.body.textContent).toContain( "doesn't include it. Ask an owner or admin if you need it.", ) expect(findButton('Go to Home') ?? findButton('Go to Articles')).toBeDefined() expect(document.body.querySelector('[role="tablist"]')).toBeNull() expect(document.body.querySelector('h1:not([data-slot="title"])')).toBeNull() expect(calls(STATS)).toHaveLength(role ? 1 : 0) }, ) it('The header and the tabs', async () => { await mountPage() expect(document.body.querySelector('h1:not([data-slot="title"])')?.textContent).toBe( 'Cache', ) expect(document.body.textContent).toContain( 'Generated variant files. Purged files are generated again on the next request.', ) expect(findButton('Purge unused')).toBeDefined() expect(findButton('Purge all variants')).toBeDefined() const tabs = Array.from(document.body.querySelectorAll('[role="tab"]')) expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Variants', 'Cache']) expect(tabs[1]!.getAttribute('aria-selected')).toBe('true') expect(tabs[1]!.className).toContain('border-primary') expect(tabs[0]!.getAttribute('href')).toBe('/settings/media/variants') expect(calls(STATS)).toHaveLength(1) }) it('Activating Variants routes to the variants page', async () => { await mountPage() await click(document.body.querySelector('[role="tab"]')) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings/media/variants'), ) }) it('The desktop breadcrumb reads Settings, Media, Cache', async () => { await mountPage() const items = Array.from( document.body.querySelectorAll('nav[aria-label="breadcrumb"] li'), ).map((item) => item.textContent?.trim()) expect(items.filter(Boolean)).toEqual(['Settings', 'Media', 'Cache']) }) }) describe('Rule: Cache screen, loaded', () => { it('The four stat tiles', async () => { await mountPage() expect(tiles()).toEqual([ 'Generated files 1,284 Across 6 variants', 'Disk used 212 MB Originals not included', 'Variants defined 6 5 enabled, 1 disabled', 'Rejected requests 37 Last 7 days', ]) }) it('The By variant list', async () => { await mountPage() const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-variant-row"]')) expect(rows.map((row) => textOf(row))).toEqual([ 'hero 244 files · 84 MB', 'card 318 files · 58 MB', 'print Disabled 74 files · 21 MB', 'thumb 412 files · 21 MB', 'square 140 files · 19 MB', 'og 96 files · 9 MB', ]) const widths = rows.map((row) => row.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow'), ) expect(widths).toEqual(['100', '69', '25', '25', '23', '11']) const printBar = rows[2]!.querySelector('[data-slot="indicator"]')! expect(printBar.className).toContain('border-accented') expect(rows[0]!.querySelector('[data-slot="indicator"]')!.className).toContain('bg-primary') expect(rows[0]!.querySelector('span.font-mono')!.className).toContain('font-medium') }) it('The Top 10 list on desktop', async () => { await mountPage() const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-image-row"]')) expect(rows).toHaveLength(10) expect(textOf(rows[0])).toBe('1 north-wall.jpg 6 variants 5.8 MB Purge') expect(textOf(rows[9])).toBe('10 chimney-stack.jpg 3 variants 2.1 MB Purge') expect(rows[0]!.querySelector('[data-testid="media-thumb"]')).not.toBeNull() }) it('The Top 10 list on mobile', async () => { await mountPage({ desktop: false }) const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-image-row"]')) expect(textOf(rows[0])).toBe('1 north-wall.jpg 6 variants · 5.8 MB') const purge = buttonByLabel('Purge north-wall.jpg')! expect(purge.textContent?.trim()).toBe('') expect(purge.className).toContain('size-11') }) it('The singular reads 1 file and 1 variant', async () => { await mountPage({ routes: { [STATS]: () => Promise.resolve( stats({ by_variant: [ { name: 'hero', enabled: true, files: 1, bytes: MB, }, ], top_images: [ { id: 1, filename: 'a.jpg', url: '/static/uploadedImages/a.jpg', files: 1, bytes: MB, }, ], }), ), }, }) expect(textOf(document.body.querySelector('[data-testid="cache-variant-row"]'))).toBe( 'hero 1 file · 1 MB', ) expect(textOf(document.body.querySelector('[data-testid="cache-image-row"]'))).toContain( '1 variant', ) }) }) describe('Rule: Cache screen, loading, empty and error', () => { it('Loading', async () => { vi.useFakeTimers() const pending = deferred() await mountPage({ routes: { [STATS]: () => pending.promise } }) await vi.advanceTimersByTimeAsync(149) expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() await vi.advanceTimersByTimeAsync(1) const region = document.body.querySelector('[aria-busy="true"]')! expect(region.getAttribute('aria-label')).toBe('Loading cache') expect(region.querySelectorAll('[data-testid="cache-tile"]')).toHaveLength(4) expect(region.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0) pending.resolve(stats()) await vi.advanceTimersByTimeAsync(0) }) it('A response at 100 ms never draws skeletons', async () => { vi.useFakeTimers() const pending = deferred() await mountPage({ routes: { [STATS]: () => pending.promise } }) await vi.advanceTimersByTimeAsync(100) pending.resolve(stats()) await vi.advanceTimersByTimeAsync(100) expect(document.body.querySelector('[aria-busy="true"]')).toBeNull() expect(tiles()).toHaveLength(4) }) it('Empty cache', async () => { await mountPage({ routes: { [STATS]: () => Promise.resolve(emptyStats()) }, }) expect(tiles()).toEqual([ 'Generated files 0 Nothing cached', 'Disk used 0 MB Nothing cached', 'Variants defined 6 5 enabled, 1 disabled', 'Rejected requests 37 Last 7 days', ]) expect(document.body.textContent).toContain('Nothing generated yet') expect(document.body.textContent).toContain( "Variants are generated on the first request with ?v=, or from an image's Variants tab.", ) expect(findButton('Purge unused')!.disabled).toBe(true) expect(findButton('Purge all variants')!.disabled).toBe(true) expect(document.body.querySelector('[data-testid="cache-by-variant"]')).toBeNull() expect(document.body.querySelector('[data-testid="cache-top-images"]')).toBeNull() }) it.each([ ['a 500', () => Promise.reject(new ApiError(500))], ['a network error', () => Promise.reject(new NetworkError())], ])('Error loading: %s', async (_, failure) => { let attempt = 0 await mountPage({ routes: { [STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())), }, }) const alert = document.body.querySelector('[role="alert"]')! expect(textOf(alert)).toContain("Couldn't load cache stats") expect(textOf(alert)).toContain('Check your connection and try again.') expect(alert.querySelector('[data-slot="close"]')).toBeNull() expect(tiles()).toHaveLength(0) await click(buttonIn(alert, 'Try again')) expect(calls(STATS)).toHaveLength(2) expect(tiles()).toHaveLength(4) }) it('Purge unused is disabled when nothing is unused', async () => { await mountPage({ routes: { [STATS]: () => Promise.resolve(stats({ unused: { files: 0, bytes: 0, days: 30 } })), }, }) expect(findButton('Purge unused')!.disabled).toBe(true) expect(findButton('Purge all variants')!.disabled).toBe(false) }) }) describe('Rule: Cache screen, purge all with a typed word', () => { it('The dialog opens with the button disabled', async () => { await mountPage() await click(findButton('Purge all variants')) const dialog = alertDialog()! expect(textOf(dialog)).toContain('Purge all variants?') expect(textOf(dialog)).toContain( "This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request.", ) expect(textOf(dialog)).toContain('Type purge to confirm') expect(dialog.querySelector('input')!.className).toContain('font-mono') expect(buttonIn(dialog, 'Cancel')).toBeDefined() expect(confirmButton().disabled).toBe(true) expect(calls(PURGE)).toHaveLength(0) }) it.each([ ['pur', true], ['Purge', true], ['purge', false], ['purged', true], ])('The word unlocks the button: %s', async (typed, disabled) => { await mountPage() await click(findButton('Purge all variants')) await typeWord(typed) expect(confirmButton().disabled).toBe(disabled) }) it('Cancel sends nothing', async () => { await mountPage() await click(findButton('Purge all variants')) await click(buttonIn(alertDialog()!, 'Cancel')) await vi.waitFor(() => expect(alertDialog()).toBeNull()) expect(calls(PURGE)).toHaveLength(0) }) it('Confirming sends the purge', async () => { await mountPage({ routes: { [PURGE]: () => Promise.resolve({ scope: 'all', purged_files: 1284, purged_bytes: 222298112, failed_files: 0, }), }, }) await click(findButton('Purge all variants')) await typeWord('purge') await click(confirmButton()) await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(1)) expect(bodyOf(PURGE)).toEqual({ scope: 'all' }) }) }) describe('Rule: Cache screen, purge unused', () => { it('The dialog states the count from the stats and confirming sends the purge', async () => { await mountPage({ routes: { [PURGE]: () => Promise.resolve({ scope: 'unused', purged_files: 312, purged_bytes: 42991616, failed_files: 0, }), }, }) await click(findButton('Purge unused')) const dialog = alertDialog()! expect(textOf(dialog)).toContain('Purge unused variants?') expect(textOf(dialog)).toContain( "This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request.", ) expect(dialog.querySelector('input')).toBeNull() expect(buttonIn(dialog, 'Cancel')).toBeDefined() await click(buttonIn(dialog, 'Purge 312 files')) await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(1)) expect(bodyOf(PURGE)).toEqual({ scope: 'unused' }) }) }) describe('Rule: Cache screen, purging, done and failed', () => { async function startPurgeAll(routes: Record) { await mountPage({ routes }) await click(findButton('Purge all variants')) await typeWord('purge') await click(confirmButton()) } it('Purging', async () => { const pending = deferred() await startPurgeAll({ [PURGE]: () => pending.promise }) const banner = document.body.querySelector('[data-testid="cache-purge-banner"]')! expect(textOf(banner)).toContain('Purging 1,284 files') expect(banner.getAttribute('aria-live')).toBe('polite') expect(banner.querySelector('[role="progressbar"]')).not.toBeNull() expect(findButton('Purge unused')!.disabled).toBe(true) expect(findButton('Purge all variants')!.disabled).toBe(true) expect(buttonByLabel('Purge north-wall.jpg')!.disabled).toBe(true) expect(document.body.querySelector('.opacity-60')).not.toBeNull() expect(document.body.querySelector('[role="tablist"]')).not.toBeNull() }) it('Purge done after purge all', async () => { let reads = 0 await startPurgeAll({ [STATS]: () => Promise.resolve(reads++ === 0 ? stats() : emptyStats()), [PURGE]: () => Promise.resolve({ scope: 'all', purged_files: 1284, purged_bytes: 222298112, failed_files: 0, }), }) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) expect(toastCalls()[0]).toMatchObject({ color: 'success', title: 'Purged 1,284 files, freed 212 MB', }) await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2)) await vi.waitFor(() => expect(document.body.textContent).toContain('Nothing generated yet')) }) it('Purge done after purge unused', async () => { await mountPage({ routes: { [PURGE]: () => Promise.resolve({ scope: 'unused', purged_files: 312, purged_bytes: 42991616, failed_files: 0, }), }, }) await click(findButton('Purge unused')) await click(buttonIn(alertDialog()!, 'Purge 312 files')) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) expect(toastCalls()[0]).toMatchObject({ title: 'Purged 312 files, freed 41 MB', }) await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2)) }) it('Purge failed offers Try again without asking for the word again', async () => { await startPurgeAll({ [PURGE]: () => Promise.resolve({ scope: 'all', purged_files: 640, purged_bytes: 111149056, failed_files: 644, }), }) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) const toast = toastCalls()[0]! expect(toast).toMatchObject({ color: 'error', title: "Purge didn't finish", description: '640 of 1,284 files were removed. Try again.', }) await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2)) const [action] = toast.actions as { label: string onClick: () => void }[] expect(action!.label).toBe('Try again') action!.onClick() await flushPromises() await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(2)) expect(bodyOf(PURGE, 1)).toEqual({ scope: 'all' }) expect(alertDialog()).toBeNull() }) it.each([ [ 'a 400', new ApiError(400), 'Check the request', "A value in the request isn't valid. Reload the page and try again.", ], [ 'a 403', new ApiError(403), "You don't have access", 'Only admins can manage image variants.', ], [ 'a 500', new ApiError(500), 'Something went wrong', "The server couldn't finish. Try again. If it keeps happening, check the server log.", ], [ 'a network error', new NetworkError(), 'Something went wrong', "The server couldn't finish. Try again. If it keeps happening, check the server log.", ], ])('A purge request that errors: %s', async (_, failure, title, description) => { await startPurgeAll({ [PURGE]: () => Promise.reject(failure) }) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) expect(toastCalls()[0]).toMatchObject({ color: 'error', title, description, }) expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull() expect(tiles()).toHaveLength(4) expect(calls(STATS)).toHaveLength(1) }) it('A 401 is left to the API client', async () => { await startPurgeAll({ [PURGE]: () => Promise.reject(new ApiError(401)), }) expect(toastCalls()).toHaveLength(0) expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull() }) }) describe('Rule: Cache screen, purging the variants of one image', () => { it('A row Purge sends the image scope and is done', async () => { await mountPage({ routes: { [PURGE]: () => Promise.resolve({ scope: 'image', purged_files: 6, purged_bytes: 6082765, failed_files: 0, }), }, }) await click(buttonByLabel('Purge north-wall.jpg')) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) expect(bodyOf(PURGE)).toEqual({ scope: 'image', image_id: 1 }) expect(alertDialog()).toBeNull() expect(toastCalls()[0]).toMatchObject({ title: 'Purged 6 files (5.8 MB) for north-wall.jpg', }) await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2)) }) it('While it runs the row shows a loader and the other Purge buttons stay enabled', async () => { const pending = deferred() await mountPage({ routes: { [PURGE]: () => pending.promise } }) await click(buttonByLabel('Purge north-wall.jpg')) expect(buttonByLabel('Purge north-wall.jpg')!.querySelector('.animate-spin')).not.toBeNull() expect(buttonByLabel('Purge lime-plaster.jpg')!.disabled).toBe(false) expect(findButton('Purge all variants')!.disabled).toBe(false) }) it('The image no longer exists', async () => { await mountPage({ routes: { [PURGE]: () => Promise.reject(new ApiError(404)) }, }) await click(buttonByLabel('Purge north-wall.jpg')) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) expect(toastCalls()[0]).toMatchObject({ title: 'Not found', description: 'This variant or image no longer exists. Refresh the page.', }) await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2)) }) it('Failed', async () => { await mountPage({ routes: { [PURGE]: () => Promise.resolve({ scope: 'image', purged_files: 4, purged_bytes: 1000, failed_files: 2, }), }, }) await click(buttonByLabel('Purge north-wall.jpg')) await vi.waitFor(() => expect(toastCalls()).toHaveLength(1)) expect(toastCalls()[0]).toMatchObject({ title: "Purge didn't finish", description: '4 of 6 files were removed. Try again.', }) }) }) describe('Rule: Accessibility and vocabulary', () => { it('Roles, labels and vocabulary', async () => { await mountPage() expect(document.body.querySelector('[role="tablist"]')).not.toBeNull() expect(document.body.querySelectorAll('[role="tab"]')).toHaveLength(2) const text = document.body.textContent ?? '' for (const banned of ['thumbnail preset', 'version', 'rendition']) { expect(text).not.toContain(banned) } }) }) describe('Rule: Cache screen, settings.edit_general', () => { it('lets a session with settings.edit_general purge and shows no read-only banner', async () => { await mountPage({ access: customAccess('settings.view, settings.edit_general') }) expect(document.body.textContent).not.toContain( 'You can read these settings but not change them', ) expect(findButton('Purge all variants')?.disabled).toBe(false) }) it('locks the purge controls behind the banner with settings.view alone', async () => { await mountPage({ access: customAccess('settings.view') }) expect(document.body.textContent).toContain('You can read these settings but not change them') expect(findButton('Purge all variants')?.disabled).toBe(true) }) })