/** * CROSS-SUITE CONTRACT — the exact shape the client run broke on: ONE entity * with an optional string, driven through scaffold-entity + scaffold-business * + scaffold-controller with the SAME field list. Each suite guards its own * emission; only a cross-check catches the two halves disagreeing (the * controller mapping every field into a Create command that only carries the * required ones — CS1061/CS7036 on 11 of 12 entities of the client module). */ import { describe, it, expect } from 'vitest' import { generate as generateEntity } from '../../../../data-layer/cli/scaffold-entity/generate.js' import { generate as generateBusiness } from '../../../../business-layer/cli/scaffold-business/generate.js' import { generate as generateController } from '../generate.js' const FIELDS = [ { name: 'Code', type: 'string', required: true }, { name: 'Label', type: 'string', required: true }, { name: 'DeactivationReason', type: 'string', required: false }, ] const COMMON = { module: 'parametrage', appCode: 'Demo', applicationCode: 'flotte', namespace: 'Demo' } /** Count the parameters of `public record {Name}(...)` in a C# source. * String-scan, no regex — the params carry no nested parens. */ function recordArity(src: string, name: string): number { const marker = `public record ${name}(` const idx = src.indexOf(marker) expect(idx, `record ${name} not found`).toBeGreaterThanOrEqual(0) const inner = src.slice(idx + marker.length, src.indexOf(')', idx + marker.length)).trim() return inner.length === 0 ? 0 : inner.split(',').length } /** Count the arguments of `new {Name}(...)` in a C# source. */ function callArity(src: string, name: string): number { const marker = `new ${name}(` const idx = src.indexOf(marker) expect(idx, `call site new ${name}(...) not found`).toBeGreaterThanOrEqual(0) const inner = src.slice(idx + marker.length, src.indexOf(')', idx + marker.length)).trim() return inner.length === 0 ? 0 : inner.split(',').length } describe('optional-field contract — scaffold-entity ⊆ scaffold-business ⊆ scaffold-controller', () => { const entityFiles = generateEntity({ name: 'VehicleType', pluralName: 'VehicleTypes', domainPrefix: 'parametrage', tenantMode: 'strict', schemaTarget: 'extensions', fields: FIELDS.map((f) => ({ ...f, isKey: false, indexed: false, unique: false })), relations: [], constraints: [], projectPath: '/proj', ...COMMON, }) const businessFiles = generateBusiness({ name: 'VehicleType', pluralName: 'VehicleTypes', fields: FIELDS.map((f) => ({ ...f, isKey: false })), businessRules: [], customActions: [], projectPath: '/proj', ...COMMON, }) const controllerFiles = generateController({ name: 'VehicleType', pluralName: 'VehicleTypes', section: 'referentiels', navRoute: 'parametrage.referentiels', actions: ['read', 'create', 'update', 'delete'], customActions: [], fields: FIELDS, projectPath: '/proj', ...COMMON, }) const fileOf = (files: Array<{ path: string; content: string }>, suffix: string) => files.find((f) => f.path.endsWith(suffix))!.content it('the optional string is honest end-to-end: string? on the entity, Create AND Update surfaces (Create honnête)', () => { const entity = fileOf(entityFiles, 'VehicleType.cs') expect(entity).toContain('public string? DeactivationReason { get; private set; }') // The factory takes it as a DEFAULTED parameter — positional call sites untouched. expect(entity).toContain('string? deactivationReason = null') const dtos = fileOf(businessFiles, 'DTOs/VehicleTypeDtos.cs') expect(dtos).toContain('string? DeactivationReason') // A value typed on the create form used to be silently dropped by binding // against the required-only DTO — it now rides Create as a nullable member. const create = fileOf(businessFiles, 'Commands/CreateVehicleTypeCommand.cs') expect(create).toContain('string? DeactivationReason') }) it('controller Create/Update call arities MATCH the business command records', () => { const ctrl = fileOf(controllerFiles, 'VehicleTypesController.cs') const create = fileOf(businessFiles, 'Commands/CreateVehicleTypeCommand.cs') const update = fileOf(businessFiles, 'Commands/UpdateVehicleTypeCommand.cs') expect(callArity(ctrl, 'CreateVehicleTypeCommand')).toBe(recordArity(create, 'CreateVehicleTypeCommand')) expect(callArity(ctrl, 'UpdateVehicleTypeCommand')).toBe(recordArity(update, 'UpdateVehicleTypeCommand')) }) }) describe('lifecycle-phased field contract — excluded from the WHOLE Create surface, kept on Update', () => { const PHASED_FIELDS = [ { name: 'Code', type: 'string', required: true }, { name: 'Label', type: 'string', required: true }, { name: 'PaymentDate', type: 'datetime', required: false, phase: 'paiement' }, ] const entityFiles = generateEntity({ name: 'Invoice', pluralName: 'Invoices', domainPrefix: 'billing', tenantMode: 'strict', schemaTarget: 'extensions', fields: PHASED_FIELDS.map((f) => ({ ...f, isKey: false, indexed: false, unique: false })), relations: [], constraints: [], projectPath: '/proj', ...COMMON, }) const businessFiles = generateBusiness({ name: 'Invoice', pluralName: 'Invoices', fields: PHASED_FIELDS.map((f) => ({ ...f, isKey: false })), businessRules: [], customActions: [], projectPath: '/proj', ...COMMON, }) const controllerFiles = generateController({ name: 'Invoice', pluralName: 'Invoices', section: 'invoices', navRoute: 'billing.invoices', actions: ['read', 'create', 'update', 'delete'], customActions: [], fields: PHASED_FIELDS, projectPath: '/proj', ...COMMON, }) const fileOf = (files: Array<{ path: string; content: string }>, suffix: string) => files.find((f) => f.path.endsWith(suffix))!.content it('the phased field is nullable on the entity but NEVER a factory parameter', () => { const entity = fileOf(entityFiles, 'Invoice.cs') expect(entity).toContain('public DateTime? PaymentDate { get; private set; }') expect(entity).not.toContain('paymentDate = null') expect(entity).not.toContain('PaymentDate = paymentDate,') }) it('absent from Create, present on Update, arities still match across the trio', () => { const create = fileOf(businessFiles, 'Commands/CreateInvoiceCommand.cs') const update = fileOf(businessFiles, 'Commands/UpdateInvoiceCommand.cs') const ctrl = fileOf(controllerFiles, 'InvoicesController.cs') expect(create).not.toContain('PaymentDate') expect(update).toContain('PaymentDate') expect(callArity(ctrl, 'CreateInvoiceCommand')).toBe(recordArity(create, 'CreateInvoiceCommand')) expect(callArity(ctrl, 'UpdateInvoiceCommand')).toBe(recordArity(update, 'UpdateInvoiceCommand')) }) })