602 lines
13 KiB
TypeScript
602 lines
13 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 { MediaItem } from '@/composables/useMedia'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
|
import MediaView from '../MediaView.vue'
|
|
|
|
type Row = [
|
|
number,
|
|
string,
|
|
number,
|
|
number | null,
|
|
number | null,
|
|
string,
|
|
number,
|
|
string,
|
|
string,
|
|
boolean,
|
|
string,
|
|
]
|
|
|
|
const ROWS: Row[] = [
|
|
[
|
|
1,
|
|
'north-wall.jpg',
|
|
4089446,
|
|
4032,
|
|
3024,
|
|
'image/jpeg',
|
|
2,
|
|
'Marta Alves',
|
|
'2026-09-12T12:00:00Z',
|
|
false,
|
|
'0a',
|
|
],
|
|
[
|
|
2,
|
|
'kitchen-after.webp',
|
|
1258291,
|
|
2400,
|
|
1600,
|
|
'image/webp',
|
|
3,
|
|
'Inès Duarte',
|
|
'2026-09-08T12:00:00Z',
|
|
false,
|
|
'1b',
|
|
],
|
|
[
|
|
3,
|
|
'kitchen-before.webp',
|
|
1153434,
|
|
2400,
|
|
1600,
|
|
'image/webp',
|
|
1,
|
|
'Inès Duarte',
|
|
'2026-09-08T11:00:00Z',
|
|
false,
|
|
'2c',
|
|
],
|
|
[
|
|
4,
|
|
'logo-renew.svg',
|
|
8192,
|
|
null,
|
|
null,
|
|
'image/svg+xml',
|
|
5,
|
|
'jonas',
|
|
'2026-09-05T12:00:00Z',
|
|
false,
|
|
'3d',
|
|
],
|
|
[
|
|
5,
|
|
'floorboards-copy.jpg',
|
|
2516582,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
3,
|
|
'Marta Alves',
|
|
'2026-09-02T12:00:00Z',
|
|
true,
|
|
'0e',
|
|
],
|
|
[
|
|
6,
|
|
'IMG_2208.jpg',
|
|
2516582,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
2,
|
|
'jonas',
|
|
'2026-08-30T12:00:00Z',
|
|
true,
|
|
'0e',
|
|
],
|
|
[
|
|
7,
|
|
'IMG_4471.jpg',
|
|
3250585,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
2,
|
|
'Marta Alves',
|
|
'2026-08-29T12:00:00Z',
|
|
true,
|
|
'2f',
|
|
],
|
|
[
|
|
8,
|
|
'window-frame.jpg',
|
|
1887437,
|
|
3000,
|
|
2000,
|
|
'image/jpeg',
|
|
0,
|
|
'Inès Duarte',
|
|
'2026-08-28T12:00:00Z',
|
|
false,
|
|
'3a',
|
|
],
|
|
[
|
|
9,
|
|
'stone-wall-detail.jpg',
|
|
3250585,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
1,
|
|
'jonas',
|
|
'2026-08-21T12:00:00Z',
|
|
true,
|
|
'2f',
|
|
],
|
|
[
|
|
10,
|
|
'bathroom-tiles.png',
|
|
880640,
|
|
1800,
|
|
1200,
|
|
'image/png',
|
|
0,
|
|
'Marta Alves',
|
|
'2026-08-04T12:00:00Z',
|
|
false,
|
|
'1b',
|
|
],
|
|
[
|
|
11,
|
|
'lime-plaster.jpg',
|
|
2202009,
|
|
3200,
|
|
2133,
|
|
'image/jpeg',
|
|
2,
|
|
'Inès Duarte',
|
|
'2026-07-19T12:00:00Z',
|
|
false,
|
|
'1c',
|
|
],
|
|
[
|
|
12,
|
|
'floorboards.jpg',
|
|
2516582,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
4,
|
|
'Inès Duarte',
|
|
'2026-06-14T12:00:00Z',
|
|
true,
|
|
'0e',
|
|
],
|
|
]
|
|
|
|
export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
|
export const MEGABYTE = 1024 * 1024
|
|
|
|
export function fixture(ids?: number[]): MediaItem[] {
|
|
return ROWS.filter(([id]) => !ids || ids.includes(id)).map(
|
|
([
|
|
id,
|
|
filename,
|
|
size,
|
|
width,
|
|
height,
|
|
content_type,
|
|
used_in,
|
|
uploaded_by,
|
|
created_at,
|
|
duplicate,
|
|
sha256,
|
|
]) => ({
|
|
id,
|
|
filename,
|
|
url: `/media/${filename}`,
|
|
size,
|
|
content_type,
|
|
width,
|
|
height,
|
|
sha256,
|
|
uploaded_by,
|
|
created_at,
|
|
used_in,
|
|
duplicate,
|
|
}),
|
|
)
|
|
}
|
|
|
|
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
|
|
return { items, next_cursor: null, counts }
|
|
}
|
|
|
|
export function deferred<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 }
|
|
}
|
|
|
|
export interface Handlers {
|
|
list?: (path: string) => unknown
|
|
usage?: (id: number) => unknown
|
|
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(
|
|
outcome: (id: number) => unknown = () => undefined,
|
|
ids?: number[],
|
|
): Handlers {
|
|
const gone = new Set<number>()
|
|
return {
|
|
list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))),
|
|
remove: async (id) => {
|
|
try {
|
|
const result = await outcome(id)
|
|
gone.add(id)
|
|
return result
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 404) {
|
|
gone.add(id)
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
export const kit = {
|
|
requestMock: vi.fn(),
|
|
wrapper: null as VueWrapper | null,
|
|
}
|
|
|
|
function dispatcher(handlers: Handlers) {
|
|
return (path: string, options?: RequestInit) => {
|
|
const method = options?.method ?? 'GET'
|
|
if (method === 'GET' && path.startsWith('/v1/media/?hash=')) {
|
|
return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path)
|
|
}
|
|
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]))
|
|
}
|
|
if (method === 'POST' && path === '/v1/media/merge') {
|
|
return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))(
|
|
JSON.parse(String(options?.body)),
|
|
)
|
|
}
|
|
const single = /^\/v1\/media\/(\d+)$/.exec(path)
|
|
if (method === 'DELETE' && single) {
|
|
return (handlers.remove ?? (() => undefined))(Number(single[1]))
|
|
}
|
|
throw new Error(`Unexpected request: ${method} ${path}`)
|
|
}
|
|
}
|
|
|
|
function fetchFrom(dispatch: ReturnType<typeof dispatcher>) {
|
|
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 dispatch(path, init)
|
|
return reply(body === undefined ? 204 : 200, body)
|
|
} catch (error) {
|
|
if (error instanceof ApiError) {
|
|
return reply(error.status)
|
|
}
|
|
throw error
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function mountPage(
|
|
handlers: Handlers = {},
|
|
url = '/media',
|
|
width = DESKTOP_WIDTH,
|
|
options: { realApi?: boolean; role?: string } = {},
|
|
) {
|
|
setViewportWidth(width)
|
|
setActivePinia(createPinia())
|
|
const session = useSessionStore()
|
|
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) {
|
|
kit.requestMock = fetchFrom(dispatch)
|
|
vi.stubGlobal('fetch', kit.requestMock)
|
|
} else {
|
|
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
|
|
vi.mocked(useApi).mockReturnValue({
|
|
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
|
request: kit.requestMock,
|
|
})
|
|
}
|
|
await router.push(url)
|
|
kit.wrapper = mount(MediaView, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
await flushPromises()
|
|
return kit.wrapper
|
|
}
|
|
|
|
export async function unmountPage() {
|
|
kit.wrapper?.unmount()
|
|
kit.wrapper = null
|
|
vi.useRealTimers()
|
|
vi.unstubAllGlobals()
|
|
vi.restoreAllMocks()
|
|
await router.push('/login')
|
|
document.body.innerHTML = ''
|
|
}
|
|
|
|
export 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?.replace(/\s+/g, ' ').trim()
|
|
if (text) {
|
|
parts.push(text)
|
|
}
|
|
}
|
|
return parts.join(' ')
|
|
}
|
|
|
|
export function cards() {
|
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-media-card]'))
|
|
}
|
|
|
|
export function cardFor(filename: string) {
|
|
return cards().find((card) => textOf(card).includes(filename))!
|
|
}
|
|
|
|
export function tabs() {
|
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
|
|
}
|
|
|
|
export function tab(label: string) {
|
|
return tabs().find((element) => textOf(element).startsWith(label))!
|
|
}
|
|
|
|
export function findButton(label: string, root: ParentNode = document.body) {
|
|
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
|
(button) => textOf(button) === label,
|
|
)
|
|
}
|
|
|
|
export function panel() {
|
|
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
|
}
|
|
|
|
export function dialog() {
|
|
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
|
}
|
|
|
|
export function listCalls() {
|
|
return kit.requestMock.mock.calls.filter(
|
|
([path]) =>
|
|
typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='),
|
|
)
|
|
}
|
|
|
|
export function lastListPath() {
|
|
return listCalls().at(-1)![0] as string
|
|
}
|
|
|
|
export function callsTo(method: string, pattern: RegExp) {
|
|
return kit.requestMock.mock.calls.filter(
|
|
([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path),
|
|
)
|
|
}
|
|
|
|
export function press(element: Element, key: string) {
|
|
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
|
}
|
|
|
|
export function pressOnWindow(key: string) {
|
|
document.body.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
|
)
|
|
}
|
|
|
|
export async function settle() {
|
|
await flushPromises()
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
await flushPromises()
|
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
await flushPromises()
|
|
}
|
|
|
|
export function announcer() {
|
|
return textOf(document.body.querySelector('[data-testid="media-announcer"]'))
|
|
}
|
|
|
|
export function checkboxFor(filename: string) {
|
|
return document.body.querySelector<HTMLElement>(
|
|
`[role="checkbox"][aria-label="Select ${filename}"]`,
|
|
)!
|
|
}
|
|
|
|
export function selectAllCheckbox() {
|
|
return document.body
|
|
.querySelector('[data-testid="image-count"]')!
|
|
.parentElement!.querySelector<HTMLElement>('[role="checkbox"]')!
|
|
}
|
|
|
|
export function tableHeaderCheckbox() {
|
|
return document.body.querySelector<HTMLElement>('[role="checkbox"][aria-label="Select all"]')!
|
|
}
|
|
|
|
export function bulkBar() {
|
|
return document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')
|
|
}
|
|
|
|
export function fileWith(name: string, type: string, size?: number, content = name) {
|
|
const file = new File([content], name, { type })
|
|
if (size !== undefined) {
|
|
Object.defineProperty(file, 'size', { value: size })
|
|
}
|
|
return file
|
|
}
|
|
|
|
export function hexOf(content: string) {
|
|
return Array.from(new TextEncoder().encode(content), (byte) =>
|
|
byte.toString(16).padStart(2, '0'),
|
|
)
|
|
.join('')
|
|
.padEnd(64, '0')
|
|
.slice(0, 64)
|
|
}
|
|
|
|
export function stubDigest() {
|
|
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
|
|
const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0'))
|
|
.join('')
|
|
.padEnd(64, '0')
|
|
.slice(0, 64)
|
|
return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer
|
|
})
|
|
vi.stubGlobal('crypto', { subtle: { digest } })
|
|
return digest
|
|
}
|
|
|
|
export class FakeXhr extends EventTarget {
|
|
static instances: FakeXhr[] = []
|
|
upload = new EventTarget()
|
|
method = ''
|
|
url = ''
|
|
status = 0
|
|
responseText = ''
|
|
withCredentials = false
|
|
body: FormData | null = null
|
|
aborted = false
|
|
finished = false
|
|
headers: Record<string, string> = {}
|
|
|
|
open(method: string, url: string) {
|
|
this.method = method
|
|
this.url = url
|
|
}
|
|
|
|
send(body: FormData) {
|
|
this.body = body
|
|
FakeXhr.instances.push(this)
|
|
}
|
|
|
|
abort() {
|
|
this.aborted = true
|
|
this.finished = true
|
|
this.dispatchEvent(new Event('abort'))
|
|
}
|
|
|
|
getResponseHeader(name: string) {
|
|
return this.headers[name.toLowerCase()] ?? null
|
|
}
|
|
|
|
progress(loaded: number, total: number) {
|
|
this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total }))
|
|
}
|
|
|
|
respond(status: number, headers: Record<string, string> = {}, text = '') {
|
|
this.status = status
|
|
this.headers = Object.fromEntries(
|
|
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
|
)
|
|
this.responseText = text
|
|
this.finished = true
|
|
this.dispatchEvent(new Event('load'))
|
|
}
|
|
|
|
fail() {
|
|
this.finished = true
|
|
this.dispatchEvent(new Event('error'))
|
|
}
|
|
|
|
get fileName() {
|
|
return (this.body?.get('file') as File).name
|
|
}
|
|
}
|
|
|
|
export function stubXhr() {
|
|
FakeXhr.instances = []
|
|
vi.stubGlobal('XMLHttpRequest', FakeXhr)
|
|
}
|
|
|
|
export function inFlight() {
|
|
return FakeXhr.instances.filter((xhr) => !xhr.finished)
|
|
}
|
|
|
|
export function xhrFor(name: string) {
|
|
return FakeXhr.instances.find((xhr) => xhr.fileName === name)!
|
|
}
|
|
|
|
export function fileInput() {
|
|
return document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
|
}
|
|
|
|
export async function chooseFiles(files: File[]) {
|
|
const input = fileInput()
|
|
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
await settle()
|
|
}
|
|
|
|
export function dragEvent(type: string, types: string[], files: File[] = []) {
|
|
const event = new Event(type, { bubbles: true, cancelable: true })
|
|
Object.defineProperty(event, 'dataTransfer', { value: { types, files } })
|
|
document.body.dispatchEvent(event)
|
|
return event
|
|
}
|
|
|
|
export function uploadPanel() {
|
|
return document.body.querySelector<HTMLElement>('[data-testid="media-upload-panel"]')
|
|
}
|
|
|
|
export function uploadRows() {
|
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="upload-row"]'))
|
|
}
|
|
|
|
export function rowTexts() {
|
|
return uploadRows().map(textOf)
|
|
}
|