464 lines
12 KiB
TypeScript
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"]',
|
|
),
|
|
)
|
|
}
|