- The SPA loads the backend::lang bundle before /auth/me, sets the document language from meta.locale and renders plural messages with Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME - Create and record routes; mapWinterUrl maps recordUrl and redirects onto the controller's list, create and record routes only - List rows open their record; FormView loads the form schema and the record, shows context-allowed fields in the span grid, saves values keyed by field name and toasts the resolved saved message - A 422 puts each message under its field (aria-invalid, aria-describedby), shows the plural banner, focuses the first invalid field in schema order and clears a field's error on change - The D-05 registry maps text, textarea, number and dropdown; any other type renders the unsupported-field box with the type in DM Mono - The admin OpenAPI document declares the write request bodies (AdminRecord, AdminIDsRequest) and the list filter query as a deepObject, so the typed client can send them - New backend::lang form.load_failed key; boardwalk/dist rebuilt
88 lines
2.9 KiB
TypeScript
88 lines
2.9 KiB
TypeScript
// Shared smoke-test helpers: a fetch mock keyed by "METHOD /path" and an app
|
|
// mount at a route with a signed-in admin. Fixtures are neutral acme.demo.*
|
|
// data; no application names appear in framework tests.
|
|
import { vi } from 'vitest'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import { createMemoryHistory } from 'vue-router'
|
|
import App from '../src/App.vue'
|
|
import { createAdminRouter } from '../src/app/router'
|
|
import { me } from '../src/state/useAuth'
|
|
import { loadNavigation } from '../src/state/useNavigation'
|
|
import navigation from './fixtures/navigation.json'
|
|
|
|
export const API = '/admin-test/api/v1'
|
|
|
|
export const profile = {
|
|
data: {
|
|
id: 7,
|
|
login: 'dev',
|
|
email: 'dev@example.test',
|
|
first_name: 'Dana',
|
|
last_name: 'Dev',
|
|
is_superuser: false,
|
|
role: { id: 2, code: 'developer', name: 'Developer' },
|
|
},
|
|
meta: {},
|
|
}
|
|
|
|
export interface Reply {
|
|
status?: number
|
|
body: unknown
|
|
}
|
|
|
|
export type Route = Reply | ((request: Request) => Reply | Promise<Reply>)
|
|
|
|
export function json(status: number, body: unknown): Response {
|
|
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
|
}
|
|
|
|
export const notFound = { error: { code: 'not_found', message: 'Not found', details: {} } }
|
|
|
|
/** Replaces fetch; routes are keyed by "METHOD pathname" (query ignored). */
|
|
export function mockApi(routes: Record<string, Route>): Request[] {
|
|
const calls: Request[] = []
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
|
const request = input as Request
|
|
calls.push(request)
|
|
const route = routes[`${request.method} ${new URL(request.url).pathname}`]
|
|
if (!route) {
|
|
return json(404, notFound)
|
|
}
|
|
const reply = typeof route === 'function' ? await route(request.clone()) : route
|
|
return json(reply.status ?? 200, reply.body)
|
|
})
|
|
return calls
|
|
}
|
|
|
|
export function pathOf(request: Request): string {
|
|
return new URL(request.url).pathname
|
|
}
|
|
|
|
export function queryOf(request: Request): URLSearchParams {
|
|
return new URL(request.url).searchParams
|
|
}
|
|
|
|
/** Requests matching a method and pathname, in call order. */
|
|
export function requestsTo(calls: Request[], method: string, pathname: string): Request[] {
|
|
return calls.filter((request) => request.method === method && pathOf(request) === pathname)
|
|
}
|
|
|
|
/** Mounts the whole app at a path with the profile and navigation loaded. */
|
|
export async function mountApp(path: string, routes: Record<string, Route>, options: { attach?: boolean } = {}) {
|
|
const calls = mockApi({
|
|
[`GET ${API}/auth/me`]: { body: profile },
|
|
[`GET ${API}/navigation`]: { body: navigation },
|
|
...routes,
|
|
})
|
|
await me()
|
|
await loadNavigation()
|
|
const router = createAdminRouter(createMemoryHistory())
|
|
await router.push(path)
|
|
const wrapper = mount(App, {
|
|
global: { plugins: [router] },
|
|
attachTo: options.attach ? document.body : undefined,
|
|
})
|
|
await flushPromises()
|
|
return { wrapper, calls, router }
|
|
}
|