feat(10-03): open, edit and save a record with toast and 422 feedback
- 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
This commit is contained in:
87
admin/tests/helpers.ts
Normal file
87
admin/tests/helpers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user