import { describe, it, expect } from 'vitest' import { generate, legacyPaths } from '../generate.js' import { validateCodedEntity } from '../validate.js' import type { ScaffoldBusinessInput, BusinessCustomAction } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldBusinessInput { return { name: 'Budget', pluralName: 'Budgets', module: 'budgets', appCode: 'TestV2', applicationCode: 'crm', namespace: 'TestV2', fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'Label', type: 'string', required: true, isKey: false }, { name: 'InitialAmount', type: 'decimal', required: true, isKey: false }, ], businessRules: [], customActions: [], projectPath: '/tmp/project', ...overrides, } } describe('scaffold-business / generate — standard CRUD', () => { it('emits Commands, Handlers, Service interface and impl for CRUD', () => { const files = generate(fixture()) const paths = files.map((f) => f.path) expect(paths).toEqual(expect.arrayContaining([ expect.stringContaining('Commands/CreateBudgetCommand.cs'), expect.stringContaining('Commands/UpdateBudgetCommand.cs'), expect.stringContaining('Commands/DeleteBudgetCommand.cs'), expect.stringContaining('Handlers/CreateBudgetCommandHandler.cs'), expect.stringContaining('Interfaces/IBudgetService.cs'), expect.stringContaining('Services/Crm/Budgets/BudgetService.cs'), ])) }) it('service injects IExtensionsDbContext (not ICoreDbContext) — extension entities live in the extensions schema', () => { const files = generate(fixture()) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/private readonly IExtensionsDbContext _context;/) // Default tenantMode 'strict' (mirror of scaffold-entity): the service ALSO // injects ICurrentTenantService and resolves tenantId server-side. expect(svc.content).toMatch(/public BudgetService\(IExtensionsDbContext context, ICurrentTenantService tenantService\)/) expect(svc.content).toContain('var tenantId = _tenantService.TenantId') expect(svc.content).toContain('Budget.Create(command.Code, command.Label, command.InitialAmount, tenantId);') expect(svc.content).not.toMatch(/ICoreDbContext/) // Set() still drives the queries (exposed on IExtensionsDbContext). expect(svc.content).toMatch(/_context\.Set\(\)/) // Service namespace gains . (A1 classification); imports the // FLAT Domain namespace (entities stay flat — Domain exception). expect(svc.content).toContain('namespace TestV2.Infrastructure.Services.Crm.Budgets;') expect(svc.content).toContain('using TestV2.Domain.Entities;') }) it('GetAllAsync paginates + searches + sorts SERVER-side (multi-field OR + whitelisted sort)', () => { const svc = generate(fixture()).find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! // Search: OR of LIKE across every stored STRING column (Code, Label) — never the // decimal (InitialAmount), and never the old single-field-only search. expect(svc.content).toMatch(/EF\.Functions\.Like\(x\.Code, \$"%\{query\.Search\}%"\) \|\| EF\.Functions\.Like\(x\.Label, \$"%\{query\.Search\}%"\)/) expect(svc.content).not.toMatch(/EF\.Functions\.Like\(x\.InitialAmount/) // Server-side total + paging. expect(svc.content).toMatch(/var total = await q\.CountAsync\(ct\)/) expect(svc.content).toMatch(/\.Skip\(\(query\.Page - 1\) \* query\.PageSize\)\.Take\(query\.PageSize\)/) // Whitelisted sort switch (anti-injection) with a CreatedAt-DESC default. expect(svc.content).toMatch(/var asc = \(query\.SortDir \?\? ""\)\.ToLowerInvariant\(\) == "asc"/) expect(svc.content).toMatch(/"code" => asc \? q\.OrderBy\(x => x\.Code\) : q\.OrderByDescending\(x => x\.Code\)/) expect(svc.content).toMatch(/_ => q\.OrderByDescending\(x => x\.CreatedAt\)/) }) it('query records carry the page/size/search/sort contract on BOTH strata', () => { const q = generate(fixture()).find((f) => f.path.endsWith('Queries/GetBudgetQuery.cs'))! // Integration list query gains SortBy/SortDir. expect(q.content).toMatch(/public record GetBudgetsQuery\(int Page = 1, int PageSize = 20, string\? Search = null, string\? SortBy = null, string\? SortDir = null\)/) // Screen list query is now scaffolder-owned (was hand-written by ba-develop). expect(q.content).toMatch(/public record GetBudgetListScreenQuery\(int Page = 1, int PageSize = 20, string\? Search = null, string\? SortBy = null, string\? SortDir = null\)/) }) it('appends the pagespec screen filters to BOTH list queries (typed, PascalCase, after FKs)', () => { const files = generate(fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'ClientId', type: 'Guid', required: true, isKey: false }, { name: 'Overdue', type: 'bool', required: false, isKey: false }, { name: 'InvoiceDate', type: 'date', required: true, isKey: false }, ], screenFilters: [ { field: 'search', control: 'text' }, // fused with Search { field: 'clientId', control: 'lookup' }, // rides the Guid FK channel { field: 'settlementStatus', control: 'select' }, { field: 'overdue', control: 'boolean' }, { field: 'invoiceDate', control: 'date-range' }, ], })) const q = files.find((f) => f.path.endsWith('Queries/GetBudgetQuery.cs'))! const filterMembers = 'string\\? SettlementStatus = null, bool\\? Overdue = null, DateTime\\? InvoiceDateFrom = null, DateTime\\? InvoiceDateTo = null' expect(q.content).toMatch(new RegExp(`public record GetBudgetListScreenQuery\\(int Page = 1, int PageSize = 20, string\\? Search = null, string\\? SortBy = null, string\\? SortDir = null, Guid\\? ClientId = null, ${filterMembers}\\)`)) // The integration query carries the SAME filter members — GetAllAsync // applies them, so a filtered integration list is server-filtered too. expect(q.content).toMatch(new RegExp(`public record GetBudgetsQuery\\(int Page = 1, int PageSize = 20, string\\? Search = null, string\\? SortBy = null, string\\? SortDir = null, Guid\\? ClientId = null, ${filterMembers}\\)`)) // Generated predicates in GetAllAsync — one guarded Where per param, // BEFORE CountAsync, typed per control. const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/if \(query\.Overdue is not null\)\s*\{\s*q = q\.Where\(x => x\.Overdue == query\.Overdue\.Value\);/) expect(svc.content).toMatch(/if \(query\.InvoiceDateFrom is not null\)\s*\{\s*q = q\.Where\(x => x\.InvoiceDate >= DateOnly\.FromDateTime\(query\.InvoiceDateFrom\.Value\)\);/) expect(svc.content).toMatch(/if \(query\.InvoiceDateTo is not null\)\s*\{\s*q = q\.Where\(x => x\.InvoiceDate <= DateOnly\.FromDateTime\(query\.InvoiceDateTo\.Value\)\);/) // settlementStatus resolves no entity field → wire param kept, TODO instead // of a silent dead predicate. expect(svc.content).toMatch(/TODO\[filter:settlementStatus\]/) expect(svc.content.indexOf('query.Overdue is not null')) .toBeLessThan(svc.content.indexOf('var total = await q.CountAsync')) }) it('a screen filter on a FORMULA column filters on its inline projected expression', () => { const files = generate(fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'Libelle', type: 'string', required: true, isKey: false }, { name: 'DisplayLabel', type: 'string', required: false, isKey: false, formula: 'Code + " — " + Libelle' }, ], screenFilters: [{ field: 'displayLabel', control: 'text' }], })) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/EF\.Functions\.Like\(\(x\.Code \+ " — " \+ x\.Libelle\), \$"%\{query\.DisplayLabel\}%"\)/) }) it('exposes every Guid FK as an optional relation filter on BOTH list queries + applies the Where', () => { const files = generate(fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'ClientId', type: 'Guid', required: true, isKey: false }, { name: 'ContactId', type: 'Guid', required: false, isKey: false }, ], })) const q = files.find((f) => f.path.endsWith('Queries/GetBudgetQuery.cs'))! // Both strata: the FK params append AFTER the sort contract, in field order. expect(q.content).toMatch(/public record GetBudgetsQuery\(int Page = 1, int PageSize = 20, string\? Search = null, string\? SortBy = null, string\? SortDir = null, Guid\? ClientId = null, Guid\? ContactId = null\)/) expect(q.content).toMatch(/public record GetBudgetListScreenQuery\(int Page = 1, int PageSize = 20, string\? Search = null, string\? SortBy = null, string\? SortDir = null, Guid\? ClientId = null, Guid\? ContactId = null\)/) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! // Explicit Where per FK — no dynamic property lookup. expect(svc.content).toMatch(/if \(query\.ClientId is not null\)\s*\{\s*q = q\.Where\(x => x\.ClientId == query\.ClientId\);/) expect(svc.content).toMatch(/if \(query\.ContactId is not null\)/) // The FK Where lands BEFORE CountAsync so the total reflects the filter. expect(svc.content.indexOf('query.ClientId is not null')) .toBeLessThan(svc.content.indexOf('var total = await q.CountAsync')) // The Guid FK is never searchable text. expect(svc.content).not.toMatch(/EF\.Functions\.Like\(x\.ClientId/) }) it('emits BOTH a Create and an Update CommandValidator', () => { const files = generate(fixture()) const paths = files.map((f) => f.path) expect(paths).toEqual(expect.arrayContaining([ expect.stringContaining('Validators/CreateBudgetCommandValidator.cs'), expect.stringContaining('Validators/UpdateBudgetCommandValidator.cs'), ])) const update = files.find((f) => f.path.endsWith('Validators/UpdateBudgetCommandValidator.cs'))! // Update validator guards the primary key and the required string fields. expect(update.content).toMatch(/RuleFor\(x => x\.Id\)\.NotEmpty\(\)/) expect(update.content).toMatch(/RuleFor\(x => x\.Label\)\.NotEmpty\(\)/) }) it('a `numbering` rule is NEVER spliced nor TODO-ised — doctrine comment only (the socle allocates)', () => { const files = generate(fixture({ businessRules: [ { id: 'BR-007', description: 'Numéro de demande AFF-{YY}-{SEQ:4}', ruleType: 'numbering', severity: 'err', expression: 'format = "AFF-{YY}-{SEQ:4}" ; scope = tenant ; reset = yearly ; gapless = true', validExamples: [], invalidExamples: [], }, ], })) for (const name of ['Create', 'Update']) { const v = files.find((f) => f.path.endsWith(`Validators/${name}BudgetCommandValidator.cs`))!.content // The BR trace survives (DEV-API-008), the spec does not become code. expect(v).toContain('// BR-007') expect(v).toContain('CodedEntitySaveHandler') expect(v).toContain('DO NOT implement') expect(v).not.toContain('format = "AFF-') expect(v).not.toContain('TODO') } }) it('the DECLARATIVE-form net catches a ruleType-less numbering spec too (format = "…{TOKEN" shape)', () => { const files = generate(fixture({ businessRules: [ { id: 'BR-003', description: 'Numéro de facture', expression: 'format = "FAC-{YYYY}-{SEQ:5}" ; scope = tenant ; reset = yearly', validExamples: [], invalidExamples: [], }, ], })) const v = files.find((f) => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content expect(v).not.toContain('FAC-{YYYY}') expect(v).toContain('DO NOT implement') }) it('the pseudo-code net catches a numbering spec that LOST its ruleType (nextSeq splice would not compile)', () => { const files = generate(fixture({ businessRules: [ { id: 'BR-002', description: 'Numéro d\'opportunité', expression: 'reference = "OPP-" + fmt(year,"yy") + "-" + pad(nextSeq(scope=tenant, reset=yearly), 4)', validExamples: [], invalidExamples: [], }, ], })) const v = files.find((f) => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content expect(v).not.toContain('nextSeq') expect(v).toContain('DO NOT implement') }) it('applies each business rule to BOTH validators (invariant holds on update)', () => { const files = generate(fixture({ businessRules: [ { id: 'BR-001', description: 'Initial amount must be positive', expression: 'RuleFor(x => x.InitialAmount).GreaterThan(0)', validExamples: [], invalidExamples: [], }, ], })) const create = files.find((f) => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))! const update = files.find((f) => f.path.endsWith('Validators/UpdateBudgetCommandValidator.cs'))! expect(create.content).toMatch(/RuleFor\(x => x\.InitialAmount\)\.GreaterThan\(0\)/) expect(update.content).toMatch(/RuleFor\(x => x\.InitialAmount\)\.GreaterThan\(0\)/) }) }) describe('scaffold-business / validate — codedEntity guard (backend third layer)', () => { it('rejects a "code" entry in fields[] when codedEntity is set — the Code never rides Create/Update', () => { const r = validateCodedEntity({ ...fixture(), codedEntity: true }) expect(r.valid).toBe(false) expect(r.errors[0]).toContain('engine-allocated') }) it('without the flag (or without a code field) nothing changes', () => { expect(validateCodedEntity({ ...fixture(), codedEntity: false }).valid).toBe(true) expect(validateCodedEntity({ ...fixture({ fields: [{ name: 'Label', type: 'string', required: true, isKey: false }] }), codedEntity: true, }).valid).toBe(true) }) it('the ENRICHED object flag arms the guard too (isCodedEntity semantics, never === true)', () => { const r = validateCodedEntity({ ...fixture(), codedEntity: { label: { fr: 'Référence' } } as never }) expect(r.valid).toBe(false) expect(r.errors[0]).toContain('engine-allocated') }) }) describe('scaffold-business / generate — custom actions (per-page mode)', () => { function archive(): BusinessCustomAction { return { code: 'archive', scope: 'row', payloadDto: null, responseDto: 'NoContent', } } it('emits a Command record per custom action with row-scope param shape', () => { const files = generate(fixture({ customActions: [archive()] })) const cmd = files.find((f) => f.path.endsWith('ArchiveBudgetCommand.cs'))! expect(cmd).toBeDefined() expect(cmd.content).toMatch(/public record ArchiveBudgetCommand\(Guid Id\) : IRequest;/) }) it('emits a Handler that calls _service.{Code}Async and returns Unit for NoContent', () => { const files = generate(fixture({ customActions: [archive()] })) const handler = files.find((f) => f.path.endsWith('ArchiveBudgetCommandHandler.cs'))! expect(handler).toBeDefined() expect(handler.content).toMatch(/await _service\.ArchiveAsync\(request\.Id, cancellationToken\)/) expect(handler.content).toMatch(/return Unit\.Value;/) }) it('adds the custom action to the I{Entity}Service interface', () => { const files = generate(fixture({ customActions: [archive()] })) const iface = files.find((f) => f.path.endsWith('IBudgetService.cs'))! expect(iface.content).toMatch(/Task ArchiveAsync\(Guid id, CancellationToken ct = default\);/) }) it('emits a canonical recipe in the Service impl for "archive" code', () => { const files = generate(fixture({ customActions: [archive()] })) const svc = files.find((f) => f.path.includes('Infrastructure/Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/public async Task ArchiveAsync\(Guid id, CancellationToken/) // FirstOrDefaultAsync (never FindAsync): FindAsync bypasses the named // Tenant/DataScope + soft-delete query filters — cross-tenant writes. expect(svc.content).toMatch(/var entity = await _context\.Set\(\)\.FirstOrDefaultAsync\(x => x\.Id == id, ct\)/) expect(svc.content).not.toMatch(/\.FindAsync\(/) // The canonical archive sets Status="archived" expect(svc.content).toMatch(/SetValue\(entity, "archived"\)/) expect(svc.content).toMatch(/await _context\.SaveChangesAsync\(ct\)/) }) it('emits canonical restore / activate / deactivate recipes', () => { const files = generate(fixture({ customActions: [ { code: 'restore', scope: 'row', payloadDto: null, responseDto: 'NoContent' }, { code: 'activate', scope: 'row', payloadDto: null, responseDto: 'NoContent' }, { code: 'deactivate', scope: 'row', payloadDto: null, responseDto: 'NoContent' }, ], })) const svc = files.find((f) => f.path.includes('Infrastructure/Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/SetValue\(entity, "active"\)/) expect(svc.content).toMatch(/SetValue\(entity, true\)/) expect(svc.content).toMatch(/SetValue\(entity, false\)/) }) it('emits a canonical duplicate recipe (clone + new Guid + return clone)', () => { const files = generate(fixture({ customActions: [ { code: 'duplicate', scope: 'row', payloadDto: null, responseDto: 'BudgetDto' }, ], })) const svc = files.find((f) => f.path.includes('Infrastructure/Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/public async Task DuplicateAsync\(Guid id/) expect(svc.content).toMatch(/var clone = \(Budget\)source\.GetType\(\)\.GetMethod\("Clone"\)/) expect(svc.content).toMatch(/SetValue\(clone, Guid\.NewGuid\(\)\)/) expect(svc.content).toMatch(/_context\.Set\(\)\.Add\(clone\)/) }) it('emits NotImplementedException stub for unknown codes with UC reference', () => { const files = generate(fixture({ customActions: [ { code: 'approve', scope: 'row', payloadDto: null, responseDto: 'NoContent', ucReference: 'UC-BUD-014' }, ], })) const svc = files.find((f) => f.path.includes('Infrastructure/Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/public async Task ApproveAsync\(Guid id/) expect(svc.content).toMatch(/TODO\[UC-BUD-014\]: implement 'approve' per the linked use case\./) expect(svc.content).toMatch(/throw new NotImplementedException\(\);/) }) it('handles bulk-scope custom action with IReadOnlyList ids parameter', () => { const files = generate(fixture({ customActions: [ { code: 'archive', scope: 'bulk', payloadDto: null, responseDto: 'NoContent' }, ], })) const cmd = files.find((f) => f.path.endsWith('ArchiveBudgetCommand.cs'))! expect(cmd.content).toMatch(/public record ArchiveBudgetCommand\(IReadOnlyList Ids\)/) const iface = files.find((f) => f.path.endsWith('IBudgetService.cs'))! expect(iface.content).toMatch(/Task ArchiveAsync\(IReadOnlyList ids, CancellationToken/) }) it('handles row-scope action with payload DTO', () => { const files = generate(fixture({ customActions: [ { code: 'approve', scope: 'row', payloadDto: 'ApproveRequest', responseDto: 'BudgetDto' }, ], })) const cmd = files.find((f) => f.path.endsWith('ApproveBudgetCommand.cs'))! expect(cmd.content).toMatch(/public record ApproveBudgetCommand\(Guid Id, ApproveRequest Payload\) : IRequest;/) const handler = files.find((f) => f.path.endsWith('ApproveBudgetCommandHandler.cs'))! expect(handler.content).toMatch(/return await _service\.ApproveAsync\(request\.Id, request\.Payload, cancellationToken\)/) }) it('emits NO custom artefacts when customActions is empty (legacy default)', () => { const files = generate(fixture({ customActions: [] })) expect(files.find((f) => f.path.includes('Archive'))).toBeUndefined() expect(files.find((f) => f.path.includes('Duplicate'))).toBeUndefined() }) it('pascalizes kebab-case codes for class and method names', () => { const files = generate(fixture({ customActions: [ { code: 'submit-for-review', scope: 'row', payloadDto: null, responseDto: 'NoContent' }, ], })) const cmd = files.find((f) => f.path.endsWith('SubmitForReviewBudgetCommand.cs'))! expect(cmd).toBeDefined() expect(cmd.content).toMatch(/public record SubmitForReviewBudgetCommand\(Guid Id\)/) const handler = files.find((f) => f.path.endsWith('SubmitForReviewBudgetCommandHandler.cs'))! expect(handler.content).toMatch(/_service\.SubmitForReviewAsync\(request\.Id/) }) }) describe('scaffold-business / generate — lookup endpoint (feeds )', () => { it('emits {Entity}RefDto in the DTOs file', () => { const files = generate(fixture()) const dtos = files.find((f) => f.path.endsWith('DTOs/BudgetDtos.cs'))! expect(dtos.content).toMatch(/public record BudgetRefDto\(Guid Id, string DisplayName\);/) }) it('emits Get{Plural}LookupQuery alongside the standard queries', () => { const files = generate(fixture()) const queries = files.find((f) => f.path.endsWith('Queries/GetBudgetQuery.cs'))! expect(queries.content).toMatch( /public record GetBudgetsLookupQuery\(string\? Search = null, int Page = 1, int PageSize = 20\) : IRequest>;/, ) }) it('emits Get{Plural}LookupQueryHandler that delegates to GetLookupAsync', () => { const files = generate(fixture()) const handler = files.find((f) => f.path.endsWith('Handlers/GetBudgetsLookupQueryHandler.cs'))! expect(handler).toBeDefined() expect(handler.content).toMatch( /class GetBudgetsLookupQueryHandler : IRequestHandler>/, ) expect(handler.content).toMatch(/=> _service\.GetLookupAsync\(request, cancellationToken\);/) }) it('adds GetLookupAsync to the I{Entity}Service interface', () => { const files = generate(fixture()) const iface = files.find((f) => f.path.endsWith('Interfaces/IBudgetService.cs'))! expect(iface.content).toMatch( /Task> GetLookupAsync\(GetBudgetsLookupQuery query, CancellationToken ct = default\);/, ) }) it('implements GetLookupAsync projecting the preferred display field (Label)', () => { // Fixture has Code, Label, InitialAmount — Label wins over Code per the // Name → Label → Code → Title priority order. const files = generate(fixture()) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/public async Task> GetLookupAsync/) expect(svc.content).toMatch(/EF\.Functions\.Like\(x\.Label, \$"%\{query\.Search\}%"\)/) expect(svc.content).toMatch(/\.OrderBy\(x => x\.Label\)/) expect(svc.content).toMatch(/new BudgetRefDto\(x\.Id, x\.Label\)/) }) it('GetLookupAsync resolves a Guid search by Id (so the combobox shows the label, not the Guid)', () => { const files = generate(fixture()) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! // The frontend EntityLookup passes the selected FK Guid as `search` to // resolve its label — a Guid must filter by Id (exact), not LIKE on the name. expect(svc.content).toMatch(/if \(Guid\.TryParse\(query\.Search, out var lookupId\)\)/) expect(svc.content).toMatch(/q = q\.Where\(x => x\.Id == lookupId\);/) // The LIKE branch survives for text search. expect(svc.content).toMatch(/else\s*\{[\s\S]*?EF\.Functions\.Like\(x\.Label, \$"%\{query\.Search\}%"\)/) }) it('honours explicit displayNameExpr when it matches a stored field', () => { const files = generate(fixture({ displayNameExpr: 'Code' })) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/new BudgetRefDto\(x\.Id, x\.Code\)/) }) it('REFUSES to generate when no display field exists (no silent GUID label — §26)', () => { // The historical fallback shipped rows labelled by the first string column // (phone numbers, free comments) or a raw GUID — it always produced // *something*, so nothing ever surfaced. Now: fail-closed. expect(() => generate(fixture({ fields: [ { name: 'Amount', type: 'decimal', required: true, isKey: false }, { name: 'IsActive', type: 'bool', required: true, isKey: false }, ], }))).toThrow(/no display field[\s\S]*\*\*Affichage\*\*/) }) it('displayNameExpr: Id is the CONSCIOUS opt-out (**Affichage** : Id) — GUID label accepted', () => { const files = generate(fixture({ displayNameExpr: 'Id', fields: [ { name: 'Amount', type: 'decimal', required: true, isKey: false }, { name: 'IsActive', type: 'bool', required: true, isKey: false }, ], })) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))! expect(svc.content).toMatch(/new BudgetRefDto\(x\.Id, x\.Id\.ToString\(\)\)/) }) it('a first-string like MobilePhone is NEVER silently promoted (the Driver shape)', () => { // Driver: identity projected from auth_Users, only free-text strings local. expect(() => generate(fixture({ fields: [ { name: 'MobilePhone', type: 'string', required: false, isKey: false }, { name: 'DeactivationReason', type: 'string', required: false, isKey: false }, ], }))).toThrow(/no display field/) }) it('a Person-mandatory entity composes FirstName + LastName from the Core projection (no displayNameExpr)', () => { const files = generate(fixture({ fields: [ { name: 'UserId', type: 'guid', required: true, isKey: false }, { name: 'MobilePhone', type: 'string', required: false, isKey: false }, { name: 'FirstName', type: 'string', required: false, isKey: false, source: { nav: 'User', target: 'User', property: 'FirstName', fkField: 'UserId' } }, { name: 'LastName', type: 'string', required: false, isKey: false, source: { nav: 'User', target: 'User', property: 'LastName', fkField: 'UserId' } }, ], })) const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content // Lookup displayName = full name, never the phone. expect(svc).toContain('new BudgetRefDto(x.Id, (x.User.FirstName + " " + x.User.LastName))') // The LIST search gains the projected leg — "search a driver by his name". expect(svc).toContain('EF.Functions.Like((x.User.FirstName + " " + x.User.LastName), $"%{query.Search}%")') }) }) describe('scaffold-business / generate — dataScope (server-assigned owner, anti-spoof)', () => { const ownScope = { mode: 'own' as const, ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' } it('Create DTO / Command / Validator never expose the owner column', () => { const files = generate(fixture({ dataScope: ownScope })) const dtos = files.find(f => f.path.endsWith('DTOs/BudgetDtos.cs'))!.content const cmd = files.find(f => f.path.endsWith('Commands/CreateBudgetCommand.cs'))!.content const val = files.find(f => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content expect(dtos).toMatch(/public record CreateBudgetDto\((?:(?!OwnerUserId)[\s\S])*?\);/) expect(cmd).not.toContain('OwnerUserId') expect(val).not.toContain('OwnerUserId') }) it('CreateAsync resolves the owner from ICurrentUserAccessor and passes it to the factory (never command input)', () => { const svc = generate(fixture({ dataScope: ownScope })).find(f => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content expect(svc).toContain('using SmartStack.Application.Common.Interfaces.Identity;') expect(svc).toContain('private readonly ICurrentUserAccessor _currentUser;') expect(svc).toContain('var ownerUserId = _currentUser.HasRequestContext') // Owner slot sits after the field args, before the trailing tenantId — the // exact positional contract of the scaffold-entity factory. expect(svc).toContain('Budget.Create(command.Code, command.Label, command.InitialAmount, ownerUserId, tenantId);') expect(svc).not.toContain('command.OwnerUserId') }) it('Update never carries the owner (ownership is immutable), assigned stays updatable', () => { const files = generate(fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'AssignedToUserId', type: 'guid', required: false, isKey: false }, ], dataScope: { mode: 'own-assigned', ownerProperty: 'OwnerUserId', assignedProperty: 'AssignedToUserId' }, })) const updateCmd = files.find(f => f.path.endsWith('Commands/UpdateBudgetCommand.cs'))!.content const svc = files.find(f => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content expect(updateCmd).not.toContain('OwnerUserId') expect(updateCmd).toContain('AssignedToUserId') expect(svc).toMatch(/entity\.Update\((?:(?!OwnerUserId)[^)])*\)/) }) it('a spoofed OwnerUserId forwarded in fields[] is substituted at its slot (never read from the command)', () => { const files = generate(fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'OwnerUserId', type: 'guid', required: true, isKey: false }, { name: 'InitialAmount', type: 'decimal', required: true, isKey: false }, ], dataScope: ownScope, })) const svc = files.find(f => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content // Positional slot preserved (middle of the arg list), value = server variable. expect(svc).toContain('Budget.Create(command.Code, ownerUserId, command.InitialAmount, tenantId);') const cmd = files.find(f => f.path.endsWith('Commands/CreateBudgetCommand.cs'))!.content expect(cmd).not.toContain('OwnerUserId') }) it('tenantMode none + no dataScope keeps the single-dependency service (regression guard)', () => { const svc = generate(fixture({ tenantMode: 'none' })).find(f => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content expect(svc).toContain('public BudgetService(IExtensionsDbContext context) => _context = context;') expect(svc).toContain('Budget.Create(command.Code, command.Label, command.InitialAmount);') expect(svc).not.toContain('ICurrentTenantService') expect(svc).not.toContain('ICurrentUserAccessor') }) }) describe('scaffold-business / generate — versioned (rowversion optimistic concurrency, offline-write 409)', () => { const svcOf = (files: ReturnType) => files.find(f => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content const dtosOf = (files: ReturnType) => files.find(f => f.path.endsWith('DTOs/BudgetDtos.cs'))!.content it('DetailDto gains a trailing byte[]? RowVersion and GetByIdAsync projects it', () => { const files = generate(fixture({ versioned: true })) expect(dtosOf(files)).toMatch(/public record BudgetDetailDto\([\s\S]*?DateTime\? UpdatedAt,[\s\S]*?byte\[\]\? RowVersion\n\);/) expect(svcOf(files)).toContain('x.CreatedAt, x.UpdatedAt, x.RowVersion))') }) it('ListDto does NOT carry the token (detail-only contract)', () => { const files = generate(fixture({ versioned: true })) expect(dtosOf(files)).toMatch(/public record BudgetListDto\((?:(?!RowVersion)[\s\S])*?\);/) expect(svcOf(files)).not.toMatch(/BudgetListDto\([^)]*RowVersion/) }) it('Update DTO + Command gain a trailing byte[]? RowVersion = null (echoed by the offline outbox; null online)', () => { const files = generate(fixture({ versioned: true })) expect(dtosOf(files)).toMatch(/public record UpdateBudgetDto\([\s\S]*?byte\[\]\? RowVersion = null\n\);/) const cmd = files.find(f => f.path.endsWith('Commands/UpdateBudgetCommand.cs'))!.content expect(cmd).toMatch(/public record UpdateBudgetCommand\(\n Guid Id,\n[\s\S]*?byte\[\]\? RowVersion = null\) : IRequest;/) }) it('UpdateAsync guards the save with OriginalValue = command.RowVersion (socle MyTime pattern, via DbSet.Entry)', () => { const svc = svcOf(generate(fixture({ versioned: true }))) expect(svc).toContain('if (command.RowVersion is { Length: > 0 })') expect(svc).toContain('_context.Set().Entry(entity).Property(e => e.RowVersion).OriginalValue = command.RowVersion;') }) it('UpdateAsync maps DbUpdateConcurrencyException → ConflictException carrying the CURRENT server state', () => { const svc = svcOf(generate(fixture({ versioned: true }))) expect(svc).toMatch(/try\s*\{\s*await _context\.SaveChangesAsync\(ct\);\s*\}\s*catch \(DbUpdateConcurrencyException\)/) expect(svc).toContain('var current = await GetByIdAsync(command.Id, ct);') expect(svc).toMatch(/throw new ConflictException\(\s*"This Budget was changed on the server since you last loaded it\.", current\);/) }) it('RowVersion never leaks into Create, validators, search, sort or entity.Update (EF-managed)', () => { const files = generate(fixture({ versioned: true })) expect(files.find(f => f.path.endsWith('Commands/CreateBudgetCommand.cs'))!.content).not.toContain('RowVersion') expect(files.find(f => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content).not.toContain('RowVersion') expect(files.find(f => f.path.endsWith('Validators/UpdateBudgetCommandValidator.cs'))!.content).not.toContain('RowVersion') const svc = svcOf(files) expect(svc).toMatch(/entity\.Update\((?:(?!RowVersion)[^)])*\);/) expect(svc).not.toMatch(/EF\.Functions\.Like\(x\.RowVersion/) expect(svc).not.toContain('"rowversion" =>') }) it('non-versioned spec leaves ZERO RowVersion/concurrency trace in any file (regression guard)', () => { for (const file of generate(fixture())) { expect(file.content, file.path).not.toContain('RowVersion') expect(file.content, file.path).not.toContain('DbUpdateConcurrencyException') expect(file.content, file.path).not.toContain('ConflictException') } }) }) describe('scaffold-business / generate — optional strings + honest audit DTO (client-run regressions)', () => { const withOptional = () => fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'Label', type: 'string', required: true, isKey: false }, { name: 'DeactivationReason', type: 'string', required: false, isKey: false }, ], }) it('an optional string is `string?` on Create AND Update (Create honnête), never NotEmpty-validated', () => { const files = generate(withOptional()) const dtos = files.find((f) => f.path.endsWith('DTOs/BudgetDtos.cs'))!.content const update = files.find((f) => f.path.endsWith('Commands/UpdateBudgetCommand.cs'))!.content const create = files.find((f) => f.path.endsWith('Commands/CreateBudgetCommand.cs'))!.content const createValidator = files.find((f) => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content const svc = files.find((f) => f.path.endsWith('Budgets/BudgetService.cs'))!.content expect(dtos).toContain('string? DeactivationReason') expect(update).toContain('string? DeactivationReason') // Create-honnête contract: the optional value typed at create is no longer // silently dropped — it rides the Create surface as a nullable member… expect(create).toContain('string? DeactivationReason') // …reaches the factory as a NAMED optional argument… expect(svc).toContain('deactivationReason: command.DeactivationReason') // …and is never required by the validator. expect(createValidator).not.toContain('x.DeactivationReason') }) it('a lifecycle-phased field is excluded from the whole Create surface (kept on Update)', () => { const files = generate(fixture({ fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'Label', type: 'string', required: true, isKey: false }, { name: 'PaymentDate', type: 'datetime', required: false, isKey: false, phase: 'paiement' }, ], })) const create = files.find((f) => f.path.endsWith('Commands/CreateBudgetCommand.cs'))!.content const update = files.find((f) => f.path.endsWith('Commands/UpdateBudgetCommand.cs'))!.content const svc = files.find((f) => f.path.endsWith('Budgets/BudgetService.cs'))!.content expect(create).not.toContain('PaymentDate') expect(update).toContain('PaymentDate') expect(svc).not.toContain('paymentDate: command.PaymentDate') }) it('DetailDto declares `DateTime? UpdatedAt` — BaseEntity.UpdatedAt IS nullable in the package', () => { // Non-nullable `DateTime UpdatedAt` made `x.UpdatedAt` (DateTime?) CS1503 on // EVERY entity. No coalesce to CreatedAt either: a never-modified row must // not read as modified-at-creation — the DTO tells the truth. const files = generate(fixture()) const dtos = files.find((f) => f.path.endsWith('DTOs/BudgetDtos.cs'))!.content expect(dtos).toContain('DateTime? UpdatedAt') expect(dtos).not.toMatch(/DateTime UpdatedAt/) }) it('a custom action with payloadDto + payloadFields EMITS the payload DTO record', () => { // The Command/Service reference the payload DTO but nothing else in the // chain generates the type — with the field specs mirrored from the // pagespec payloadParameters, the record is materialised here. const files = generate(fixture({ customActions: [{ code: 'deactivate', scope: 'row', payloadDto: 'DeactivateBudgetDto', responseDto: 'NoContent', payloadFields: [ { name: 'reason', type: 'textarea', required: true }, { name: 'suspendedUntil', type: 'date', required: false }, { name: 'targetValueId', type: 'lookup', required: false }, ], }], })) const dto = files.find((f) => f.path.endsWith('DTOs/DeactivateBudgetDto.cs')) expect(dto).toBeDefined() expect(dto!.content).toContain('public record DeactivateBudgetDto(') expect(dto!.content).toContain('string Reason') expect(dto!.content).toContain('DateOnly? SuspendedUntil') expect(dto!.content).toContain('Guid? TargetValueId') // The command still takes the DTO as its payload. const cmd = files.find((f) => f.path.endsWith('Commands/DeactivateBudgetCommand.cs'))!.content expect(cmd).toContain('DeactivateBudgetDto Payload') }) it('a payloadDto WITHOUT payloadFields emits no record (pre-existing authored-DTO path)', () => { const files = generate(fixture({ customActions: [{ code: 'approve', scope: 'row', payloadDto: 'ApproveRequest', responseDto: 'NoContent' }], })) expect(files.find((f) => f.path.endsWith('DTOs/ApproveRequest.cs'))).toBeUndefined() }) }) describe('scaffold-business / generate — shared pluralizer (client defect 2026-08-25 #1/#2)', () => { // The naive `e + 's'` fallback emitted GetDrivingLicenceCategorysQuery while // scaffold-controller (shared pluralize()) consumed GetDrivingLicenceCategoriesQuery // -> CS0246 on every entity whose plural is not a plain +s. it('irregular plural WITHOUT pluralName: records + files + service all use pluralize()', () => { const files = generate(fixture({ name: 'DrivingLicenceCategory', pluralName: undefined })) const paths = files.map((f) => f.path) expect(paths).toEqual(expect.arrayContaining([ expect.stringContaining('Handlers/GetDrivingLicenceCategoriesQueryHandler.cs'), ])) const all = files.map((f) => f.content).join('\n') expect(all).toContain('GetDrivingLicenceCategoriesQuery') expect(all).not.toContain('GetDrivingLicenceCategorysQuery') // File name and record name come from the SAME plural — they can never diverge. const handler = files.find((f) => f.path.endsWith('Handlers/GetDrivingLicenceCategoriesQueryHandler.cs'))! expect(handler.content).toContain('class GetDrivingLicenceCategoriesQueryHandler') expect(handler.content).toContain('IRequestHandler { const files = generate(fixture({ name: 'Rue', pluralName: 'Rues' })) expect(files.map((f) => f.path)).toEqual(expect.arrayContaining([ expect.stringContaining('Handlers/GetRuesQueryHandler.cs'), ])) }) it('legacyPaths re-emits the naive-plural file set so a rename leaves no orphan handler', () => { // Before 2026-08-25 the fallback was `e + 's'`: a previous run wrote // GetDrivingLicenceCategorysQueryHandler.cs. The re-run must DELETE it, or // MediatR rejects the duplicate IRequestHandler registration at boot. const spec = fixture({ name: 'DrivingLicenceCategory', pluralName: undefined }) const legacy = legacyPaths(spec) expect(legacy.some((p) => p.endsWith('Handlers/GetDrivingLicenceCategorysQueryHandler.cs'))).toBe(true) // Never lists a path the current run emits (we don't delete what we rewrite). const current = new Set(generate(spec).map((f) => f.path)) for (const p of legacy) expect(current.has(p)).toBe(false) }) it('regular plural: legacyPaths carries NO naive-name extras (naive === pluralize)', () => { const spec = fixture({ name: 'Budget', pluralName: undefined }) const legacy = legacyPaths(spec) // Only the directory-relocation mapping remains — same cardinality as generate(). expect(legacy.length).toBe(generate(spec).length) }) }) describe('scaffold-business / generate — payload record defaults (client defect 2026-08-25 #3)', () => { it('ALL-OPTIONAL payload record defaults every member to null (controller `dto ?? new()` compiles)', () => { const files = generate(fixture({ customActions: [{ code: 'suspend', scope: 'row', payloadDto: 'SuspendBudgetDto', responseDto: 'NoContent', payloadFields: [ { name: 'reason', type: 'textarea', required: false }, { name: 'suspendedUntil', type: 'date', required: false }, ], }], })) const dto = files.find((f) => f.path.endsWith('DTOs/SuspendBudgetDto.cs'))! expect(dto.content).toContain('string? Reason = null') expect(dto.content).toContain('DateOnly? SuspendedUntil = null') }) it('a payload with >=1 REQUIRED member emits NO defaults (mandatory [FromBody] branch, C# forbids required-after-optional)', () => { const files = generate(fixture({ customActions: [{ code: 'deactivate', scope: 'row', payloadDto: 'DeactivateBudgetDto', responseDto: 'NoContent', payloadFields: [ { name: 'reason', type: 'textarea', required: true }, { name: 'suspendedUntil', type: 'date', required: false }, ], }], })) const dto = files.find((f) => f.path.endsWith('DTOs/DeactivateBudgetDto.cs'))! expect(dto.content).not.toContain('= null') expect(dto.content).toContain('string Reason') expect(dto.content).toContain('DateOnly? SuspendedUntil') }) }) describe('scaffold-business / generate — GET queryParameters (client defect 2026-08-25 #4)', () => { it('a GET compute action receives its parameters as nullable scalars, not a payload DTO', () => { const files = generate(fixture({ customActions: [{ code: 'impact', scope: 'row', payloadDto: null, responseDto: 'VatImpactDto', queryParameters: [{ name: 'year', type: 'number' }], }], })) const iface = files.find((f) => f.path.endsWith('Interfaces/IBudgetService.cs'))!.content // Signature order matches the controller call: id, query scalars, ct. expect(iface).toContain('Task ImpactAsync(Guid id, decimal? year, CancellationToken ct = default)') const cmd = files.find((f) => f.path.endsWith('Commands/ImpactBudgetCommand.cs'))!.content expect(cmd).toContain('Guid Id, decimal? Year') const handler = files.find((f) => f.path.endsWith('Handlers/ImpactBudgetCommandHandler.cs'))!.content expect(handler).toContain('request.Id, request.Year, cancellationToken') const svc = files.find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content expect(svc).toContain('ImpactAsync(Guid id, decimal? year, CancellationToken ct = default)') }) }) describe('scaffold-business / scheduled jobs (derive-job-specs)', () => { const JOB = { ucCode: 'UC-FLOTTE-PARAM-ALERTES-003', jobId: 'flotte-parametrage-notifier-echeances', slug: 'notifier-echeances', methodName: 'RunNotifierEcheancesAsync', cron: '0 3 * * *', emissionEntity: 'AlertEmission', } it('emits the Task Run{X}Async(DateOnly runDate, ct) pair — interface + stub with the TODO[UC] anchor', () => { const files = generate(fixture({ scheduledJobs: [JOB] } as Parameters[0])) const iface = files.find(f => f.path.endsWith('Interfaces/IBudgetService.cs'))!.content expect(iface).toContain('Task RunNotifierEcheancesAsync(DateOnly runDate, CancellationToken ct = default);') const svc = files.find(f => f.path.endsWith('/BudgetService.cs'))!.content expect(svc).toContain('public async Task RunNotifierEcheancesAsync(DateOnly runDate, CancellationToken ct = default)') // runDate is the replay/testability seam. expect(svc).toContain('var on = runDate == default ? DateOnly.FromDateTime(DateTime.UtcNow) : runDate;') // The idempotence contract is spelled against the emission entity, and the // TODO[UC] marker is the business.todo-uc auto-heal anchor. expect(svc).toContain('record each emission in AlertEmission') expect(svc).toContain('// TODO[UC-FLOTTE-PARAM-ALERTES-003]') }) it('a job without an emission entity carries the ba.gap TODO instead', () => { const files = generate(fixture({ scheduledJobs: [{ ...JOB, emissionEntity: null }] } as Parameters[0])) const svc = files.find(f => f.path.endsWith('/BudgetService.cs'))!.content expect(svc).toContain('no emission entity modelled') }) it('no scheduledJobs → byte-stable legacy output (no dangling identifiers)', () => { const files = generate(fixture()) const svc = files.find(f => f.path.endsWith('/BudgetService.cs'))!.content expect(svc).not.toContain('RunNotifierEcheancesAsync') }) }) describe('scaffold-business / generate — clock-referencing rules (TimeProvider)', () => { const clockRule = { id: 'BR-007', description: 'Driver must be at least 18 years old', expression: 'RuleFor(x => x.BirthDate).LessThanOrEqualTo(DateTime.UtcNow.AddYears(-18)).WithMessage("parc.driver.age-min")', validExamples: [], invalidExamples: [], } it('rewrites DateTime.UtcNow onto the injected TimeProvider in BOTH validators', () => { const files = generate(fixture({ businessRules: [clockRule] })) for (const name of ['Create', 'Update']) { const v = files.find(f => f.path.endsWith(`Validators/${name}BudgetCommandValidator.cs`))!.content // The guard becomes testable at its boundary: a FakeTimeProvider can sit // the test exactly ON the 18th birthday. expect(v).toContain('_time.GetUtcNow().UtcDateTime.AddYears(-18)') expect(v).not.toContain('DateTime.UtcNow') expect(v).toContain('private readonly TimeProvider _time;') expect(v).toContain(`public ${name}BudgetCommandValidator(TimeProvider time)`) expect(v).toContain('_time = time;') } }) it('rewrites DateTime.Now and DateTime.Today onto the local-clock members', () => { const files = generate(fixture({ businessRules: [ { ...clockRule, expression: 'RuleFor(x => x.DueDate).GreaterThan(DateTime.Today).When(_ => DateTime.Now.Hour < 18)' }, ] })) const v = files.find(f => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content expect(v).toContain('_time.GetLocalNow().Date') expect(v).toContain('_time.GetLocalNow().DateTime.Hour') }) it('a clock-free module keeps parameterless validators (no TimeProvider blast radius)', () => { const files = generate(fixture()) const v = files.find(f => f.path.endsWith('Validators/CreateBudgetCommandValidator.cs'))!.content expect(v).toContain('public CreateBudgetCommandValidator()') expect(v).not.toContain('TimeProvider') }) }) describe('scaffold-business / generate — kanban move matrix guard (transitionMatrix)', () => { const moveAction: BusinessCustomAction = { code: 'move', scope: 'row', payloadDto: 'MoveBudgetDto', payloadFields: [{ name: 'status', type: 'select', required: true }], responseDto: 'NoContent', ucReference: 'UC-BUD-020', fieldAssignments: [{ param: 'status', field: 'status' }], transitionMatrix: [ { from: 'submitted', to: 'rejected', rule: 'BR-020' }, { from: 'draft', to: 'submitted', rule: 'BR-012' }, { from: 'submitted', to: 'approved', rule: 'BR-012' }, ], statusProperty: 'Status', statusErrorCode: 'budgets.status.invalid-transition', } const svcOf = (action: BusinessCustomAction) => generate(fixture({ customActions: [action] })) .find((f) => f.path.endsWith('Services/Crm/Budgets/BudgetService.cs'))!.content it('emits the FULL dictionary guard — sorted stable, TryGetValue + Contains, error code in the throw', () => { const svc = svcOf(moveAction) // Deterministic literal: froms sorted, targets sorted. expect(svc).toMatch(/var allowedTransitions = new Dictionary\s*\n\s*\{\s*\n\s*\["draft"\] = new\[\] \{ "submitted" \},\s*\n\s*\["submitted"\] = new\[\] \{ "approved", "rejected" \},\s*\n\s*\};/) expect(svc).toMatch(/var targetStatus = payload\.Status;/) expect(svc).toMatch(/!allowedTransitions\.TryGetValue\(entity\.Status, out var allowedTargets\)/) expect(svc).toMatch(/!allowedTargets\.Contains\(targetStatus\)/) expect(svc).toMatch(/throw new BusinessRuleException\(\$"\[budgets\.status\.invalid-transition\] Cannot 'move': transition '\{entity\.Status\}' → '\{targetStatus\}' is not allowed\."\);/) // The guarded assignment — never the generic fieldAssignments echo. expect(svc).toMatch(/entity\.Status = targetStatus;/) expect(svc).not.toMatch(/entity\.Status = payload\.Status;/) expect(svc).toMatch(/await _context\.SaveChangesAsync\(ct\);/) // Guard precedes the assignment. expect(svc.indexOf('TryGetValue')).toBeLessThan(svc.indexOf('entity.Status = targetStatus')) }) it('traces each BR edge as an ENFORCED comment — never a TODO[BR-…] marker (the matrix IS the enforcement)', () => { const svc = svcOf(moveAction) expect(svc).toMatch(/\/\/ Enforced BR-012: draft → submitted, submitted → approved/) expect(svc).toMatch(/\/\/ Enforced BR-020: submitted → rejected/) expect(svc).not.toMatch(/TODO\[BR-012\]/) expect(svc).not.toMatch(/TODO\[BR-020\]/) }) it('honours a non-Status statusProperty end-to-end (the legacy recipe hardcoded entity.Status)', () => { const svc = svcOf({ ...moveAction, statusProperty: 'Etat', fieldAssignments: [{ param: 'etat', field: 'etat' }], }) expect(svc).toMatch(/var targetStatus = payload\.Etat;/) expect(svc).toMatch(/allowedTransitions\.TryGetValue\(entity\.Etat,/) expect(svc).toMatch(/entity\.Etat = targetStatus;/) expect(svc).not.toMatch(/entity\.Status/) }) it('keeps NON-status field assignments (a move dialog capturing a motif) and drops the error-code prefix when absent', () => { const svc = svcOf({ ...moveAction, statusErrorCode: undefined, payloadFields: [ { name: 'status', type: 'select', required: true }, { name: 'motif', type: 'textarea', required: false }, ], fieldAssignments: [ { param: 'status', field: 'status' }, { param: 'motif', field: 'rejectionReason' }, ], }) expect(svc).toMatch(/entity\.RejectionReason = payload\.Motif;/) expect(svc).toMatch(/throw new BusinessRuleException\(\$"Cannot 'move':/) }) it('the matrix recipe WINS over the single-edge workflow recipe when both are present', () => { const svc = svcOf({ ...moveAction, fromStatus: ['draft'], toStatus: 'submitted' }) expect(svc).toMatch(/allowedTransitions/) expect(svc).not.toMatch(/entity is in '\{entity\.Status\}', expected/) }) }) describe('scaffold-business / a reference table is named by its LABEL, not by its code', () => { // Measured on a real app: 11 référentiel services were fixed BY HAND because // every combobox and every column rendered `A_FAIRE` instead of « À faire ». // The generator was never fixed, so every regeneration re-introduced it. // The mechanics: `Code` sits 3rd in the display cascade and `Libelle` 5th. const referentiel = (overrides: Partial = {}): ScaffoldBusinessInput => fixture({ name: 'StatutTache', pluralName: 'StatutTaches', fields: [ { name: 'Code', type: 'string', required: true, isKey: false }, { name: 'Libelle', type: 'string', required: true, isKey: false }, ], ...overrides, }) const service = (spec: ScaffoldBusinessInput): string => generate(spec).find((f) => f.path.includes('Services/') && f.path.endsWith('StatutTacheService.cs'))!.content it('a lookup projects — and orders by — its Libelle', () => { const svc = service(referentiel({ classification: 'lookup' })) expect(svc).toContain('new StatutTacheRefDto(x.Id, x.Libelle)') expect(svc).toContain('q.OrderBy(x => x.Libelle)') expect(svc).not.toContain('new StatutTacheRefDto(x.Id, x.Code)') }) it('any OTHER classification keeps Code — on an invoice the code IS the handle', () => { expect(service(referentiel({ classification: 'agrégat racine' }))).toContain( 'new StatutTacheRefDto(x.Id, x.Code)', ) }) it('an UNSTATED classification keeps the historical cascade — the CLI never guesses', () => { expect(service(referentiel())).toContain('new StatutTacheRefDto(x.Id, x.Code)') }) it('**Affichage** still wins over the cascade, lookup or not', () => { expect(service(referentiel({ classification: 'lookup', displayNameExpr: 'Code' }))).toContain( 'new StatutTacheRefDto(x.Id, x.Code)', ) }) it('the classification is folded like the audit does — accents and case included', () => { expect(service(referentiel({ classification: 'Référentiel LOOKUP' }))).toContain( 'new StatutTacheRefDto(x.Id, x.Libelle)', ) }) })