/** * Integration-strata DTO contract — the C# records (scaffold-business, the * wire's authority) and the TS interfaces (scaffold-api-client) must describe * the SAME members for every surface, from ONE enriched fields[] (formula, * source, phase, isKey, data-scope owner). The historical drift: 34 computed/ * projected members the record sent and the interface denied — nothing could * display them, "pas même à la main" — while a formula passed through could * land in the TS Create DTO the C# record never had. * * lib/field-read-surface is the shared inclusion SSOT; this test pins the two * generators to it end-to-end (the sibling of screen-strata-contract.test.ts * on the DEFAULT stratum). */ import { describe, it, expect } from 'vitest' import { generate as generateApiClient } from '../generate.js' import { generate as generateBusiness } from '../../../../../backend/business-layer/cli/scaffold-business/generate.js' import type { ScaffoldApiClientInput } from '../types.js' import type { ScaffoldBusinessInput } from '../../../../../backend/business-layer/cli/scaffold-business/types.js' // ONE logical field list — PascalCase for the backend, camelCase mirror for // the frontend (Phase 3a passes the same enriched set to both). const LOGICAL_FIELDS = [ { pascal: 'Reference', type: 'string', required: true }, { pascal: 'DueDate', type: 'date', required: true }, { pascal: 'Overdue', type: 'bool', required: false, formula: 'DueDate < CreatedAt' }, { pascal: 'Email', type: 'string', required: false, source: { nav: 'User', property: 'Email' } }, { pascal: 'PaymentDate', type: 'date', required: false, phase: 'paiement' }, { pascal: 'Notes', type: 'string', required: false }, { pascal: 'OwnerUserId', type: 'guid', required: true }, { pascal: 'PlateNumber', type: 'string', required: false, derived: { kind: 'child', collection: 'Registrations', select: 'PlateNumber', pick: { mode: 'open-period', endField: 'EndDate' } } }, ] const camel = (s: string) => s.charAt(0).toLowerCase() + s.slice(1) const businessSpec: ScaffoldBusinessInput = { name: 'Order', pluralName: 'Orders', module: 'sales', appCode: 'Demo', applicationCode: 'erp', namespace: 'Demo', dataScope: { mode: 'own', ownerProperty: 'OwnerUserId' }, fields: LOGICAL_FIELDS.map(f => ({ name: f.pascal, type: f.type, required: f.required, isKey: false, ...(f.formula ? { formula: f.formula } : {}), ...(f.source ? { source: f.source } : {}), ...(f.phase ? { phase: f.phase } : {}), ...(f.derived ? { derived: f.derived } : {}), })), businessRules: [], customActions: [], projectPath: '/tmp/project', } as unknown as ScaffoldBusinessInput const apiClientSpec: ScaffoldApiClientInput = { module: 'sales', appCode: 'erp', entities: [{ name: 'Order', pluralName: 'Orders', section: 'orders', hasDashboard: false, dataScopeOwner: 'ownerUserId', fields: LOGICAL_FIELDS.map(f => ({ name: camel(f.pascal), type: f.type === 'bool' ? 'boolean' : f.type, required: f.required, ...(f.formula ? { formula: f.formula } : {}), ...(f.source ? { source: f.source } : {}), ...(f.phase ? { phase: f.phase } : {}), ...(f.derived ? { derived: f.derived } : {}), })), }], projectPath: '/web', useScreens: false, } as unknown as ScaffoldApiClientInput function csMembers(record: string): Map { // `Type Name,` lines — nullable when the type ends with `?`. Skip system tail. const out = new Map() for (const m of record.matchAll(/^\s{4}([\w?<>]+)\s+(\w+),?$/gm)) { if (['Id', 'CreatedAt', 'UpdatedAt', 'RowVersion'].includes(m[2])) continue out.set(camel(m[2]), m[1].endsWith('?')) } return out } function tsMembers(iface: string): Map { const out = new Map() for (const m of iface.matchAll(/^\s{2}(\w+)(\??):/gm)) { if (['id', 'createdAt', 'updatedAt', 'rowVersion'].includes(m[1])) continue out.set(m[1], m[2] === '?') } return out } function extract(source: string, open: string): string { const i = source.indexOf(open) expect(i, `${open} present`).toBeGreaterThan(-1) const close = source.indexOf(open.includes('record') ? ');' : '}', i) return source.slice(i, close) } describe('integration strata — C# records ≡ TS interfaces from ONE enriched fields[]', () => { const csFiles = generateBusiness(businessSpec) const dto = csFiles.find(f => f.path.endsWith('DTOs/OrderDto.cs') || /DTOs\/.*\.cs$/.test(f.path))! const tsFiles = generateApiClient(apiClientSpec) const types = tsFiles.find(f => f.path.endsWith('types/index.ts'))! it('ListDto: every computed/projected member the record sends exists on the interface', () => { const cs = csMembers(extract(dto.content, 'public record OrderListDto(')) const ts = tsMembers(extract(types.content, 'export interface OrderListDto {')) expect([...ts.keys()].sort()).toEqual([...cs.keys()].sort()) // The drift's poster children: the formula and the navigation projection. expect(ts.has('overdue')).toBe(true) expect(ts.has('email')).toBe(true) expect(ts.has('plateNumber')).toBe(true) // the derived member rides the read wire too }) it('CreateDto: read-only members and the owner NEVER enter; optional non-phased do', () => { const cs = csMembers(extract(dto.content, 'public record CreateOrderDto(')) const ts = tsMembers(extract(types.content, 'export interface CreateOrderDto {')) expect([...ts.keys()].sort()).toEqual([...cs.keys()].sort()) expect(ts.has('overdue')).toBe(false) expect(ts.has('email')).toBe(false) expect(ts.has('paymentDate')).toBe(false) expect(ts.has('ownerUserId')).toBe(false) expect(ts.has('plateNumber')).toBe(false) // derived = read-only, never a Create member expect(ts.get('notes')).toBe(true) // optional → nullable member (« Create honnête ») expect(ts.get('reference')).toBe(false) }) it('UpdateDto: stored members minus the owner — never the computed/projected ones', () => { const cs = csMembers(extract(dto.content, 'public record UpdateOrderDto(')) const ts = tsMembers(extract(types.content, 'export interface UpdateOrderDto {')) expect([...ts.keys()].sort()).toEqual([...cs.keys()].sort()) expect(ts.has('paymentDate')).toBe(true) // the edit surface writes the phased field expect(ts.has('overdue')).toBe(false) expect(ts.has('ownerUserId')).toBe(false) }) })