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

464 lines
12 KiB
TypeScript

import { vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
export interface Call {
method: string
path: string
raw: string
query: URLSearchParams
body: Record<string, unknown> | undefined
}
export type Responder = (call: Call) => unknown | Promise<unknown>
export interface Backend {
sites: Site[]
authors: Author[]
users: unknown[]
calls: Call[]
responders: Record<string, Responder>
}
export function sitesFixture(): Site[] {
const base = {
default_social_image_id: null,
default_robots: 'index' as const,
locale: null,
title_template: '%title% | %site%',
default_description: '',
}
return [
{
...base,
id: 1,
name: 'Renew',
site_name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
is_default: true,
article_count: 268,
},
{
...base,
id: 2,
name: 'Journal',
site_name: 'Journal',
key: 'journal',
base_url: 'https://journal.example',
is_default: false,
article_count: 38,
},
{
...base,
id: 3,
name: 'Projects',
site_name: 'Projects',
key: 'projects',
base_url: 'https://projects.example',
default_robots: 'noindex',
is_default: false,
article_count: 6,
},
{
...base,
id: 4,
name: 'Archive',
site_name: 'Archive',
key: 'archive',
base_url: 'https://archive.example',
is_default: false,
article_count: 0,
},
]
}
function author(overrides: Partial<Author> & Pick<Author, 'id' | 'name' | 'slug'>): Author {
return {
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
...overrides,
}
}
export function authorsFixture(withEmails = true): Author[] {
const list = [
author({
id: 2,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter.',
website: 'https://ines-duarte.example',
social_links: [
{
type: 'linkedin',
url: 'https://www.linkedin.com/in/ines-duarte',
},
],
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
}),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
article_count: 3,
}),
author({
id: 9,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
article_count: 1,
}),
author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }),
]
return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined }))
}
export function usersFixture() {
const user = (id: number, username: string, display: string, email: string, role: string) => ({
kind: 'user',
id,
username,
display_name: display,
email,
role,
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
})
return [
user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'),
user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'),
user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'),
]
}
function fail(status: number, code?: string): never {
throw new ApiError(status, undefined, code)
}
function parseBody(options: RequestInit | undefined) {
return typeof options?.body === 'string'
? (JSON.parse(options.body) as Record<string, unknown>)
: undefined
}
function pageOf(backend: Backend, query: URLSearchParams) {
const q = (query.get('q') ?? '').toLowerCase()
const size = Number(query.get('size') ?? 25)
const page = Number(query.get('page') ?? 1)
const matching = backend.authors.filter((item) =>
`${item.name} ${item.slug}`.toLowerCase().includes(q),
)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
}
export function defaultRoute(backend: Backend, call: Call): unknown {
const { method, path, query, body } = call
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
if (method === 'GET' && path === '/v1/site/') {
return structuredClone(backend.sites)
}
if (method === 'POST' && path === '/v1/site/') {
if (backend.sites.some((site) => site.key === body?.key)) {
fail(409, 'site_key_taken')
}
const created = {
id: 100,
default_social_image_id: null,
locale: null,
article_count: 0,
...body,
} as unknown as Site
backend.sites.push(created)
return created
}
if (siteMatch && method === 'PATCH') {
const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1]))
if (!site) {
fail(404, 'site_not_found')
}
if (siteMatch[2]) {
backend.sites.forEach((candidate) => (candidate.is_default = candidate === site))
} else {
Object.assign(site, body)
}
return structuredClone(site)
}
if (siteMatch && method === 'DELETE') {
const id = Number(siteMatch[1])
const site = backend.sites.find((candidate) => candidate.id === id)
if (!site) {
fail(404, 'site_not_found')
}
if (site.is_default) {
fail(403, 'default_site')
}
const reassign = query.get('reassign_to')
if (site.article_count > 0 && reassign === null) {
fail(409, 'site_has_articles')
}
backend.sites = backend.sites.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/author/') {
return pageOf(backend, query)
}
if (method === 'POST' && path === '/v1/author/') {
const created = {
id: 200,
is_linked: body?.user_id != null,
article_count: 0,
...body,
}
backend.authors.push(created as unknown as Author)
return created
}
if (authorMatch && method === 'PATCH') {
const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1]))
if (!target) {
fail(404, 'author_not_found')
}
Object.assign(target, body)
if (body && 'user_id' in body) {
target.is_linked = body.user_id !== null
}
return structuredClone(target)
}
if (authorMatch && method === 'DELETE') {
const id = Number(authorMatch[1])
backend.authors = backend.authors.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/user/') {
return backend.users
}
if (method === 'GET' && path === '/v1/media/') {
return {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
}
throw new Error(`Unexpected request: ${method} ${path}`)
}
export function createBackend(): Backend {
return {
sites: sitesFixture(),
authors: authorsFixture(),
users: usersFixture(),
calls: [],
responders: {},
}
}
export function callsTo(backend: Backend, method: string, path: string) {
return backend.calls.filter((call) => call.method === method && call.path === path)
}
export function failWith(status: number, code?: string): Responder {
return () => fail(status, code)
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export function pending<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
export interface MountOptions {
role?: string
width?: number
backend?: Backend
author?: MeAuthor
email?: string
}
export const kit: { wrapper: VueWrapper | null; backend: Backend } = {
wrapper: null,
backend: createBackend(),
}
export async function mountDirectory(
view: 'sites' | 'authors',
url: string,
options: MountOptions = {},
) {
setViewportWidth(options.width ?? DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
setActivePinia(createPinia())
const backend = options.backend ?? createBackend()
kit.backend = backend
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
email: options.email ?? 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
...(options.author ? { author: options.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: parseBody(init),
}
backend.calls.push(call)
const responder =
backend.responders[`${call.method} ${path}`] ??
backend.responders[`${call.method} ${pathname}`]
return responder ? responder(call) : defaultRoute(backend, call)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
const component = view === 'sites' ? SitesView : AuthorsView
kit.wrapper = mount(
defineComponent({
render: () => h(UApp, null, { default: () => h(component) }),
}),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return backend
}
export async function unmountDirectory() {
kit.wrapper?.unmount()
kit.wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function button(label: string | RegExp, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a')).find(
(element) => {
const text = element.getAttribute('aria-label') || textOf(element)
return typeof label === 'string' ? text === label : label.test(text)
},
)
}
export async function click(label: string | RegExp, root: ParentNode = document.body) {
const target = button(label, root)
if (!target) {
throw new Error(`No control named ${String(label)}`)
}
target.click()
await flushPromises()
}
export function dialog() {
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="directory-row"], [data-testid="directory-card"]',
),
)
}
export function rowTexts() {
return rows().map((row) => textOf(row))
}
export async function openRowMenu(name: string) {
await click(`Actions for ${name}`)
}
export async function chooseMenuItem(label: string) {
await click(label)
}
export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function blur(element: HTMLElement) {
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
element.dispatchEvent(new Event('blur'))
await flushPromises()
}
export function field(placeholder: string) {
return document.body.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
}
export function labelled(label: string) {
const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) =>
textOf(element).startsWith(label),
)
const id = labelElement?.getAttribute('for')
return id ? document.getElementById(id)! : null
}
export function toasts() {
return textOf(
document.body.querySelector(
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
),
)
}