import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldApiClientInput } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldApiClientInput { return { module: 'crm', appCode: 'TestV2', entities: [ { name: 'Contact', section: 'directory', hasDashboard: false, fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, ], }, ], projectPath: '/web', ...overrides, } } describe('scaffold-api-client / generate', () => { it('every api.get / post call assigns result via const data = … (api already unwraps)', () => { const files = generate(fixture()) const service = files.find((f) => f.path.endsWith('contactService.ts')) expect(service).toBeDefined() const lines = service!.content.split(/\r?\n/) for (let i = 0; i < lines.length; i++) { const line = lines[i] if (/await\s+api\.(get|post)\b/.test(line)) { expect(line).toMatch(/const\s+data\s*=\s*await\s+api/) } } }) it('never returns the raw AxiosResponse — services return T (or void)', () => { const files = generate(fixture()) const service = files.find((f) => f.path.endsWith('contactService.ts'))! // Forbid the raw-response anti-pattern. Hooks rely on T-shaped returns. expect(service.content).not.toMatch(/return\s+(?:res|response|axiosResponse)\b/) // Positive assertion: GET / POST return data. const dataReturns = service.content.match(/return\s+data\s*;/g) ?? [] expect(dataReturns.length).toBeGreaterThanOrEqual(3) // list + detail + create }) it('emits hooks under src/features/{app}/{module}/{entityLower}/hooks/', () => { const files = generate(fixture()) expect(files.some((f) => f.path === 'src/features/testv2/crm/contact/hooks/useContact.ts')).toBe(true) expect(files.some((f) => f.path === 'src/features/testv2/crm/contact/services/contactService.ts')).toBe(true) expect(files.some((f) => f.path === 'src/features/testv2/crm/contact/types/index.ts')).toBe(true) }) it('emits a single getDashboard method + {widgets} contract only when hasDashboard:true', () => { const noDash = generate(fixture()).find((f) => f.path.endsWith('contactService.ts'))! expect(noDash.content).not.toMatch(/getDashboard/) const withDash = generate( fixture({ entities: [{ ...fixture().entities[0], hasDashboard: true, customActions: [] }] }), ) const service = withDash.find((f) => f.path.endsWith('contactService.ts'))! const types = withDash.find((f) => f.path.endsWith('types/index.ts'))! // One endpoint per dashboard → GET .../dashboard (no consolidated/alerts split). expect(service.content).toMatch(/getDashboard:/) expect(service.content).not.toMatch(/getDashboardConsolidated/) expect(service.content).not.toMatch(/dashboard\/consolidated/) // The {widgets} contract replaces the bogus consolidated.metrics + alerts DTOs. expect(types.content).toMatch(/ContactDashboardDto/) expect(types.content).toMatch(/widgets: Record/) expect(types.content).not.toMatch(/DashboardConsolidatedDto/) expect(types.content).not.toMatch(/DashboardAlertDto/) }) }) describe('scaffold-api-client / generate — custom actions (per-page mode)', () => { function entityWithActions(customActions: NonNullable) { return { name: 'Budget', section: 'budgets', hasDashboard: false, fields: [ { name: 'code', type: 'string', required: true }, { name: 'label', type: 'string', required: true }, ], customActions, } } it('emits a service member per row-scope custom action with no payload', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'archive', scope: 'row', payloadType: null, responseType: 'void' }, ])], projectPath: '/web', }) const service = files.find((f) => f.path.endsWith('budgetService.ts'))! // Row-scope no-payload member: archive: async (id: string): Promise expect(service.content).toMatch(/archive: async \(id: string\): Promise/) expect(service.content).toMatch(/await api\.post\(`\${API_PATH}\/\${id}\/archive`\);/) }) it('emits a hook per custom action using useState/isPending pattern', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'archive', scope: 'row', payloadType: null, responseType: 'void' }, ])], projectPath: '/web', }) const hook = files.find((f) => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/export function useArchiveBudget\(\)/) expect(hook.content).toMatch(/isPending/) expect(hook.content).toMatch(/mutateAsync/) }) it('emits Promise return type when responseType is non-void', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'duplicate', scope: 'row', payloadType: null, responseType: 'BudgetDetailDto' }, ])], projectPath: '/web', }) const service = files.find((f) => f.path.endsWith('budgetService.ts'))! expect(service.content).toMatch(/duplicate: async \(id: string\): Promise/) expect(service.content).toMatch(/const data = await api\.post\(`\${API_PATH}\/\${id}\/duplicate`\);/) expect(service.content).toMatch(/return data;/) }) it('emits row-scope action with payload as useState hook', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'approve', scope: 'row', payloadType: 'ApproveRequest', responseType: 'BudgetDto' }, ])], projectPath: '/web', }) const hook = files.find((f) => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/export function useApproveBudget\(\)/) expect(hook.content).toMatch(/isPending/) expect(hook.content).toMatch(/mutateAsync/) }) it('BUG C — the hooks file IMPORTS the custom-action request/response types (not only the service)', () => { // The hook body references payloadType in mutateSig and responseType in // Promise; without importing them tsc fails TS2304 (the reported bug: // JoindreDocumentRequest/PieceJointeDto in useEchange, etc.). const files = generate({ module: 'echanges', appCode: 'TestV2', entities: [{ name: 'Echange', section: 'echanges', hasDashboard: false, fields: [{ name: 'objet', type: 'string', required: true }], customActions: [ { code: 'joindre-document', scope: 'row', payloadType: 'JoindreDocumentRequest', responseType: 'PieceJointeDto' }, ], }], projectPath: '/web', }) const hook = files.find((f) => f.path.endsWith('useEchange.ts'))! // Both custom types land on the `import type { … } from '../types'` line. expect(hook.content).toMatch(/import type \{[^}]*\bJoindreDocumentRequest\b[^}]*\} from '\.\.\/types'/) expect(hook.content).toMatch(/import type \{[^}]*\bPieceJointeDto\b[^}]*\} from '\.\.\/types'/) // …and they resolve — the types are declared in ../types by the same run. const types = files.find((f) => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/interface JoindreDocumentRequest/) expect(types.content).toMatch(/interface PieceJointeDto/) }) it('emits bulk-scope action with ids[] when no payloadType, posts to /bulk/', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'archive', scope: 'bulk', payloadType: null, responseType: 'void' }, ])], projectPath: '/web', }) const service = files.find((f) => f.path.endsWith('budgetService.ts'))! expect(service.content).toMatch(/archive: async \(ids: string\[\]\)/) expect(service.content).toMatch(/`\${API_PATH}\/bulk\/archive`, \{ ids \}/) const hook = files.find((f) => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/export function useArchiveBudget\(\)/) }) it('emits header-scope action without id parameter', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'export', scope: 'header', payloadType: null, responseType: 'void' }, ])], projectPath: '/web', }) const service = files.find((f) => f.path.endsWith('budgetService.ts'))! expect(service.content).toMatch(/export: async \(\): Promise/) expect(service.content).toMatch(/`\${API_PATH}\/export`/) const hook = files.find((f) => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/export function useExportBudget\(\)/) }) it('pascalizes kebab-case codes (`bulk-archive` → useBulkArchiveBudget)', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([ { code: 'bulk-archive', scope: 'bulk', payloadType: null, responseType: 'void' }, ])], projectPath: '/web', }) const hook = files.find((f) => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/export function useBulkArchiveBudget\(\)/) const service = files.find((f) => f.path.endsWith('budgetService.ts'))! // Service member uses camelCase expect(service.content).toMatch(/bulkArchive: async/) // URL keeps kebab-case expect(service.content).toMatch(/`\${API_PATH}\/bulk\/bulk-archive`/) }) it('emits no custom hooks/members when customActions is empty (legacy default)', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [entityWithActions([])], projectPath: '/web', }) const service = files.find((f) => f.path.endsWith('budgetService.ts'))! expect(service.content).not.toMatch(/archive:/) expect(service.content).not.toMatch(/duplicate:/) const hook = files.find((f) => f.path.endsWith('useBudget.ts'))! expect(hook.content).not.toMatch(/useArchive/) expect(hook.content).not.toMatch(/useDuplicate/) }) it('uses endpoint field for URL when provided, code for naming', () => { // Closes the historical REFERENTIELS class of 404s where the pagespec // `code: 'toggle-actif'` was emitted into the URL while the controller // route was `[HttpPost("activate")]`. With `endpoint` set, the URL // matches the controller; `code` continues to drive the TS identifier. const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [{ code: 'toggle-actif', endpoint: 'activate', scope: 'row', httpMethod: 'post', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('contactService.ts'))! // Member name derived from code (camelCase) expect(service.content).toMatch(/toggleActif: async \(id: string\)/) // URL derived from endpoint — NOT from code expect(service.content).toMatch(/`\$\{API_PATH\}\/\$\{id\}\/activate`/) expect(service.content).not.toMatch(/\/toggle-actif/) // Hook name still derived from code (PascalCase) const hook = files.find(f => f.path.endsWith('useContact.ts'))! expect(hook.content).toMatch(/export function useToggleActifContact\(\)/) }) it('falls back to code for URL when endpoint is absent (backward compat)', () => { const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [{ code: 'archive', scope: 'row', httpMethod: 'post', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/`\$\{API_PATH\}\/\$\{id\}\/archive`/) }) it('respects httpMethod for GET actions with endpoint override', () => { // Mirrors the REFERENTIELS `analyze-impact` / `detect-anomalies` pattern // where the BA code stays verbose but the controller exposes a short // GET route (no body, query-only). const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [{ code: 'analyze-impact', endpoint: 'impact', scope: 'row', httpMethod: 'get', responseType: 'ImpactResult', }], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/api\.get/) expect(service.content).toMatch(/`\$\{API_PATH\}\/\$\{id\}\/impact`/) expect(service.content).not.toMatch(/\/analyze-impact/) }) it('header-scope action with endpoint override (sync-from-pce → sync-from-proconcept)', () => { // Closes the REFERENTIELS `sync-from-pce` → controller `sync-from-proconcept` mismatch. const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Employe', section: 'employes', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [{ code: 'sync-from-pce', endpoint: 'sync-from-proconcept', scope: 'header', httpMethod: 'post', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('employeService.ts'))! // Header-scope: no /{id}/ prefix. URL = API_PATH + endpoint. expect(service.content).toMatch(/`\$\{API_PATH\}\/sync-from-proconcept`/) expect(service.content).not.toMatch(/\/sync-from-pce/) // TS member name uses code → syncFromPce expect(service.content).toMatch(/syncFromPce: async \(\)/) }) it('skips kind:"navigate" actions — no service member emitted', () => { // The REFERENTIELS post-mortem: BA pagespecs declared `open` actions // (kind:"navigate", targetScreen:SCR-…-DETAIL) which propagated through // ba-develop into 6 phantom `POST /{id}/open` endpoints — none of which // exist on the backend controllers. With the filter, scaffold-api-client // emits nothing for these; scaffold-component still renders the button // as an inline `navigate(targetRoute)`. const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [ { code: 'open', kind: 'navigate', scope: 'row', responseType: 'void' }, // A legit API action survives the filter side-by-side. { code: 'archive', kind: 'api', scope: 'row', httpMethod: 'post', responseType: 'void' }, ], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('contactService.ts'))! // The navigate action MUST NOT produce a service member. expect(service.content).not.toMatch(/open:\s*async/) expect(service.content).not.toMatch(/\${API_PATH}\/\${id}\/open/) // The legit api action still appears. expect(service.content).toMatch(/archive: async \(id: string\)/) }) it('skips kind:"navigate" actions — no React hook emitted', () => { const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [ { code: 'open', kind: 'navigate', scope: 'row', responseType: 'void' }, ], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useContact.ts'))! // No `useOpenContact()` hook anywhere. expect(hook.content).not.toMatch(/useOpenContact/) }) it('defaults kind to "api" (backward compat — actions without kind still emit)', () => { const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [{ name: 'Name', type: 'string', required: true }], customActions: [ // No `kind` field — must default to 'api' per Zod schema. { code: 'archive', scope: 'row', responseType: 'void' }, ], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/archive: async \(id: string\)/) }) }) describe('scaffold-api-client / generate — lookup hook (feeds )', () => { it('emits {Entity}RefDto in the types file', () => { const files = generate(fixture()) const types = files.find((f) => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/export interface ContactRefDto \{\s+id: string;\s+displayName: string;\s+\}/) }) it('imports {Entity}RefDto into the service', () => { const files = generate(fixture()) const service = files.find((f) => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/import type \{[^}]*ContactRefDto[^}]*\} from '\.\.\/types'/) }) it('emits getLookup service method hitting GET ${API_PATH}/lookup with search/page/pageSize params', () => { const files = generate(fixture()) const service = files.find((f) => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/getLookup: async \(params\?: \{ search\?: string; page\?: number; pageSize\?: number \}\)/) expect(service.content).toMatch(/api\.get>\(`\$\{API_PATH\}\/lookup`, \{ params \}\)/) expect(service.content).toMatch(/return data;/) }) it('emits use{Entity}Lookup hook with useState pattern', () => { const files = generate(fixture()) const hook = files.find((f) => f.path.endsWith('useContact.ts'))! expect(hook.content).toMatch(/export function useContactLookup\(params\?: \{ search\?: string; page\?: number; pageSize\?: number \}\)/) expect(hook.content).toMatch(/contactService\.getLookup\(params\)/) expect(hook.content).toMatch(/useState/) }) }) describe('scaffold-api-client / generate — Wave E (useScreens=true, screen-driven mode)', () => { it('sets API_PATH to /api/screens/{plural-kebab} when useScreens=true', () => { const files = generate(fixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/screens\/contacts';/) expect(service.content).not.toMatch(/const API_PATH = '\/api\/crm\/contacts';/) }) it('declares INTEGRATION_PATH (NavRoute-resolved) for delete + getLookup fallback', () => { const files = generate(fixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('contactService.ts'))! // navRoute defaults to `${module}.${section}` = crm.directory → /api/crm/directory. expect(service.content).toMatch(/const INTEGRATION_PATH = '\/api\/crm\/directory';/) }) it('default routeMode (integration) → NavRoute-resolved /api/{module}/{section}', () => { const files = generate(fixture()) // default routeMode = 'integration' const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/crm\/directory';/) // Neither the legacy direct shape (/api/{appCode}/...) nor the dead integration // literal must appear. expect(service.content).not.toMatch(/\/api\/testv2\/crm\/directory/) expect(service.content).not.toMatch(/\/api\/v1\/integration\//) expect(service.content).not.toMatch(/INTEGRATION_PATH/) }) it('routeMode integration → NavRoute-resolved /api/{module}/{section}', () => { const files = generate(fixture({ routeMode: 'integration' })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/crm\/directory';/) expect(service.content).not.toMatch(/INTEGRATION_PATH/) }) it('honours an explicit entity.navRoute over the {module}.{section} default', () => { const files = generate(fixture({ entities: [{ name: 'Contact', section: 'directory', navRoute: 'repertoire.contacts', hasDashboard: false, fields: [] }] })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/const API_PATH = '\/api\/repertoire\/contacts';/) }) it('getAll hits /list and getById hits /detail/{id} in screen-driven mode', () => { const files = generate(fixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/api\.get>\(`\$\{API_PATH\}\/list`/) expect(service.content).toMatch(/api\.get\(`\$\{API_PATH\}\/detail\/\$\{id\}`/) }) it('create POSTs to /form and update PUTs to /form/{id} in screen-driven mode', () => { const files = generate(fixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/api\.post\(`\$\{API_PATH\}\/form`,/) expect(service.content).toMatch(/api\.put\(`\$\{API_PATH\}\/form\/\$\{id\}`,/) }) it('delete falls back to INTEGRATION_PATH in screen-driven mode', () => { const files = generate(fixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/api\.delete\(`\$\{INTEGRATION_PATH\}\/\$\{id\}`\)/) }) it('getLookup falls back to INTEGRATION_PATH in screen-driven mode', () => { const files = generate(fixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/api\.get>\(`\$\{INTEGRATION_PATH\}\/lookup`,/) }) }) describe('scaffold-api-client / generate — Wave F1 (screen-driven DTOs shaped from pagespec columns)', () => { function entityWithScreenColumns() { return { name: 'Contact', section: 'directory', hasDashboard: false, fields: [ // Random domain fields the BA does NOT want exposed on the list view. // Phase 3a should NOT pick the first 5 of these — it should follow // the pagespec columns instead. { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, { name: 'email', type: 'string', required: false }, { name: 'phone', type: 'string', required: false }, { name: 'internalNotes', type: 'string', required: false }, { name: 'secretField', type: 'string', required: false }, // never on the list ], screenColumns: { list: [ { key: 'fullName', formatHint: 'string' }, { key: 'status', formatHint: 'string' }, { key: 'lastContactAt', formatHint: 'datetime' }, ], detail: [ { key: 'fullName', formatHint: 'string' }, { key: 'status', formatHint: 'string' }, { key: 'lastContactAt', formatHint: 'datetime' }, { key: 'totalOrders', formatHint: 'integer' }, { key: 'revenue', formatHint: 'currency' }, ], }, } } it('shapes ContactListDto from pagespec list columns (not from entity.fields)', () => { const files = generate(fixture({ useScreens: true, entities: [entityWithScreenColumns()] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/interface ContactListDto \{[\s\S]*?fullName: string;[\s\S]*?status: string;[\s\S]*?lastContactAt: string;[\s\S]*?\}/) // Domain fields NOT in pagespec columns must NOT leak into the list DTO. expect(types.content.split('export interface ContactDetailDto')[0]).not.toMatch(/firstName:/) expect(types.content.split('export interface ContactDetailDto')[0]).not.toMatch(/secretField:/) }) it('shapes ContactDetailDto from pagespec detail columns (richer than list)', () => { const files = generate(fixture({ useScreens: true, entities: [entityWithScreenColumns()] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! // Detail superset: all list cols + totalOrders + revenue. expect(types.content).toMatch(/interface ContactDetailDto \{[\s\S]*?totalOrders: number;[\s\S]*?revenue: number;[\s\S]*?\}/) }) it('maps formatHint to TS type exactly like dotnetTypeFor on the backend (parity)', () => { const cols = [ { key: 'price', formatHint: 'currency' }, { key: 'qty', formatHint: 'integer' }, { key: 'active', formatHint: 'boolean' }, { key: 'createdAt', formatHint: 'datetime' }, { key: 'ownerId', formatHint: 'guid' }, { key: 'note', formatHint: 'whatever' }, // fallback ] const ent = { ...entityWithScreenColumns(), screenColumns: { list: cols, detail: cols } } const files = generate(fixture({ useScreens: true, entities: [ent] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/price: number;/) expect(types.content).toMatch(/qty: number;/) expect(types.content).toMatch(/active: boolean;/) expect(types.content).toMatch(/createdAt: string;/) // ISO-8601 on the wire expect(types.content).toMatch(/ownerId: string;/) expect(types.content).toMatch(/note: string;/) // default fallback }) it('falls back to legacy entity.fields when screenColumns is omitted even if useScreens=true', () => { // Backward compat: a project that flips useScreens but has not yet wired // pagespec columns into the spec still gets a working (best-effort) TS DTO. const ent = { name: 'Contact', section: 'directory', hasDashboard: false, fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, ], } const files = generate(fixture({ useScreens: true, entities: [ent] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/firstName: string;/) expect(types.content).toMatch(/lastName: string;/) }) it('uses screenColumns regardless of useScreens / routeMode (Wave F5 — gate removed)', () => { // Wave F5 (2026-05-27): screenColumns now win in ALL route modes. // Previously gated behind `isScreenMode`, which made non-screen-mode DTOs // fall back to entity.fields.slice(0,5) and silently mismatch the C# DTO. const files = generate(fixture({ useScreens: false, entities: [entityWithScreenColumns()] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! // List DTO comes from pagespec columns — fullName/status/lastContactAt. const listSlice = types.content.split('export interface ContactDetailDto')[0] expect(listSlice).toMatch(/fullName: string;/) expect(listSlice).toMatch(/status: string;/) expect(listSlice).toMatch(/lastContactAt: string;/) // Domain fields not in pagespec MUST NOT leak into the list shape. expect(listSlice).not.toMatch(/firstName:/) expect(listSlice).not.toMatch(/secretField:/) }) it('uses screenColumns in integration mode (the REFERENTIELS regression class)', () => { // Closes the bug where integration-mode DTOs were shaped from // entity.fields.slice(0,5) — wrong fields + wrong types vs the actual // C# controller response (Employe/Secteur/Site etc. in GAF). const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [ // Pure domain fields — must NOT drive the wire shape any more. { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, { name: 'internalNotes', type: 'string', required: false }, ], screenColumns: { list: [ { key: 'name' }, { key: 'score', formatHint: 'number' }, { key: 'isActive', formatHint: 'boolean' }, ], }, }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.includes('types/index.ts'))! const listSlice = types.content.split('export interface ContactDetailDto')[0] // Shaped from screenColumns, not entity.fields expect(listSlice).toMatch(/name: string;/) expect(listSlice).toMatch(/score: number;/) expect(listSlice).toMatch(/isActive: boolean;/) expect(listSlice).not.toMatch(/firstName:/) expect(listSlice).not.toMatch(/internalNotes:/) }) it('falls back to ALL entity.fields when screenColumns absent in integration mode (client defect 2026-08-25 #5)', () => { // Backward compatibility — any spec that hasn't been migrated to carry // pagespec columns still emits a well-formed DTO. The fallback carries // EVERY business field: the backend {E}ListDto has no first-5 window, so a // slice(0,5) here made the TS type LIE about the JSON (TS2339 on any // column beyond the 5th — 4 of 12 referential lists in the client run). const files = generate({ module: 'crm', appCode: 'app', entities: [{ name: 'Contact', section: 'contacts', hasDashboard: false, fields: [ { name: 'Name', type: 'string', required: true }, { name: 'Email', type: 'string', required: false }, { name: 'Phone', type: 'string', required: false }, { name: 'Score', type: 'int', required: true }, { name: 'Active', type: 'bool', required: true }, { name: 'Notes', type: 'string', required: false }, ], // screenColumns intentionally omitted }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.includes('types/index.ts'))! const listSlice = types.content.split('export interface ContactDetailDto')[0] expect(listSlice).toMatch(/name: string;/) expect(listSlice).toMatch(/active: boolean;/) // The 6th field is now typable — parity with the C# ListDto. expect(listSlice).toMatch(/notes: string;/) }) it('Create/Update Dtos still derive from entity.fields (form posts use the integration command shape)', () => { // The screen-driven /form endpoint reuses Create{E}Command from the // integration stratum (scaffold-screen-controller emits exactly that). // So the TS Create{E}Dto must follow entity.fields, NOT screenColumns. const files = generate(fixture({ useScreens: true, entities: [entityWithScreenColumns()] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/interface CreateContactDto \{[\s\S]*?firstName: string;[\s\S]*?lastName: string;[\s\S]*?\}/) expect(types.content).toMatch(/interface UpdateContactDto \{[\s\S]*?firstName: string;[\s\S]*?email\?: string;[\s\S]*?\}/) }) it('detail falls back to list columns when no detail columns are provided', () => { const ent = { ...entityWithScreenColumns(), screenColumns: { list: [{ key: 'fullName', formatHint: 'string' }, { key: 'status', formatHint: 'string' }], // detail omitted on purpose }, } const files = generate(fixture({ useScreens: true, entities: [ent] })) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/interface ContactDetailDto \{[\s\S]*?fullName: string;[\s\S]*?status: string;[\s\S]*?\}/) }) }) describe('scaffold-api-client / generate — Fix #1 (sub-resource parentPath)', () => { function subResourceFixture(overrides: Partial = {}): ScaffoldApiClientInput { return { module: 'referentiels', appCode: 'gaf', entities: [{ name: 'Rue', pluralName: 'Rues', section: 'rues', parentPath: '/api/gaf/referentiels/sites/{parentId}/rues', parentIdParam: 'siteId', hasDashboard: false, fields: [{ name: 'nomRue', type: 'string', required: true }], }], projectPath: '/test', routeMode: 'integration', ...overrides, } as ScaffoldApiClientInput } it('emits API_PATH as a function (parentId) => string when parentPath is set', () => { const files = generate(subResourceFixture()) const service = files.find(f => f.path.endsWith('rueService.ts'))! expect(service.content).toMatch(/const API_PATH = \(siteId: string\) =>/) // Template substitution: `{parentId}` placeholder replaced by `${siteId}` expect(service.content).toMatch(/`\/api\/gaf\/referentiels\/sites\/\$\{siteId\}\/rues`/) }) it('every CRUD service method takes siteId as its first argument', () => { const files = generate(subResourceFixture()) const service = files.find(f => f.path.endsWith('rueService.ts'))! expect(service.content).toMatch(/getAll: async \(siteId: string, params\?:/) expect(service.content).toMatch(/getById: async \(siteId: string, id: string\)/) expect(service.content).toMatch(/create: async \(siteId: string, payload: CreateRueDto\)/) expect(service.content).toMatch(/update: async \(siteId: string, id: string, payload: UpdateRueDto\)/) expect(service.content).toMatch(/delete: async \(siteId: string, id: string\)/) expect(service.content).toMatch(/getLookup: async \(siteId: string, params\?:/) }) it('every CRUD service method calls API_PATH(siteId) in its URL template', () => { const files = generate(subResourceFixture()) const service = files.find(f => f.path.endsWith('rueService.ts'))! // getAll uses the function call as the URL prefix expect(service.content).toMatch(/api\.get>\(API_PATH\(siteId\), \{ params \}\)/) // getById/update/delete use it inside template literals with /${id} expect(service.content).toMatch(/`\$\{API_PATH\(siteId\)\}\/\$\{id\}`/) }) it('every hook propagates the parent ID into its signature and service call', () => { const files = generate(subResourceFixture()) const hook = files.find(f => f.path.endsWith('useRue.ts'))! // List + detail hooks expect(hook.content).toMatch(/export function useRues\(siteId: string, params\?:/) expect(hook.content).toMatch(/export function useRue\(siteId: string, id: string\)/) // Mutation hooks bind the parent ID at hook-instantiation time expect(hook.content).toMatch(/export function useCreateRue\(siteId: string\)/) expect(hook.content).toMatch(/export function useUpdateRue\(siteId: string\)/) expect(hook.content).toMatch(/export function useDeleteRue\(siteId: string\)/) // Service calls thread siteId through expect(hook.content).toMatch(/rueService\.getAll\(siteId, params\)/) expect(hook.content).toMatch(/rueService\.create\(siteId, data\)/) }) it('rejects parentPath + useScreens combo (mutually exclusive)', () => { expect(() => generate(subResourceFixture({ useScreens: true }))).toThrow( /parentPath.*screen mode.*mutually exclusive/i, ) }) it('non-nested entities keep their legacy signatures byte-for-byte (backward compat)', () => { // Default fixture is a flat Contact — no parentPath. The emitted service // must NOT carry any parentId parameter anywhere. const files = generate(fixture()) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/const API_PATH = '/) // plain string constant expect(service.content).not.toMatch(/const API_PATH = \(/) // not a function expect(service.content).toMatch(/getAll: async \(params\?:/) // The list params carry the full server contract: page/size/search + sort. expect(service.content).toMatch(/getAll: async \(params\?: \{ page\?: number; pageSize\?: number; search\?: string; sortBy\?: string; sortDir\?: string \}\)/) expect(service.content).toMatch(/getById: async \(id: string\)/) const hook = files.find(f => f.path.endsWith('useContact.ts'))! expect(hook.content).toMatch(/export function useContacts\(params\?: \{ page\?: number; pageSize\?: number; search\?: string; sortBy\?: string; sortDir\?: string \}\)/) }) }) describe('scaffold-api-client / generate — relation (FK) filter params', () => { function fkFixture(overrides: Partial = {}): ScaffoldApiClientInput { return fixture({ entities: [ { name: 'Invoice', section: 'invoices', hasDashboard: false, fields: [ { name: 'number', type: 'string', required: true }, { name: 'clientId', type: 'Guid', required: true }, { name: 'ContactId', type: 'guid', required: false }, ], }, ], ...overrides, }) } it('getAll + use{Plural} expose one optional string param per Guid FK (integration mode)', () => { const files = generate(fkFixture()) const service = files.find(f => f.path.endsWith('invoiceService.ts'))! // Wire key === pagespec relatedTabs[].relationFk, camelCase whatever the input casing. expect(service.content).toMatch(/getAll: async \(params\?: \{ page\?: number; pageSize\?: number; search\?: string; sortBy\?: string; sortDir\?: string; clientId\?: string; contactId\?: string \}\)/) const hook = files.find(f => f.path.endsWith('useInvoice.ts'))! expect(hook.content).toMatch(/export function useInvoices\(params\?: \{ page\?: number; pageSize\?: number; search\?: string; sortBy\?: string; sortDir\?: string; clientId\?: string; contactId\?: string \}\)/) }) it('screen mode carries the same FK params on the /list call', () => { const files = generate(fkFixture({ useScreens: true })) const service = files.find(f => f.path.endsWith('invoiceService.ts'))! expect(service.content).toMatch(/getAll: async \(params\?: \{ page\?: number; pageSize\?: number; search\?: string; sortBy\?: string; sortDir\?: string; clientId\?: string; contactId\?: string \}\)/) }) it('non-FK Guid system fields and string ids never become filter params', () => { const files = generate(fixture({ entities: [ { name: 'Contact', section: 'directory', hasDashboard: false, fields: [ { name: 'tenantId', type: 'Guid', required: true }, { name: 'externalId', type: 'string', required: false }, { name: 'label', type: 'string', required: true }, ], }, ], })) const service = files.find(f => f.path.endsWith('contactService.ts'))! expect(service.content).toMatch(/getAll: async \(params\?: \{ page\?: number; pageSize\?: number; search\?: string; sortBy\?: string; sortDir\?: string \}\)/) }) }) describe('scaffold-api-client / generate — Wave F5 (PaginatedResult.totalCount alignment)', () => { it('emits totalCount (not total) in PaginatedResult — matches SmartStack.app PaginatedResult.TotalCount', () => { const files = generate(fixture()) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/totalCount: number;/) // The historical `total: number` is gone — it silently dropped the count // for every paginated endpoint because the JSON key was always `totalCount`. expect(types.content).not.toMatch(/^\s*total: number;/m) }) it('totalCount field is exported in all route modes (integration, screens)', () => { for (const mode of ['integration'] as const) { const f = generate(fixture({ routeMode: mode })).find(x => x.path.endsWith('types/index.ts'))! expect(f.content, `routeMode=${mode}`).toMatch(/totalCount: number;/) } const screens = generate(fixture({ useScreens: true })).find(x => x.path.endsWith('types/index.ts'))! expect(screens.content, 'useScreens=true').toMatch(/totalCount: number;/) }) }) describe('scaffold-api-client / generate — Bug 1 (custom action payload/response types are emitted + imported)', () => { // Reproducer: AFFAIRES/Demande pagespecs referenced CloseRequest, RefuseBalanceRequest, // ReconductRequest, ReconductResponse, PrimeApiExportFile in their customActions. // The CLI never declared nor imported these, so the generated Service.ts // failed compilation with TS2304 « cannot find name CloseRequest » 7 times. it('emits permissive placeholder interface when *Shape is missing', () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', section: 'demandes', hasDashboard: false, fields: [{ name: 'Numero', type: 'string', required: true }], customActions: [{ code: 'close', kind: 'api', scope: 'row', httpMethod: 'post', payloadType: 'CloseRequest', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/export interface CloseRequest \{/) expect(types.content).toMatch(/\[key: string\]: unknown;/) expect(types.content).toMatch(/TODO\[BA-SHAPE\]/) }) it('emits typed interface when payloadShape is provided', () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', section: 'demandes', hasDashboard: false, fields: [{ name: 'Numero', type: 'string', required: true }], customActions: [{ code: 'reconduct', kind: 'api', scope: 'row', httpMethod: 'post', payloadType: 'ReconductRequest', payloadShape: { motif: 'string', newDeadline: 'string' }, responseType: 'ReconductResponse', responseShape: { newId: 'string', warnings: 'string[]' }, }], }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/export interface ReconductRequest \{/) expect(types.content).toMatch(/motif: string;/) expect(types.content).toMatch(/newDeadline: string;/) expect(types.content).toMatch(/export interface ReconductResponse \{/) expect(types.content).toMatch(/newId: string;/) expect(types.content).toMatch(/warnings: string\[\];/) }) it('imports custom payload/response types in the service file', () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', section: 'demandes', hasDashboard: false, fields: [{ name: 'Numero', type: 'string', required: true }], customActions: [ { code: 'close', scope: 'row', payloadType: 'CloseRequest', responseType: 'void' }, { code: 'export', scope: 'header', payloadType: null, responseType: 'PrimeApiExportFile' }, ], }], projectPath: '/test', routeMode: 'integration', }) const service = files.find(f => f.path.endsWith('demandeService.ts'))! expect(service.content).toMatch(/import type \{[^}]*CloseRequest[^}]*\} from '\.\.\/types'/) expect(service.content).toMatch(/import type \{[^}]*PrimeApiExportFile[^}]*\} from '\.\.\/types'/) }) it('does NOT re-emit entity DTOs already exported by the file (no collision)', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [{ name: 'Budget', section: 'budgets', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [{ code: 'duplicate', scope: 'row', payloadType: null, // Reuses the entity's own DTO as a response — must not be re-declared. responseType: 'BudgetDetailDto', }], }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.endsWith('types/index.ts'))! // BudgetDetailDto exists exactly once (the entity DTO declaration). const matches = types.content.match(/export interface BudgetDetailDto \{/g) ?? [] expect(matches.length).toBe(1) }) it('does NOT emit declarations for TS builtins (void/string/string[])', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [{ name: 'Budget', section: 'budgets', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [ { code: 'archive', scope: 'row', payloadType: null, responseType: 'void' }, { code: 'export', scope: 'header', payloadType: null, responseType: 'string' }, ], }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).not.toMatch(/export interface void/) expect(types.content).not.toMatch(/export interface string/) }) }) describe('scaffold-api-client / generate — Bug 2 (entity alias export)', () => { // Reproducer: scaffold-component pages emitted `import { Demande } from '.../types'` // but scaffold-api-client only exported `DemandeListDto` + `DemandeDetailDto`, // triggering 4× TS2305 « no exported member ». it('exports {E} as an alias of {E}DetailDto', () => { const files = generate(fixture()) const types = files.find(f => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/export type Contact = ContactDetailDto;/) }) it('alias works in every route mode (integration, screens)', () => { for (const mode of ['integration', 'screens'] as const) { const f = generate( mode === 'screens' ? fixture({ useScreens: true }) : fixture({ routeMode: mode }), ).find(x => x.path.endsWith('types/index.ts'))! expect(f.content, `mode=${mode}`).toMatch(/export type Contact = ContactDetailDto;/) } }) }) describe('scaffold-api-client / generate — Bug 3 (custom hook mutateAsync arg count)', () => { // Reproducer: `useCloseDemande` called `service.close(arg)` with a single // argument while `service.close(id, payload)` expected two — TS2554. it('row-scope + payload hook signature is mutateAsync({ id, payload })', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [{ name: 'Budget', section: 'budgets', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [{ code: 'approve', scope: 'row', payloadType: 'ApproveRequest', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useBudget.ts'))! // The hook destructures { id, payload } AND threads both into the service call. expect(hook.content).toMatch(/const mutateAsync = async \(\{ id, payload \}: \{ id: string; payload: ApproveRequest \}\)/) expect(hook.content).toMatch(/budgetService\.approve\(id, payload\)/) // The legacy `(idOrPayload: unknown)` / `as never` shape MUST be gone. expect(hook.content).not.toMatch(/idOrPayload: unknown/) expect(hook.content).not.toMatch(/as never/) }) it('row-scope without payload keeps mutateAsync(id: string)', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [{ name: 'Budget', section: 'budgets', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [{ code: 'archive', scope: 'row', payloadType: null, responseType: 'void' }], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/const mutateAsync = async \(id: string\)/) expect(hook.content).toMatch(/budgetService\.archive\(id\)/) }) it('bulk-scope with payload carries ids + payload on the wire (mutateAsync({ ids, payload }))', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [{ name: 'Budget', section: 'budgets', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [{ code: 'bulk-archive', scope: 'bulk', payloadType: 'BulkArchiveRequest', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/const mutateAsync = async \(\{ ids, payload \}: \{ ids: string\[\]; payload: BulkArchiveRequest \}\)/) expect(hook.content).toMatch(/budgetService\.bulkArchive\(ids, payload\)/) // The service member merges the selection ids INTO the posted payload (was dropped). const service = files.find(f => f.path.endsWith('budgetService.ts'))! expect(service.content).toMatch(/bulkArchive: async \(ids: string\[\], payload: BulkArchiveRequest\)/) expect(service.content).toMatch(/\/bulk\/bulk-archive`, \{ \.\.\.payload, ids \}/) }) it('header-scope without payload emits mutateAsync()', () => { const files = generate({ module: 'budgets', appCode: 'TestV2', entities: [{ name: 'Budget', section: 'budgets', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [{ code: 'export', scope: 'header', payloadType: null, responseType: 'void' }], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useBudget.ts'))! expect(hook.content).toMatch(/const mutateAsync = async \(\): Promise/) expect(hook.content).toMatch(/budgetService\.export\(\)/) }) it('header-scope + payload + parent threads parentId into service call', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'Rue', section: 'rues', parentPath: '/api/gaf/referentiels/sites/{parentId}/rues', parentIdParam: 'siteId', hasDashboard: false, fields: [{ name: 'NomRue', type: 'string', required: true }], customActions: [{ code: 'sync', scope: 'header', payloadType: 'SyncRequest', responseType: 'void', }], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useRue.ts'))! // Hook signature carries siteId; mutateAsync takes only the payload; call // threads both: service.sync(siteId, payload). expect(hook.content).toMatch(/export function useSyncRue\(siteId: string\)/) expect(hook.content).toMatch(/const mutateAsync = async \(payload: SyncRequest\)/) expect(hook.content).toMatch(/rueService\.sync\(siteId, payload\)/) }) }) describe('scaffold-api-client / generate — Bug 8 (DetailDto = union(entity.fields, screenColumns.detail))', () => { // Reproducer: AFFAIRES/Demande pagespec declared screenColumns.detail without // localityData / addressData / parcelData (JSON-snapshot nvarchar(max) fields // present in entity.fields). The edit form fetched DemandeDetailDto then tried // to pre-fill these fields — TypeScript reported "property does not exist on // type DemandeDetailDto" because the DTO had no clue about them. it('DetailDto includes entity.fields not present in screenColumns.detail', () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', section: 'demandes', hasDashboard: false, fields: [ { name: 'Numero', type: 'string', required: true }, { name: 'LocalityData', type: 'string', required: false }, // JSON snapshot, missing from detail { name: 'AddressData', type: 'string', required: false }, { name: 'ParcelData', type: 'string', required: false }, ], screenColumns: { list: [{ key: 'numero', formatHint: 'string' }], // Only Numero in detail — the 3 JSON fields are intentionally absent. detail: [{ key: 'numero', formatHint: 'string' }], }, }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.endsWith('types/index.ts'))! // DetailDto contains every entity field, regardless of whether the BA listed it. expect(types.content).toMatch(/export interface DemandeDetailDto \{[\s\S]*?numero:[\s\S]*?localityData\?: string;[\s\S]*?addressData\?: string;[\s\S]*?parcelData\?: string;/) }) it('DetailDto preserves screenColumns.detail order before entity.fields extras', () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', section: 'demandes', hasDashboard: false, fields: [ { name: 'Numero', type: 'string', required: true }, { name: 'Snapshot', type: 'string', required: false }, ], screenColumns: { detail: [ { key: 'numero', formatHint: 'string' }, { key: 'displayedStatus', formatHint: 'string' }, // computed display field ], }, }], projectPath: '/test', routeMode: 'integration', }) const types = files.find(f => f.path.endsWith('types/index.ts'))! // displayedStatus (column-only) appears BEFORE snapshot (entity-only). const detailMatch = types.content.match(/export interface DemandeDetailDto \{([\s\S]*?)\n\}/) expect(detailMatch).toBeTruthy() const body = detailMatch![1] const numeroIdx = body.indexOf('numero:') const displayedIdx = body.indexOf('displayedStatus:') const snapshotIdx = body.indexOf('snapshot?:') expect(numeroIdx).toBeGreaterThan(-1) expect(displayedIdx).toBeGreaterThan(numeroIdx) expect(snapshotIdx).toBeGreaterThan(displayedIdx) }) it('legacy fallback (no screenColumns) keeps entity.fields shape byte-for-byte', () => { const files = generate(fixture()) const types = files.find(f => f.path.endsWith('types/index.ts'))! // Contact fixture has firstName + lastName in entity.fields, no screenColumns. expect(types.content).toMatch(/export interface ContactDetailDto \{[\s\S]*?firstName: string;[\s\S]*?lastName: string;/) }) }) describe('scaffold-api-client / generate — Bug 5 (lookup hook contract is locked)', () => { it('always exports use{E}Lookup at hooks/use{E}.ts', () => { const files = generate(fixture()) const hook = files.find(f => f.path === 'src/features/testv2/crm/contact/hooks/useContact.ts') expect(hook).toBeDefined() expect(hook!.content).toMatch(/export function useContactLookup/) }) it('lookup hook is emitted in every route mode (integration, screens)', () => { for (const mode of ['integration'] as const) { const f = generate(fixture({ routeMode: mode })).find(x => x.path.endsWith('useContact.ts'))! expect(f.content, `routeMode=${mode}`).toMatch(/export function useContactLookup/) } const screens = generate(fixture({ useScreens: true })).find(x => x.path.endsWith('useContact.ts'))! expect(screens.content, 'useScreens=true').toMatch(/export function useContactLookup/) }) it('lookup hook is emitted even when the entity has a parentPath (sub-resource)', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'Rue', section: 'rues', parentPath: '/api/gaf/referentiels/sites/{parentId}/rues', parentIdParam: 'siteId', hasDashboard: false, fields: [{ name: 'NomRue', type: 'string', required: true }], }], projectPath: '/test', routeMode: 'integration', }) const hook = files.find(f => f.path.endsWith('useRue.ts'))! expect(hook.content).toMatch(/export function useRueLookup\(siteId: string,/) }) }) describe('scaffold-api-client / generate — non-primitive field types fold to string', () => { // mapTsType used to THROW on any type outside its 10 primitives — one enum // field killed the whole entity's client (two of twelve entities in the // client run). 'string' is the correct WIRE type: SmartStack.Api registers // JsonStringEnumConverter, so enums travel as their string name. it('an enum-typed field generates the full client with the field typed string', () => { const files = generate(fixture({ entities: [{ name: 'AlertRule', section: 'alertes', hasDashboard: false, fields: [ { name: 'label', type: 'string', required: true }, { name: 'recipientMode', type: 'RecipientMode', required: true }, { name: 'severity', type: 'enum', required: false }, ], }], })) const types = files.find((f) => f.path.endsWith('types/index.ts'))!.content expect(types).toContain('recipientMode: string;') expect(types).toContain('severity?: string;') expect(files.some((f) => f.path.includes('useAlertRule'))).toBe(true) }) it('updatedAt is nullable on the DetailDto (BaseEntity.UpdatedAt is DateTime?)', () => { const files = generate(fixture()) const types = files.find((f) => f.path.endsWith('types/index.ts'))!.content expect(types).toContain('updatedAt: string | null;') }) }) describe('scaffold-api-client / generate — GET queryShape (client defect 2026-08-25 #4)', () => { // A GET action's collected parameters ride the QUERY STRING (`{ params }`), // mirroring the controller's [FromQuery] binding — a GET has no body. function entityWithGetAction() { return { name: 'VatRate', section: 'tva', hasDashboard: false, fields: [{ name: 'code', type: 'string', required: true }], customActions: [{ code: 'impact', scope: 'row' as const, httpMethod: 'get' as const, payloadType: null, responseType: 'VatImpactDto', responseShape: { total: 'number' }, queryShape: { year: 'number' }, }], } } it('row-scope GET service member sends `{ params }`, never a body', () => { const files = generate({ module: 'tva', appCode: 'TestV2', entities: [entityWithGetAction()], projectPath: '/web', }) const service = files.find((f) => f.path.endsWith('vatRateService.ts'))! expect(service.content).toMatch(/impact: async \(id: string, params\?: \{ year\?: number \}\): Promise/) expect(service.content).toContain('await api.get(`${API_PATH}/${id}/impact`, { params });') }) it('the hook keeps the `{ id, payload }` variables shape so the page dialog wiring is verb-agnostic', () => { const files = generate({ module: 'tva', appCode: 'TestV2', entities: [entityWithGetAction()], projectPath: '/web', }) const hook = files.find((f) => f.path.endsWith('useVatRate.ts'))! expect(hook.content).toMatch(/export function useImpactVatRate\(\)/) expect(hook.content).toContain('({ id, payload }: { id: string; payload: { year?: number } })') expect(hook.content).toContain('vatRateService.impact(id, payload)') }) }) describe('scaffold-api-client / supplied-on-create — Create DTO code member', () => { it('Create{E}Dto gains a terminal `code?: string` when codedEntity.supplied is set', () => { const files = generate(fixture({ entities: [{ ...fixture().entities[0], codedEntity: { supplied: true, codeKey: 'crm.contact' } as never }], })) const types = files.find((f) => f.path.endsWith('types/index.ts'))! expect(types.content).toMatch(/interface CreateContactDto \{[\s\S]*?code\?: string;\n\}/) expect(types.content).not.toMatch(/interface UpdateContactDto \{[\s\S]*?code\?/) }) it('OPT-IN STRICT: boolean flag / absent stay identical (no code member)', () => { const base = generate(fixture()) const withBool = generate(fixture({ entities: [{ ...fixture().entities[0], codedEntity: true as never }], })) expect(withBool.map((f) => f.content)).toEqual(base.map((f) => f.content)) }) })