287 lines
7.7 KiB
TypeScript
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,
|
|
)
|
|
}
|