import { vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import App from '@/App.vue' import router from '@/router' import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi' import type { Article, Byline } from '@/composables/useArticles' import type { Author } from '@/composables/useAuthors' import type { Site } from '@/composables/useSites' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { SEO_KEYS } from '@/lib/articleSeo' import { slugify } from '@/lib/directory' import { useSessionStore } from '@/stores/session' import { authorsFixture, defaultRoute, sitesFixture, usersFixture, type Backend, type Call, } from './directoryTestKit' export { callsTo, textOf, button, click, typeInto, dialog, toasts, pending, } from './directoryTestKit' export { failWith, networkFailure } from './directoryTestKit' export type MetaRole = 'owner' | 'admin' | 'verified' export interface MetaBackend extends Backend { article: Article articles: Article[] me: MeResult meAuthorSlug?: string meSlugAdjusted?: boolean } export function readyCheck() { return { status: 'ready', suggestions: [], slug: { available: true, suggestion: null }, } } export const MARTA_EMAIL = 'marta@renew.example' const ARTICLE_ID = 12 export function renewSites(): Site[] { return sitesFixture().map((site) => ({ ...site, base_url: `https://${site.key === 'renew' ? 'renew' : `${site.key}.renew`}.example`, })) } export function boardAuthors(): Author[] { const extra = (id: number, name: string): Author => ({ id, name, slug: slugify(name, 100), bio: '', avatar_id: null, website: null, social_links: [], user_id: null, is_linked: false, article_count: 0, }) return [...authorsFixture(), extra(3, 'Jonas Pereira'), extra(8, 'Paul Huber')] } export function bylines(...names: string[]): Byline[] { const all = boardAuthors() return names.map((name) => { const found = all.find((author) => author.name === name) if (found) { return { id: found.id, name: found.name, slug: found.slug } } return { id: 100 + name.length, name, slug: slugify(name, 100) } }) } export function articleOf(id: number, title: string, overrides: Partial
= {}): Article { return { id, title, body: '

Intro

', is_online: false, site_id: 1, authors: bylines('Inès Duarte'), created_at: '2026-03-12T09:14:00Z', updated_at: '2026-09-26T11:02:00Z', ...overrides, } } export function createMetaBackend( options: { sites?: Site[] authors?: Author[] role?: MetaRole author?: MeAuthor } = {}, ): MetaBackend { return { sites: options.sites ?? sitesFixture().slice(0, 1), authors: options.authors ?? boardAuthors(), users: usersFixture(), calls: [], responders: {}, article: articleOf(ARTICLE_ID, 'How we price a bathroom renovation'), articles: [], me: { id: 1, verified: true, username: 'marta', email: MARTA_EMAIL, displayName: 'Marta Alves', role: options.role === 'verified' ? undefined : (options.role ?? 'owner'), ...(options.author ? { author: options.author } : {}), }, } } function fail(status: number, code?: string): never { throw new ApiError(status, undefined, code) } function toByline(backend: MetaBackend, id: number): Byline { const author = backend.authors.find((candidate) => candidate.id === id) if (!author) { fail(404, 'author_not_found') } return { id: author.id, name: author.name, slug: author.slug } } function patchArticle(backend: MetaBackend, body: Record) { if (typeof body.site_id === 'number') { if (!backend.sites.some((site) => site.id === body.site_id)) { fail(404, 'site_not_found') } backend.article.site_id = body.site_id } if (Array.isArray(body.author_ids)) { backend.article.authors = (body.author_ids as number[]).map((id) => toByline(backend, id)) } if (typeof body.body === 'string') { backend.article.body = body.body } for (const key of SEO_KEYS) { if (key in body) { Object.assign(backend.article, { [key]: body[key] }) } } return structuredClone(backend.article) } function listArticles(backend: MetaBackend, query: URLSearchParams) { const size = Number(query.get('size') ?? 25) const page = Number(query.get('page') ?? 1) const siteKey = query.get('site') const status = query.get('status') const q = (query.get('q') ?? '').toLowerCase() const siteId = backend.sites.find((site) => site.key === siteKey)?.id const matching = backend.articles .filter((article) => !siteKey || article.site_id === siteId) .filter((article) => !status || article.is_online === (status === 'online')) .filter((article) => !q || article.title.toLowerCase().includes(q)) .filter( (article) => query.get('seo') !== 'suggestions' || (article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0), ) const online = backend.articles.filter((article) => article.is_online).length return { items: structuredClone(matching.slice((page - 1) * size, page * size)), total: matching.length, page, size, counts: { all: backend.articles.length, online, draft: backend.articles.length - online, suggestions: backend.articles.filter( (article) => article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0, ).length, }, } } function createMyAuthor(backend: MetaBackend) { if (backend.me.author) { fail(409, 'user_already_linked') } const name = backend.me.displayName || backend.me.username || '' const created = { id: 300, name, slug: backend.meAuthorSlug ?? slugify(name, 100), bio: '', avatar_id: null, website: null, social_links: [], user_id: backend.me.id ?? null, is_linked: true, article_count: 0, } as Author backend.authors.push(created) backend.me.author = { id: created.id, name: created.name, slug: created.slug, } return { ...created, slug_adjusted: backend.meSlugAdjusted ?? false, } as Author } function route(backend: MetaBackend, call: Call): unknown { const { method, path, query, body } = call const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path) if (/^\/v1\/article\/\d+\/seo\/check$/.test(path) && method === 'POST') { return readyCheck() } if (articleMatch && method === 'GET') { return structuredClone(backend.article) } if (articleMatch && method === 'PATCH') { return patchArticle(backend, body ?? {}) } if (path === '/v1/article/' && method === 'GET') { return listArticles(backend, query) } if (path === '/v1/author/me' && method === 'POST') { return createMyAuthor(backend) } if (path === '/v1/author/' && method === 'POST' && body?.user_id != null) { const created = defaultRoute(backend, call) as Author backend.me.author = { id: created.id, name: created.name, slug: created.slug, } return created } return defaultRoute(backend, call) } export interface MetaMountOptions { role?: MetaRole width?: number backend?: MetaBackend author?: MeAuthor } export const metaKit: { wrapper: ReturnType | null backend: MetaBackend } = { wrapper: null, backend: createMetaBackend(), } export async function mountMeta( _view: 'editor' | 'articles' | 'profile', url: string, options: MetaMountOptions = {}, ) { setViewportWidth(options.width ?? DESKTOP_WIDTH) Element.prototype.scrollIntoView = vi.fn() setActivePinia(createPinia()) const backend = options.backend ?? createMetaBackend({ role: options.role, author: options.author }) metaKit.backend = backend const session = useSessionStore() session.session = { verified: true, username: backend.me.username, id: backend.me.id, email: backend.me.email, displayName: backend.me.displayName, role: backend.me.role, ...(backend.me.author ? { author: backend.me.author } : {}), } session.status = 'ready' const request = vi.fn(async (path: string, init?: RequestInit) => { const [pathname = '', search = ''] = path.split('?') const call: Call = { method: init?.method ?? 'GET', path: pathname, raw: path, query: new URLSearchParams(search), body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, } backend.calls.push(call) const responder = backend.responders[`${call.method} ${path}`] ?? backend.responders[`${call.method} ${pathname}`] return responder ? responder(call) : route(backend, call) }) const getMe = vi.fn(async () => { backend.calls.push({ method: 'GET', path: '/v1/auth/me', raw: '/v1/auth/me', query: new URLSearchParams(), body: undefined, }) return structuredClone(backend.me) }) vi.mocked(useApi).mockReturnValue({ getMe, request, } as unknown as ReturnType) await router.push(url) metaKit.wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return backend } export function metaSection() { return document.body.querySelector('[data-testid="meta-section"]') } export function allMetaSections() { return Array.from(document.body.querySelectorAll('[data-testid="meta-section"]')) } export function authorRows(root: ParentNode = document.body) { return Array.from(root.querySelectorAll('[data-testid="author-row"]')) } export function rowNames(root: ParentNode = document.body) { return authorRows(root).map((row) => row.querySelector('[title]')?.getAttribute('title') ?? '') } export function handleOf(name: string) { return document.body.querySelector(`button[aria-label="Reorder ${name}"]`)! } export async function pressKey(element: HTMLElement, key: string) { element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) await flushPromises() } export function mockRowRects(rows: HTMLElement[], rowHeight = 32) { rows.forEach((row, index) => { row.getBoundingClientRect = () => ({ top: index * rowHeight, bottom: (index + 1) * rowHeight, height: rowHeight, left: 0, right: 200, width: 200, x: 0, y: index * rowHeight, toJSON: () => ({}), }) as DOMRect }) } export async function pointer(element: HTMLElement, type: string, clientY: number) { const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientY, button: 0, }) element.dispatchEvent(event) await flushPromises() } export async function dragTo(name: string, clientY: number) { const handle = handleOf(name) mockRowRects(authorRows()) await pointer(handle, 'pointerdown', 0) await pointer(handle, 'pointermove', clientY) return { release: () => pointer(handle, 'pointerup', clientY), cancel: () => pointer(handle, 'pointercancel', clientY), } } export function saveButtonEl() { return document.body.querySelector('[data-testid="save-button"]')! } export function patchCalls(backend: MetaBackend) { return backend.calls.filter( (call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path), ) } export async function unmountMeta() { metaKit.wrapper?.unmount() metaKit.wrapper = null vi.useRealTimers() await router.push('/login') document.body.innerHTML = '' }