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

287 lines
7.7 KiB
TypeScript

import { vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
export type Responder = () => unknown
export const customTypes = [
{
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
fields: eventsFields,
updated_at: '2026-10-15T09:30:00Z',
},
{
id: 2,
key: 'recipes',
name: 'Recipes',
icon: null,
fields: recipesFields,
updated_at: '2026-10-02T09:30:00Z',
},
{
id: 3,
key: 'press-mentions',
name: 'Press mentions',
icon: null,
fields: new Array(5),
updated_at: '2026-09-18T09:30:00Z',
},
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
function articleField(label: string, key: string, type: string, rest: object = {}) {
return {
key,
label,
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
export const articleFields = [
articleField('Title', 'title', 'text', { required: true, max: 200 }),
articleField('Slug', 'slug', 'text', { required: true, unique: true }),
articleField('Body', 'body', 'richtext', { max: 10000 }),
articleField('Online', 'online', 'boolean', { default: false }),
articleField('Published on', 'published_on', 'date'),
]
export const builtinTypes = [
['articles', 'Articles', 5],
['authors', 'Authors', 5],
['sites', 'Sites', 8],
['media', 'Media', 6],
['users', 'Users', 6],
].map(([key, name, count]) => ({
key,
name,
fields: key === 'articles' ? articleFields : new Array(count),
}))
export function createdType(body: Record<string, unknown>) {
return {
id: 4,
icon: null,
created_at: '2026-10-19T09:30:00Z',
updated_at: '2026-10-19T09:30:00Z',
...body,
}
}
export function manyTypes(count: number) {
return Array.from({ length: count }, (_, index) => ({
...customTypes[0]!,
id: index + 1,
key: `type-${index + 1}`,
name: `Type ${index + 1}`,
icon: null,
}))
}
export function failWith(status: number): Responder {
return () => {
throw new ApiError(status)
}
}
export function failWithBody(status: number, body: string): Responder {
return () => {
throw new ApiError(status, undefined, body)
}
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export interface PostedRequest {
url: string
body: Record<string, unknown>
}
export const entryCounts: Record<string, number> = { events: 37, recipes: 0 }
export function entryPage(total: number) {
return { items: [], total, page: 1, size: 1 }
}
function defaultType(key: string) {
const found = customTypes.find((type) => type.key === key)
if (!found) {
throw new ApiError(404, undefined, 'content_type_not_found')
}
return found
}
export interface SchemaMount {
role?: string
create?: (body: PostedRequest['body']) => unknown
update?: (body: PostedRequest['body'], key: string) => unknown
entries?: (key: string) => unknown
remove?: (key: string) => unknown
width?: number
content?: Responder
builtin?: Responder
verified?: boolean
actions?: string[]
type?: (key: string) => unknown
list?: (key: string, query: URLSearchParams) => unknown
lookup?: (ids: number[]) => unknown
before?: () => void
}
let wrapper: VueWrapper | undefined
export const calls: string[] = []
export const posts: PostedRequest[] = []
export const puts: PostedRequest[] = []
export const deletes: string[] = []
export function updatedType(key: string, body: Record<string, unknown>) {
const base = customTypes.find((type) => type.key === key)!
return { ...base, ...body, key, updated_at: '2026-10-19T11:02:00Z' }
}
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
calls.length = 0
posts.length = 0
puts.length = 0
deletes.length = 0
localStorage.clear()
options.before?.()
setViewportWidth(options.width ?? DESKTOP_WIDTH)
const pinia = createPinia()
setActivePinia(pinia)
if (options.actions) {
const actions = options.actions
useContentTypesStore().$onAction(({ name }) => {
actions.push(name)
})
}
const session = useSessionStore()
session.session = {
verified: options.verified ?? true,
username: 'marta',
id: 1,
email: 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const request = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'DELETE') {
deletes.push(url)
return (options.remove ?? (() => undefined))(url.split('/').pop()!)
}
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body)) as PostedRequest['body']
const key = url.split('/').pop()!
puts.push({ url, body })
return (options.update ?? ((sent, target) => updatedType(target, sent)))(body, key)
}
if (init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as PostedRequest['body']
posts.push({ url, body })
return (options.create ?? (() => createdType(body)))(body)
}
calls.push(url)
if (url === '/v1/content-type') {
return (options.content ?? (() => [...customTypes]))()
}
const counted = url.match(/^\/v1\/content-type\/([^/]+)\/entry\?page=1&size=5$/)
if (counted) {
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!)
}
const listed = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\?(.*)$/)
if (listed) {
return (options.list ?? (() => entryPage(0)))(
listed[1]!,
new URLSearchParams(listed[2]),
)
}
const looked = url.match(/^\/v1\/user\/lookup\?ids=(.*)$/)
if (looked) {
return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number))
}
const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/)
if (detailed) {
return (options.type ?? defaultType)(detailed[1]!)
}
if (url === '/v1/builtin-type') {
return (options.builtin ?? (() => builtinTypes))()
}
return undefined
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(path)
wrapper = mount(
defineComponent({
render: () =>
h(UApp, null, {
default: () => (shellOnly ? h(DashboardShell) : h(RouterView)),
}),
}),
{
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
},
)
await flushPromises()
return wrapper
}
export async function unmountAll() {
wrapper?.unmount()
wrapper = undefined
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
export function pending() {
let resolve!: (value: unknown) => void
const promise = new Promise((done) => (resolve = done))
return { promise, resolve }
}
export function text(element: Element | null | undefined) {
if (!element) {
return ''
}
const parts: string[] = []
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
parts.push(node.textContent ?? '')
}
return parts.join(' ').replace(/\s+/g, ' ').trim()
}
export function button(label: string) {
return [...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === label,
)
}