Files
RenewCMS/web/src/views/__tests__/metaTestKit.ts
T

415 lines
11 KiB
TypeScript

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> = {}): Article {
return {
id,
title,
body: '<p>Intro</p>',
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<string, unknown>) {
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<typeof mount> | 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<typeof useApi>)
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<HTMLElement>('[data-testid="meta-section"]')
}
export function allMetaSections() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="meta-section"]'))
}
export function authorRows(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('[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<HTMLElement>(`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<HTMLButtonElement>('[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 = ''
}