415 lines
11 KiB
TypeScript
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 = ''
|
|
}
|