import { vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' import { RouterView } from 'vue-router' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import DashboardShell from '@/components/DashboardShell.vue' import router from '@/router' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import { useContentTypesStore } from '@/stores/contentTypes' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures' export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures' export type Responder = () => unknown export const customTypes = [ { id: 1, key: 'events', name: 'Events', icon: 'files', fields: eventsFields, updated_at: '2026-10-15T09:30:00Z', }, { id: 2, key: 'recipes', name: 'Recipes', icon: null, fields: recipesFields, updated_at: '2026-10-02T09:30:00Z', }, { id: 3, key: 'press-mentions', name: 'Press mentions', icon: null, fields: new Array(5), updated_at: '2026-09-18T09:30:00Z', }, ].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' })) export function thumbnailField(rest: object = {}) { return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest }) } export function eventsWithThumbnail(rest: object = {}) { return { ...customTypes[0]!, fields: [...eventsFields, thumbnailField(rest)] } } export function thumbnailMount(rest: object = {}) { return { content: () => [eventsWithThumbnail(rest), customTypes[1]!, customTypes[2]!], type: (key: string) => (key === 'events' ? eventsWithThumbnail(rest) : customTypes[1]), } } function articleField(label: string, key: string, type: string, rest: object = {}) { return { key, label, type, required: false, unique: false, default: null, min: null, max: null, options: null, ...rest, } } export const articleFields = [ articleField('Title', 'title', 'text', { required: true, max: 200 }), articleField('Slug', 'slug', 'text', { required: true, unique: true }), articleField('Body', 'body', 'richtext', { max: 10000 }), articleField('Online', 'online', 'boolean', { default: false }), articleField('Published on', 'published_on', 'date'), ] export const builtinTypes = [ ['articles', 'Articles', 5], ['authors', 'Authors', 5], ['sites', 'Sites', 8], ['media', 'Media', 6], ['users', 'Users', 6], ].map(([key, name, count]) => ({ key, name, fields: key === 'articles' ? articleFields : new Array(count), })) export function createdType(body: Record) { return { id: 4, icon: null, created_at: '2026-10-19T09:30:00Z', updated_at: '2026-10-19T09:30:00Z', ...body, } } export function manyTypes(count: number) { return Array.from({ length: count }, (_, index) => ({ ...customTypes[0]!, id: index + 1, key: `type-${index + 1}`, name: `Type ${index + 1}`, icon: null, })) } export function failWith(status: number): Responder { return () => { throw new ApiError(status) } } export function failWithBody(status: number, body: string): Responder { return () => { throw new ApiError(status, undefined, body) } } export const networkFailure: Responder = () => { throw new NetworkError() } export interface PostedRequest { url: string body: Record } export const entryCounts: Record = { events: 37, recipes: 0 } export const emptyLibrary = { items: [], total: 0, page: 1, size: 25, counts: { all: 0, unused: 0, duplicates: 0 }, } export function entryPage(total: number) { return { items: [], total, page: 1, size: 1 } } function defaultType(key: string) { const found = customTypes.find((type) => type.key === key) if (!found) { throw new ApiError(404, undefined, 'content_type_not_found') } return found } export interface EntryWrite { method: string url: string body: Record } export interface SchemaMount { entry?: (key: string, id: number) => unknown writeEntry?: (write: EntryWrite) => unknown role?: string create?: (body: PostedRequest['body']) => unknown update?: (body: PostedRequest['body'], key: string) => unknown entries?: (key: string) => unknown remove?: (key: string) => unknown width?: number content?: Responder builtin?: Responder verified?: boolean status?: 'checking' | 'ready' actions?: string[] type?: (key: string) => unknown list?: (key: string, query: URLSearchParams) => unknown lookup?: (ids: number[]) => unknown library?: (query: URLSearchParams) => unknown before?: () => void } let wrapper: VueWrapper | undefined export const calls: string[] = [] export const posts: PostedRequest[] = [] export const puts: PostedRequest[] = [] export const deletes: string[] = [] export const entryWrites: EntryWrite[] = [] const ENTRY_URL = /^\/v1\/content-type\/[^/?]+\/entry(\/\d+)?$/ export function updatedType(key: string, body: Record) { const base = customTypes.find((type) => type.key === key)! return { ...base, ...body, key, updated_at: '2026-10-19T11:02:00Z' } } export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) { calls.length = 0 posts.length = 0 puts.length = 0 deletes.length = 0 entryWrites.length = 0 localStorage.clear() options.before?.() setViewportWidth(options.width ?? DESKTOP_WIDTH) const pinia = createPinia() setActivePinia(pinia) if (options.actions) { const actions = options.actions useContentTypesStore().$onAction(({ name }) => { actions.push(name) }) } const session = useSessionStore() session.session = { verified: options.verified ?? true, username: 'marta', id: 1, email: 'marta@renew.example', displayName: 'Marta Alves', role: (options.role ?? 'owner') as never, } session.status = options.status ?? 'ready' const request = vi.fn(async (url: string, init?: RequestInit) => { if (init?.method && ENTRY_URL.test(url)) { const write = { method: init.method, url, body: init.body ? (JSON.parse(String(init.body)) as Record) : {}, } entryWrites.push(write) return options.writeEntry?.(write) } if (init?.method === 'DELETE') { deletes.push(url) return (options.remove ?? (() => undefined))(url.split('/').pop()!) } if (init?.method === 'PUT') { const body = JSON.parse(String(init.body)) as PostedRequest['body'] const key = url.split('/').pop()! puts.push({ url, body }) return (options.update ?? ((sent, target) => updatedType(target, sent)))(body, key) } if (init?.method === 'POST') { const body = JSON.parse(String(init.body)) as PostedRequest['body'] posts.push({ url, body }) return (options.create ?? (() => createdType(body)))(body) } calls.push(url) const library = url.match(/^\/v1\/media\/\?(.*)$/) if (library) { return (options.library ?? (() => emptyLibrary))(new URLSearchParams(library[1])) } if (url === '/v1/content-type') { return (options.content ?? (() => [...customTypes]))() } const counted = url.match(/^\/v1\/content-type\/([^/]+)\/entry\?page=1&size=5$/) if (counted) { return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!) } const listed = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\?(.*)$/) if (listed) { return (options.list ?? (() => entryPage(0)))( listed[1]!, new URLSearchParams(listed[2]), ) } const looked = url.match(/^\/v1\/user\/lookup\?ids=(.*)$/) if (looked) { return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number)) } const single = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\/(\d+)$/) if (single) { return options.entry?.(single[1]!, Number(single[2])) } const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/) if (detailed) { return (options.type ?? defaultType)(detailed[1]!) } if (url === '/v1/builtin-type') { return (options.builtin ?? (() => builtinTypes))() } return undefined }) vi.mocked(useApi).mockReturnValue({ getMe: options.status === 'checking' ? vi.fn(() => new Promise(() => undefined)) : vi.fn().mockResolvedValue({ verified: true, username: 'marta' }), request, } as unknown as ReturnType) await router.push(path) wrapper = mount( defineComponent({ render: () => h(UApp, null, { default: () => (shellOnly ? h(DashboardShell) : h(RouterView)), }), }), { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }, ) await flushPromises() return wrapper } export async function unmountAll() { wrapper?.unmount() wrapper = undefined vi.useRealTimers() await router.push('/login') document.body.innerHTML = '' } export function pending() { let resolve!: (value: unknown) => void const promise = new Promise((done) => (resolve = done)) return { promise, resolve } } export function text(element: Element | null | undefined) { if (!element) { return '' } const parts: string[] = [] const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) for (let node = walker.nextNode(); node; node = walker.nextNode()) { parts.push(node.textContent ?? '') } return parts.join(' ').replace(/\s+/g, ' ').trim() } export function button(label: string) { return [...document.body.querySelectorAll('button')].find( (candidate) => text(candidate) === label, ) }