feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

+587
View File
@@ -0,0 +1,587 @@
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
}
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)
}
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 } = {},
) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
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)
}