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 { 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' })) 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 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 SchemaMount { 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 actions?: string[] type?: (key: string) => unknown list?: (key: string, query: URLSearchParams) => unknown lookup?: (ids: number[]) => unknown before?: () => void } let wrapper: VueWrapper | undefined export const calls: string[] = [] export const posts: PostedRequest[] = [] export const puts: PostedRequest[] = [] export const deletes: string[] = [] 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 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 = 'ready' const request = vi.fn(async (url: string, init?: RequestInit) => { 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) 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 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: 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, ) }