721 lines
16 KiB
TypeScript
721 lines
16 KiB
TypeScript
import { vi } from 'vitest'
|
|
import { defineComponent, h } from 'vue'
|
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
import UApp from '@nuxt/ui/components/App.vue'
|
|
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,
|
|
number,
|
|
number,
|
|
]
|
|
|
|
const ROWS: Row[] = [
|
|
[
|
|
1,
|
|
'north-wall.jpg',
|
|
4089446,
|
|
4032,
|
|
3024,
|
|
'image/jpeg',
|
|
2,
|
|
'Marta Alves',
|
|
'2026-09-12T12:00:00Z',
|
|
false,
|
|
'0a',
|
|
4,
|
|
266240,
|
|
],
|
|
[
|
|
2,
|
|
'kitchen-after.webp',
|
|
1258291,
|
|
2400,
|
|
1600,
|
|
'image/webp',
|
|
3,
|
|
'Inès Duarte',
|
|
'2026-09-08T12:00:00Z',
|
|
false,
|
|
'1b',
|
|
5,
|
|
419840,
|
|
],
|
|
[
|
|
3,
|
|
'kitchen-before.webp',
|
|
1153434,
|
|
2400,
|
|
1600,
|
|
'image/webp',
|
|
1,
|
|
'Inès Duarte',
|
|
'2026-09-08T11:00:00Z',
|
|
false,
|
|
'2c',
|
|
5,
|
|
404480,
|
|
],
|
|
[
|
|
4,
|
|
'logo-renew.svg',
|
|
8192,
|
|
null,
|
|
null,
|
|
'image/svg+xml',
|
|
5,
|
|
'jonas',
|
|
'2026-09-05T12:00:00Z',
|
|
false,
|
|
'3d',
|
|
0,
|
|
0,
|
|
],
|
|
[
|
|
5,
|
|
'floorboards-copy.jpg',
|
|
2516582,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
3,
|
|
'Marta Alves',
|
|
'2026-09-02T12:00:00Z',
|
|
true,
|
|
'0e',
|
|
3,
|
|
245760,
|
|
],
|
|
[
|
|
6,
|
|
'IMG_2208.jpg',
|
|
2516582,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
2,
|
|
'jonas',
|
|
'2026-08-30T12:00:00Z',
|
|
true,
|
|
'0e',
|
|
0,
|
|
0,
|
|
],
|
|
[
|
|
7,
|
|
'IMG_4471.jpg',
|
|
3250585,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
2,
|
|
'Marta Alves',
|
|
'2026-08-29T12:00:00Z',
|
|
true,
|
|
'2f',
|
|
2,
|
|
153600,
|
|
],
|
|
[
|
|
8,
|
|
'window-frame.jpg',
|
|
1887437,
|
|
3000,
|
|
2000,
|
|
'image/jpeg',
|
|
0,
|
|
'Inès Duarte',
|
|
'2026-08-28T12:00:00Z',
|
|
false,
|
|
'3a',
|
|
0,
|
|
0,
|
|
],
|
|
[
|
|
9,
|
|
'stone-wall-detail.jpg',
|
|
3250585,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
1,
|
|
'jonas',
|
|
'2026-08-21T12:00:00Z',
|
|
true,
|
|
'2f',
|
|
1,
|
|
61440,
|
|
],
|
|
[
|
|
10,
|
|
'bathroom-tiles.png',
|
|
880640,
|
|
1800,
|
|
1200,
|
|
'image/png',
|
|
0,
|
|
'Marta Alves',
|
|
'2026-08-04T12:00:00Z',
|
|
false,
|
|
'1b',
|
|
3,
|
|
194560,
|
|
],
|
|
[
|
|
11,
|
|
'lime-plaster.jpg',
|
|
2202009,
|
|
3200,
|
|
2133,
|
|
'image/jpeg',
|
|
2,
|
|
'Inès Duarte',
|
|
'2026-07-19T12:00:00Z',
|
|
false,
|
|
'1c',
|
|
4,
|
|
286720,
|
|
],
|
|
[
|
|
12,
|
|
'floorboards.jpg',
|
|
2516582,
|
|
3600,
|
|
2400,
|
|
'image/jpeg',
|
|
4,
|
|
'Inès Duarte',
|
|
'2026-06-14T12:00:00Z',
|
|
true,
|
|
'0e',
|
|
5,
|
|
440320,
|
|
],
|
|
]
|
|
|
|
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,
|
|
variant_count,
|
|
variant_size,
|
|
]) => ({
|
|
id,
|
|
filename,
|
|
url: `/media/${filename}`,
|
|
size,
|
|
content_type,
|
|
width,
|
|
height,
|
|
sha256,
|
|
uploaded_by,
|
|
created_at,
|
|
used_in,
|
|
duplicate,
|
|
variant_count,
|
|
variant_size,
|
|
}),
|
|
)
|
|
}
|
|
|
|
export function listBody(
|
|
items: MediaItem[],
|
|
counts = FULL_COUNTS,
|
|
paging: { total?: number; page?: number; size?: number } = {},
|
|
) {
|
|
return {
|
|
items,
|
|
total: paging.total ?? items.length,
|
|
page: paging.page ?? 1,
|
|
size: paging.size ?? 25,
|
|
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
|
|
variants?: (id: number) => unknown
|
|
generateAll?: (id: number) => unknown
|
|
generate?: (id: number, name: string) => unknown
|
|
purgeVariant?: (id: number, name: 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 variantsList = /^\/v1\/media\/(\d+)\/variants$/.exec(path)
|
|
if (variantsList && method === 'GET') {
|
|
return (handlers.variants ?? (() => ({ unsupported: null, items: [] })))(
|
|
Number(variantsList[1]),
|
|
)
|
|
}
|
|
if (variantsList && method === 'POST') {
|
|
return (handlers.generateAll ?? (() => undefined))(Number(variantsList[1]))
|
|
}
|
|
const variantNamed = /^\/v1\/media\/(\d+)\/variants\/([\w-]+)$/.exec(path)
|
|
if (variantNamed && method === 'POST') {
|
|
return (handlers.generate ?? (() => undefined))(
|
|
Number(variantNamed[1]),
|
|
variantNamed[2]!,
|
|
)
|
|
}
|
|
if (variantNamed && method === 'DELETE') {
|
|
return (handlers.purgeVariant ?? (() => undefined))(
|
|
Number(variantNamed[1]),
|
|
variantNamed[2]!,
|
|
)
|
|
}
|
|
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; app?: boolean } = {},
|
|
) {
|
|
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)
|
|
const root = options.app
|
|
? defineComponent({
|
|
render: () => h(UApp, null, { default: () => h(MediaView) }),
|
|
})
|
|
: MediaView
|
|
kit.wrapper = mount(root, {
|
|
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)
|
|
}
|
|
|
|
export const STORED_NAME = '1.jpg'
|
|
export const NORTH_WALL_BYTES = 4089446
|
|
|
|
const VARIANT_SPECS: [
|
|
string,
|
|
number | null,
|
|
number | null,
|
|
string,
|
|
string,
|
|
number,
|
|
number,
|
|
number,
|
|
][] = [
|
|
['card', 800, null, 'inside', 'webp', 800, 600, 65536],
|
|
['hero', 1200, 630, 'cover', 'webp', 1200, 630, 98304],
|
|
['og', 1200, 630, 'cover', 'jpeg', 1200, 630, 145408],
|
|
['square', 600, 600, 'cover', 'jpeg', 600, 600, 83968],
|
|
['thumb', 400, 300, 'cover', 'webp', 400, 300, 18432],
|
|
]
|
|
|
|
export function variantItems(generated: string[] = VARIANT_SPECS.map(([name]) => name)) {
|
|
return VARIANT_SPECS.map(
|
|
([name, width, height, fit, format, outputWidth, outputHeight, size]) => ({
|
|
name,
|
|
width,
|
|
height,
|
|
fit,
|
|
format,
|
|
output_width: outputWidth,
|
|
output_height: outputHeight,
|
|
output_format: format,
|
|
generated: generated.includes(name),
|
|
size: generated.includes(name) ? size : null,
|
|
url: `/static/uploadedImages/${STORED_NAME}?v=${name}`,
|
|
}),
|
|
)
|
|
}
|
|
|
|
export function variantList(generated?: string[]) {
|
|
return { unsupported: null, items: variantItems(generated) }
|
|
}
|