/** * Wave F4 (2026-05-27) — bi-strata wire contract test. * * Proves that for every pagespec column shape supported by the bi-strata * stack, the C# DTO emitted by `scaffold-screen-controller` and the TS * interface emitted by `scaffold-api-client` (useScreens=true) describe the * SAME JSON wire object byte-for-byte: * * - same property names (camelCase on JSON; PascalCase on the C# record * auto-serialised to camelCase by ASP.NET's default JsonSerializer * contract) * - same primitive types (decimal/int/number-on-the-wire; Guid/string * UUID; DateTime/ISO-8601 string; bool/boolean) * * If this test fails, frontend will crash at runtime parsing JSON that * doesn't match its declared interface — the silent class of bugs the * bi-strata refactor was meant to PREVENT, not introduce. * * The contract table below is the single source of truth — when adding a * new formatHint, update this table FIRST, then mirror the change in both * `scaffold-screen-controller/generate.ts` (`dotnetTypeFor`) and * `scaffold-api-client/generate.ts` (`tsTypeForColumn`). */ import { describe, it, expect } from 'vitest' import { generate as generateApiClient } from '../generate.js' import { generate as generateScreenController } from '../../../../../backend/screen-controller/cli/scaffold-screen-controller/generate.js' import type { ScaffoldApiClientInput } from '../types.js' import type { PageSpec, ScaffoldScreenControllerSpec, } from '../../../../../backend/screen-controller/cli/scaffold-screen-controller/types.js' /** Canonical contract — keep in sync with the JSDoc on * `ApiScreenColumnSchema` (api-client) AND the dotnetTypeFor mapping * (screen-controller). */ const CONTRACT_TABLE: Array<{ formatHint: string csharpType: string csharpDefault: string tsType: string }> = [ { formatHint: 'currency', csharpType: 'decimal', csharpDefault: '0', tsType: 'number' }, { formatHint: 'number', csharpType: 'decimal', csharpDefault: '0', tsType: 'number' }, { formatHint: 'decimal', csharpType: 'decimal', csharpDefault: '0', tsType: 'number' }, { formatHint: 'integer', csharpType: 'int', csharpDefault: '0', tsType: 'number' }, { formatHint: 'count', csharpType: 'int', csharpDefault: '0', tsType: 'number' }, { formatHint: 'date', csharpType: 'DateTime', csharpDefault: 'default', tsType: 'string' }, { formatHint: 'datetime', csharpType: 'DateTime', csharpDefault: 'default', tsType: 'string' }, { formatHint: 'bool', csharpType: 'bool', csharpDefault: 'false', tsType: 'boolean' }, { formatHint: 'boolean', csharpType: 'bool', csharpDefault: 'false', tsType: 'boolean' }, // guid/uuid is asymmetric: C# holds the native Guid type, but ASP.NET // serialises it to a string on the JSON wire (`"00000000-…"`). So the TS // interface declares `string`. This is the contract that matters at the // boundary — both ends agree on the JSON shape. { formatHint: 'guid', csharpType: 'Guid', csharpDefault: 'Guid.Empty', tsType: 'string' }, { formatHint: 'uuid', csharpType: 'Guid', csharpDefault: 'Guid.Empty', tsType: 'string' }, { formatHint: 'whatever', csharpType: 'string', csharpDefault: '""', tsType: 'string' }, // fallback { formatHint: '', csharpType: 'string', csharpDefault: '""', tsType: 'string' }, ] function backendSpec(overrides: Partial = {}): ScaffoldScreenControllerSpec { return { section: 'orders', entity: 'Order', module: 'sales', appCode: 'erp', namespace: 'Erp', moduleDir: '/ba/ERP/SALES', projectPath: '/project', ...overrides, } } function frontendSpec(useScreens: boolean, overrides: Partial = {}): ScaffoldApiClientInput { return { module: 'sales', appCode: 'erp', entities: [ { name: 'Order', section: 'orders', hasDashboard: false, fields: [ { name: 'reference', type: 'string', required: true }, { name: 'createdBy', type: 'string', required: true }, ], }, ], projectPath: '/web', useScreens, ...overrides, } } function backendPagespec(view: 'list' | 'detail', columns: PageSpec['columns']): PageSpec { return { screenCode: `SCR-ERP-SALES-ORDERS-${view.toUpperCase()}`, appCode: 'erp', module: 'sales', section: 'orders', entity: 'Order', view, permission: 'sales.orders.read', linkedUseCases: [], linkedBusinessRules: [], columns, actions: [], } as PageSpec } describe('bi-strata wire contract (Wave F4 — backend C# DTO ↔ frontend TS interface)', () => { for (const { formatHint, csharpType, csharpDefault, tsType } of CONTRACT_TABLE) { it(`formatHint="${formatHint || '(empty)'}" → C# ${csharpType} (${csharpDefault}) and TS ${tsType}`, () => { const column = { key: 'value', formatHint } // Backend const { files: backendFiles } = generateScreenController(backendSpec(), [ backendPagespec('list', [column]), ]) const dto = backendFiles.find(f => f.path.endsWith('OrderListScreenDto.cs')) expect(dto, `C# DTO must be emitted for formatHint=${formatHint}`).toBeDefined() // The pagespec column "value" becomes PascalCase property "Value" on // the C# record. Auto-serialised to lower-case "value" on the wire by // ASP.NET's default JsonSerializer contract — matching the TS field // name. const csharpRe = new RegExp(`public ${csharpType} Value \\{ get; init; \\} = ${csharpDefault.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')};`) expect(dto!.content, `Backend DTO must declare 'public ${csharpType} Value { get; init; } = ${csharpDefault};'`).toMatch(csharpRe) // Frontend const frontendFiles = generateApiClient( frontendSpec(true, { entities: [ { name: 'Order', section: 'orders', hasDashboard: false, fields: [{ name: 'reference', type: 'string', required: true }], screenColumns: { list: [column] }, }, ], }), ) const types = frontendFiles.find(f => f.path.endsWith('types/index.ts')) expect(types, `TS types file must be emitted for formatHint=${formatHint}`).toBeDefined() // The pagespec column key is rendered verbatim on the TS side. const tsRe = new RegExp(`value: ${tsType};`) expect(types!.content, `Frontend TS interface must declare 'value: ${tsType};'`).toMatch(tsRe) }) } it('list+detail mismatch is caught — when detail adds columns, they appear in the TS DetailDto and the C# DetailScreenDto', () => { const listCols = [{ key: 'reference', formatHint: 'string' }, { key: 'status', formatHint: 'string' }] const detailCols = [ ...listCols, { key: 'amount', formatHint: 'currency' }, { key: 'createdAt', formatHint: 'datetime' }, ] // Backend const { files: backendFiles } = generateScreenController(backendSpec(), [ backendPagespec('list', listCols), backendPagespec('detail', detailCols), ]) const detailDto = backendFiles.find(f => f.path.endsWith('OrderDetailScreenDto.cs'))! expect(detailDto.content).toMatch(/public string Reference \{ get; init; \}/) expect(detailDto.content).toMatch(/public decimal Amount \{ get; init; \}/) expect(detailDto.content).toMatch(/public DateTime CreatedAt \{ get; init; \}/) // Frontend const frontendFiles = generateApiClient( frontendSpec(true, { entities: [ { name: 'Order', section: 'orders', hasDashboard: false, fields: [{ name: 'reference', type: 'string', required: true }], screenColumns: { list: listCols, detail: detailCols }, }, ], }), ) const types = frontendFiles.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/interface OrderListDto \{[\s\S]*?reference: string;[\s\S]*?status: string;[\s\S]*?\}/) expect(types.content).toMatch(/interface OrderDetailDto \{[\s\S]*?reference: string;[\s\S]*?amount: number;[\s\S]*?createdAt: string;[\s\S]*?\}/) }) it('URL paths align — frontend GET /list matches backend [HttpGet("list")] under the same /api/screens/{plural} root', () => { // Backend const { files: backendFiles } = generateScreenController(backendSpec(), [ backendPagespec('list', [{ key: 'reference', formatHint: 'string' }]), ]) const ctrl = backendFiles.find(f => f.path.endsWith('OrdersScreenController.cs'))! expect(ctrl.content).toMatch(/\[Route\("api\/screens\/orders"\)\]/) expect(ctrl.content).toMatch(/\[HttpGet\("list"\)\]/) // Frontend const frontendFiles = generateApiClient(frontendSpec(true)) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/screens\/orders';/) expect(service.content).toMatch(/`\$\{API_PATH\}\/list`/) }) it('delete cross-stratum — frontend hits /api/sales/orders/{id} (NavRoute-resolved), backend integration controller (not screen)', () => { // The screen-driven contract intentionally omits DELETE. Backend // scaffold-screen-controller emits no [HttpDelete] route — frontend // falls back to INTEGRATION_PATH (the integration controller's NavRoute-resolved path). const { files: backendFiles } = generateScreenController(backendSpec(), [ backendPagespec('list', [{ key: 'reference', formatHint: 'string' }]), backendPagespec('detail', [{ key: 'reference', formatHint: 'string' }]), ]) const ctrl = backendFiles.find(f => f.path.endsWith('OrdersScreenController.cs'))! expect(ctrl.content).not.toMatch(/HttpDelete/) const frontendFiles = generateApiClient(frontendSpec(true)) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! // navRoute defaults to `${module}.${section}` = sales.orders → /api/sales/orders. expect(service.content).toMatch(/const INTEGRATION_PATH = '\/api\/sales\/orders';/) expect(service.content).toMatch(/api\.delete\(`\$\{INTEGRATION_PATH\}\/\$\{id\}`\)/) }) it('routeMode=integration — frontend hits the SAME NavRoute-resolved route as scaffold-controller', () => { const frontendFiles = generateApiClient(frontendSpec(false, { routeMode: 'integration' })) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/sales\/orders';/) }) it('default routeMode (no flag) — frontend hits the SAME NavRoute-resolved route as scaffold-controller', () => { const frontendFiles = generateApiClient(frontendSpec(false)) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/sales\/orders';/) // The legacy direct shape (/api/{appCode}/{module}/{section}) must never appear. expect(service.content).not.toMatch(/\/api\/erp\/sales\/orders/) }) it('dashboard endpoints align — backend /api/screens/orders/dashboard/* matches frontend useDashboardOrder', () => { const { files: backendFiles } = generateScreenController(backendSpec(), [ backendPagespec('list' as 'list', []), ].concat([ { ...backendPagespec('list', []), view: 'dashboard', screenCode: 'SCR-DASH' } as PageSpec, ])) const ctrl = backendFiles.find(f => f.path.endsWith('OrdersScreenController.cs'))! // One endpoint per dashboard (replaces the old consolidated/alerts split). expect(ctrl.content).toMatch(/\[HttpGet\("dashboard"\)\]/) expect(ctrl.content).not.toMatch(/dashboard\/consolidated/) expect(ctrl.content).not.toMatch(/dashboard\/alerts/) // The self-contained DTO is emitted so the controller compiles standalone. const dto = backendFiles.find(f => f.path.endsWith('OrderDashboardDto.cs'))! expect(dto, 'OrderDashboardDto.cs').toBeDefined() expect(dto.content).toMatch(/Dictionary Widgets/) // Frontend's dashboard hook ALWAYS targets the screen stratum (the widget-aware // backend), emitted as a literal /api/screens/orders/dashboard. const frontendFiles = generateApiClient( frontendSpec(true, { entities: [ { name: 'Order', section: 'orders', hasDashboard: true, // emit the dashboard method on the api-client fields: [{ name: 'reference', type: 'string', required: true }], }, ], }), ) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! expect(service.content).toMatch(/`\/api\/screens\/orders\/dashboard`/) expect(service.content).not.toMatch(/dashboard\/consolidated/) }) }) describe('bi-strata wire contract — pagespec filter params (P6/P7 server filters)', () => { const FILTERS = [ { field: 'search', control: 'text' }, // fused with the search param { field: 'statusId', control: 'lookup' }, // rides the Guid FK channel { field: 'grade', control: 'select' }, { field: 'overdue', control: 'boolean' }, { field: 'orderDate', control: 'date-range' }, ] it('screens mode: the getAll params type declares one member per derived filter param', () => { const frontendFiles = generateApiClient( frontendSpec(true, { entities: [ { name: 'Order', section: 'orders', hasDashboard: false, fields: [ { name: 'reference', type: 'string', required: true }, { name: 'statusId', type: 'guid', required: true }, ], screenFilters: FILTERS, }, ], }), ) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! // FK param from the existing channel, then the filter params in order. expect(service.content).toMatch(/statusId\?: string; grade\?: string; overdue\?: boolean; orderDateFrom\?: string; orderDateTo\?: string \}/) // No member for the fused global-search filter. expect(service.content).not.toMatch(/search\?: string; search\?/) // And the wire params match the backend's [FromQuery] names byte-for-byte. const { files: backendFiles } = generateScreenController( backendSpec({ fkFilters: ['statusId'] }), [{ ...backendPagespec('list', [{ key: 'reference' }]), filters: FILTERS } as PageSpec], ) const ctrl = backendFiles.find(f => f.path.endsWith('OrdersScreenController.cs'))! for (const wire of ['grade', 'overdue', 'orderDateFrom', 'orderDateTo']) { expect(ctrl.content, wire).toContain(`[FromQuery]`) expect(ctrl.content, wire).toMatch(new RegExp(`\\b${wire} = null`)) } }) it('integration mode: the SAME filter members (GetAll binds them — server filters on both strata)', () => { const frontendFiles = generateApiClient( frontendSpec(false, { entities: [ { name: 'Order', section: 'orders', hasDashboard: false, fields: [ { name: 'reference', type: 'string', required: true }, { name: 'statusId', type: 'guid', required: true }, ], screenFilters: FILTERS, }, ], }), ) const service = frontendFiles.find(f => f.path.endsWith('orderService.ts'))! expect(service.content).toMatch(/statusId\?: string/) expect(service.content).toMatch(/grade\?: string/) expect(service.content).toMatch(/overdue\?: boolean/) expect(service.content).toMatch(/orderDateFrom\?: string/) expect(service.content).toMatch(/orderDateTo\?: string/) }) })