import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldControllerInput, ControllerCustomAction } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldControllerInput { return { name: 'Budget', pluralName: 'Budgets', module: 'budgets', section: 'budgets', appCode: 'TestV2', applicationCode: 'crm', namespace: 'TestV2', navRoute: 'budgets.budgets', // {app}.{module}.{section} — MUST carry the applicationCode ('crm' above): // seeded grants are 4-segment and the platform permission match is exact. permissionPrefix: 'crm.budgets.budgets', actions: ['read', 'create', 'update', 'delete'], customActions: [], fields: [ { name: 'code', type: 'string', required: true }, { name: 'label', type: 'string', required: true }, ], projectPath: '/tmp/project', ...overrides, } } describe('scaffold-controller / generate — integration strata (Swagger + route prefix)', () => { it('tags the controller with [ApiExplorerSettings(GroupName = "integration")]', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[ApiExplorerSettings\(GroupName\s*=\s*"integration"\)\]/) // App/Module classification: namespace + cross-layer usings gain .. expect(ctrl.content).toContain('namespace TestV2.Api.Controllers.Crm.Budgets;') expect(ctrl.content).toContain('using TestV2.Application.Crm.Budgets.DTOs;') expect(ctrl.content).toContain('using TestV2.Api.Permissions.Crm.Budgets;') expect(ctrl.path).toContain('Api/Controllers/Crm/Budgets/BudgetsController.cs') }) it('carries [NavRoute] and NO [Route] (the platform resolves the route)', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! // The platform's NavigationRouteModelProvider clears every selector and rebuilds // the route from [NavRoute] → /api/{module}/{section}, DISCARDING any [Route]. The // frontend api-client derives the SAME path from the same navRoute (buildNavApiPath) // → front == back. A literal [Route] would be dead weight + audit confusion. expect(ctrl.content).toMatch(/\[NavRoute\("budgets\.budgets"\)\]/) expect(ctrl.content).not.toMatch(/\[Route\(/) // The legacy [controller] token must never appear. expect(ctrl.content).not.toMatch(/\[controller\]/) }) it('a multi-word entity still gets [NavRoute] + the kebab class name (no [Route])', () => { const files = generate(fixture({ name: 'OrderLine', pluralName: 'OrderLines', navRoute: 'orders.order-lines' })) const ctrl = files.find((f) => f.path.endsWith('OrderLinesController.cs'))! expect(ctrl.content).toMatch(/\[NavRoute\("orders\.order-lines"\)\]/) expect(ctrl.content).not.toMatch(/\[Route\(/) }) it('explains in a doc comment that the screen-driven API lives elsewhere', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/scaffold-screen-controller/) expect(ctrl.content).toMatch(/machine-to-machine/) }) }) describe('scaffold-controller / generate — standard CRUD', () => { it('emits GET /api/budgets and GET /api/budgets/:id always', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpGet\]\s+\[RequirePermission\(BudgetsPermissions\.Budgets\.Read\)\]/) expect(ctrl.content).toMatch(/\[HttpGet\("\{id:guid\}"\)\]/) }) it('emits POST/PUT/DELETE conditionally on actions[]', () => { const files = generate(fixture({ actions: ['read', 'create'] })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPost\]/) expect(ctrl.content).not.toMatch(/\[HttpPut\("\{id:guid\}"\)\]/) expect(ctrl.content).not.toMatch(/\[HttpDelete\("\{id:guid\}"\)\]/) }) it('emits the permissions class with one constant per action', () => { const files = generate(fixture()) const perm = files.find((f) => f.path.includes('Permissions'))! expect(perm.content).toContain('namespace TestV2.Api.Permissions.Crm.Budgets;') expect(perm.path).toContain('Api/Permissions/Crm/Budgets/BudgetsPermissions.Budgets.cs') expect(perm.content).toMatch(/public const string Read\s*=\s*"crm\.budgets\.budgets\.read"/) expect(perm.content).toMatch(/public const string Create\s*=\s*"crm\.budgets\.budgets\.create"/) expect(perm.content).toMatch(/public const string Update\s*=\s*"crm\.budgets\.budgets\.update"/) expect(perm.content).toMatch(/public const string Delete\s*=\s*"crm\.budgets\.budgets\.delete"/) expect(perm.content).toMatch(/public const string Access\s*=\s*"crm\.budgets\.budgets\.access"/) expect(perm.content).toMatch(/public const string Lookup\s*=\s*"crm\.budgets\.budgets\.lookup"/) }) it('derives the prefix from applicationCode when permissionPrefix is omitted (the 403 regression)', () => { // The fallback is the NOMINAL path — no production caller passes // permissionPrefix. A fallback without the app segment emits constants no // seeded {app}.{module}.{section}.{action} grant can match (exact platform // match) → every role-based user 403s. This test locks the derived prefix. const files = generate(fixture({ permissionPrefix: undefined })) const perm = files.find((f) => f.path.includes('Permissions'))! expect(perm.content).toMatch(/public const string Read\s*=\s*"crm\.budgets\.budgets\.read"/) expect(perm.content).toMatch(/public const string Access\s*=\s*"crm\.budgets\.budgets\.access"/) expect(perm.content).toMatch(/public const string Lookup\s*=\s*"crm\.budgets\.budgets\.lookup"/) // No 3-segment (app-less) permission value may survive anywhere in the file. expect(perm.content).not.toMatch(/"budgets\.budgets\.[a-z]+"/) }) it('Access + Lookup constants are STRUCTURAL — emitted whatever actions[] says', () => { const files = generate(fixture({ actions: ['read'] })) const perm = files.find((f) => f.path.includes('Permissions'))! // access = menu/route visibility lock, lookup = /lookup reference gate — // both exist independently of the entity's data actions. expect(perm.content).toMatch(/public const string Access\s*=\s*"crm\.budgets\.budgets\.access"/) expect(perm.content).toMatch(/public const string Lookup\s*=\s*"crm\.budgets\.budgets\.lookup"/) expect(perm.content).not.toMatch(/public const string Create\s*=/) }) }) describe('scaffold-controller / generate — custom actions (per-page mode)', () => { function archive(): ControllerCustomAction { return { code: 'archive', scope: 'row', httpMethod: 'POST', payloadDto: null, responseDto: 'NoContent', permissionAction: 'update', } } function duplicate(): ControllerCustomAction { return { code: 'duplicate', scope: 'row', httpMethod: 'POST', payloadDto: null, responseDto: 'BudgetDto', permissionAction: 'create', } } it('emits a row-scope custom action as POST /{id:guid}/{code}', () => { const files = generate(fixture({ customActions: [archive()] })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/) // Method binds id parameter and calls _service.ArchiveAsync(id, ct) expect(ctrl.content).toMatch(/public async Task Archive\(Guid id, CancellationToken/) expect(ctrl.content).toMatch(/await _service\.ArchiveAsync\(id, ct\)/) expect(ctrl.content).toMatch(/return NoContent\(\)/) }) it('binds custom action permission to the {permissionAction} segment of the perm prefix', () => { const files = generate(fixture({ customActions: [archive()] })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! // archive uses permissionAction='update' → BudgetsPermissions.Budgets.Update expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Update\)\]/) }) it('emits ActionResult when responseDto is set (not NoContent)', () => { const files = generate(fixture({ customActions: [duplicate()] })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/public async Task> Duplicate\(/) expect(ctrl.content).toMatch(/var result = await _service\.DuplicateAsync\(id, ct\)/) expect(ctrl.content).toMatch(/return Ok\(result\)/) }) it('emits a bulk-scope custom action as POST /bulk/{code} (no id)', () => { const files = generate(fixture({ customActions: [{ code: 'archive', scope: 'bulk', httpMethod: 'POST', payloadDto: 'BulkArchiveRequest', responseDto: 'NoContent', permissionAction: 'update', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPost\("bulk\/archive"\)\]/) // Body is optional (EmptyBodyBehavior.Allow + nullable dto) so a caller that // sends no body binds null → new() instead of a 415. expect(ctrl.content).toMatch(/public async Task Archive\(\[FromBody\(EmptyBodyBehavior = EmptyBodyBehavior\.Allow\)\] BulkArchiveRequest\? dto = null, CancellationToken/) // No `Guid id` parameter on bulk methods expect(ctrl.content).not.toMatch(/Archive\(Guid id, \[FromBody/) expect(ctrl.content).toMatch(/await _service\.ArchiveAsync\(dto \?\? new\(\), ct\)/) }) it('emits a header-scope custom action as POST /{code} (no id, optional payload)', () => { const files = generate(fixture({ customActions: [{ code: 'export', scope: 'header', httpMethod: 'POST', payloadDto: null, responseDto: 'FileContentResult', permissionAction: 'read', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPost\("export"\)\]/) expect(ctrl.content).toMatch(/public async Task> Export\(CancellationToken/) expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Read\)\]/) }) it('pascalizes kebab-case codes for the C# method name', () => { const files = generate(fixture({ customActions: [{ code: 'submit-for-review', scope: 'row', httpMethod: 'POST', payloadDto: null, responseDto: 'NoContent', permissionAction: 'update', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/public async Task SubmitForReview\(Guid id, /) expect(ctrl.content).toMatch(/await _service\.SubmitForReviewAsync\(id, ct\)/) // URL keeps the kebab-case expect(ctrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/submit-for-review"\)\]/) }) it('coexists peacefully with standard CRUD actions in one controller', () => { const files = generate(fixture({ actions: ['read', 'create', 'update', 'delete'], customActions: [{ code: 'archive', scope: 'row', httpMethod: 'POST', payloadDto: null, responseDto: 'NoContent', permissionAction: 'update', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! // Standard CRUD methods present expect(ctrl.content).toMatch(/public async Task> Create/) expect(ctrl.content).toMatch(/public async Task Update/) expect(ctrl.content).toMatch(/public async Task Delete/) // Custom action also present expect(ctrl.content).toMatch(/public async Task Archive/) }) it('emits NO custom action methods when customActions is empty (legacy default)', () => { const files = generate(fixture({ customActions: [] })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).not.toMatch(/Archive\(/) expect(ctrl.content).not.toMatch(/Duplicate\(/) }) }) describe('scaffold-controller / generate — custom actions httpMethod', () => { it('emits [HttpGet] for a header-scope GET action with no body', () => { const files = generate(fixture({ customActions: [{ code: 'analyze-impact', scope: 'header', httpMethod: 'GET', payloadDto: null, responseDto: 'ImpactReportDto', permissionAction: 'read', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpGet\("analyze-impact"\)\]/) expect(ctrl.content).toMatch(/public async Task> AnalyzeImpact\(CancellationToken/) // GET must NOT carry [FromBody], even if some caller pre-fills payloadDto expect(ctrl.content).not.toMatch(/AnalyzeImpact\([^)]*FromBody/) }) it('emits [HttpGet] for a row-scope GET action with Guid id and no body', () => { const files = generate(fixture({ customActions: [{ code: 'impact', scope: 'row', httpMethod: 'GET', payloadDto: null, responseDto: 'ImpactReportDto', permissionAction: 'read', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpGet\("\{id:guid\}\/impact"\)\]/) expect(ctrl.content).toMatch(/public async Task> Impact\(Guid id, CancellationToken/) expect(ctrl.content).toMatch(/await _service\.ImpactAsync\(id, ct\)/) }) it('emits [HttpPatch] for a row-scope PATCH action', () => { const files = generate(fixture({ customActions: [{ code: 'rename', scope: 'row', httpMethod: 'PATCH', payloadDto: 'RenameRequest', responseDto: 'NoContent', permissionAction: 'update', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPatch\("\{id:guid\}\/rename"\)\]/) expect(ctrl.content).toMatch(/Rename\(Guid id, \[FromBody\(EmptyBodyBehavior = EmptyBodyBehavior\.Allow\)\] RenameRequest\? dto = null, CancellationToken/) }) it('emits [HttpDelete] for a header-scope DELETE action', () => { const files = generate(fixture({ customActions: [{ code: 'purge', scope: 'header', httpMethod: 'DELETE', payloadDto: null, responseDto: 'NoContent', permissionAction: 'delete', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpDelete\("purge"\)\]/) expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Delete\)\]/) }) it('preserves POST when httpMethod is omitted (legacy default)', () => { const files = generate(fixture({ customActions: [{ code: 'archive', scope: 'row', payloadDto: null, responseDto: 'NoContent', permissionAction: 'update', } as never], // httpMethod omitted on purpose })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/) }) it('emits a non-default endpoint (code != defaultEndpoint)', () => { // BA code is `syncFromPce` but the legacy backend route is // `sync-from-proconcept`. ba-develop passes the endpoint verbatim as // `code` to scaffold-controller (the URL segment is the single source of // truth). The method name is PascalCased from that — matches the service. const files = generate(fixture({ customActions: [{ code: 'sync-from-proconcept', scope: 'header', httpMethod: 'POST', payloadDto: null, responseDto: 'SyncResultDto', permissionAction: 'execute', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpPost\("sync-from-proconcept"\)\]/) expect(ctrl.content).toMatch(/public async Task> SyncFromProconcept\(CancellationToken/) expect(ctrl.content).toMatch(/await _service\.SyncFromProconceptAsync\(ct\)/) }) }) describe('scaffold-controller / generate — lookup endpoint (feeds )', () => { it('emits GET /lookup with the v3.62 dual gate (Lookup, Read — ANY) and the right shape', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpGet\("lookup"\)\]/) // Dual gate: holders of the dedicated Lookup grant get the id+name surface // WITHOUT the full Read; Read-holders keep passing (ANY semantics). expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Lookup, BudgetsPermissions\.Budgets\.Read\)\]/) expect(ctrl.content).toMatch(/public async Task>> GetLookup/) expect(ctrl.content).toMatch(/await _service\.GetLookupAsync\(new GetBudgetsLookupQuery\(search, page, pageSize\), ct\)/) }) it('GetAll binds sortBy/sortDir and forwards the full page/size/search/sort contract', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[FromQuery\] string\? sortBy = null/) expect(ctrl.content).toMatch(/\[FromQuery\] string\? sortDir = null/) expect(ctrl.content).toMatch(/await _service\.GetAllAsync\(new GetBudgetsQuery\(page, pageSize, search, sortBy, sortDir\), ct\)/) }) it('GetAll exposes every Guid FK as an optional [FromQuery] relation filter, camelCase wire name', () => { const files = generate(fixture({ fields: [ { name: 'code', type: 'string', required: true }, { name: 'clientId', type: 'Guid', required: true }, { name: 'ContactId', type: 'guid', required: false }, ], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! // camelCase wire name === pagespec relatedTabs[].relationFk, whatever the input casing. expect(ctrl.content).toMatch(/\[FromQuery\] Guid\? clientId = null/) expect(ctrl.content).toMatch(/\[FromQuery\] Guid\? contactId = null/) expect(ctrl.content).toMatch(/await _service\.GetAllAsync\(new GetBudgetsQuery\(page, pageSize, search, sortBy, sortDir, clientId, contactId\), ct\)/) // Non-FK fields never leak into the filter list. expect(ctrl.content).not.toMatch(/\[FromQuery\] Guid\? code/) }) it('always emits the lookup endpoint (dual-gated), even when create/update/delete are disabled', () => { const files = generate(fixture({ actions: ['read'] })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toMatch(/\[HttpGet\("lookup"\)\]/) expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Lookup, BudgetsPermissions\.Budgets\.Read\)\]/) expect(ctrl.content).not.toMatch(/\[HttpPost\]/) }) it('places the lookup endpoint before the Create POST in the file (ASP.NET route ordering)', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! const lookupIdx = ctrl.content.indexOf('[HttpGet("lookup")]') const createIdx = ctrl.content.indexOf('[HttpPost]') expect(lookupIdx).toBeGreaterThan(0) expect(createIdx).toBeGreaterThan(0) expect(lookupIdx).toBeLessThan(createIdx) }) }) describe('scaffold-controller / generate — using block (real package namespaces)', () => { // SmartStack.Core.* has NEVER existed in any shipped assembly (same phantom the // data-layer SKILL guards against as SmartStack.Core.Domain). NavRouteAttribute // lives in SmartStack.Api.Routing and PaginatedResult<> in // SmartStack.Application.Common.Models — without both, every generated // controller fails CS0246 on [NavRoute] and on the GetAll/GetLookup signatures. it('imports SmartStack.Api.Routing + SmartStack.Application.Common.Models, never a SmartStack.Core.* ghost', () => { const files = generate(fixture()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('using SmartStack.Api.Routing;') expect(ctrl.content).toContain('using SmartStack.Application.Common.Models;') expect(ctrl.content).not.toContain('SmartStack.Core.') }) }) describe('scaffold-controller / generate — dto→command mappings (required vs full field split)', () => { // scaffold-business builds Create{E}Dto/Command from the REQUIRED fields only // and Update{E}Dto/Command from ALL stored fields. Mapping both from one list // made every entity with an optional field uncompilable (CS1061 on the Create // DTO members + CS1729 arity) — the single most common shape in real modules. const withOptional = () => fixture({ fields: [ { name: 'code', type: 'string', required: true }, { name: 'label', type: 'string', required: true }, { name: 'notes', type: 'string', required: false }, ], }) it('Create maps the CREATION fields — required plus optional non-phased (Create honnête)', () => { // An optional value typed on the create form used to be silently dropped // by binding against a required-only DTO — the Create surface now carries // optional non-phased fields too (nullable), mirroring scaffold-business. const files = generate(withOptional()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('new CreateBudgetCommand(dto.Code, dto.Label, dto.Notes);') }) it('Create excludes lifecycle-phased fields from the mapping (arity guard with business)', () => { const spec = fixture({ fields: [ { name: 'code', type: 'string', required: true }, { name: 'label', type: 'string', required: true }, { name: 'paymentDate', type: 'datetime', required: false, phase: 'paiement' }, ], }) const files = generate(spec) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('new CreateBudgetCommand(dto.Code, dto.Label);') expect(ctrl.content).not.toContain('CreateBudgetCommand(dto.Code, dto.Label, dto.PaymentDate') // Update keeps the phased field — the edit surface writes it. expect(ctrl.content).toContain('new UpdateBudgetCommand(id, dto.Code, dto.Label, dto.PaymentDate);') }) it('Update keeps the full stored list (optional fields included)', () => { const files = generate(withOptional()) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('new UpdateBudgetCommand(id, dto.Code, dto.Label, dto.Notes);') }) it('versioned: Update forwards dto.RowVersion into the command (offline 409 guard)', () => { const files = generate({ ...withOptional(), versioned: true }) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('new UpdateBudgetCommand(id, dto.Code, dto.Label, dto.Notes, dto.RowVersion);') }) }) describe('scaffold-controller / generate — permissions class is partial (one file per section)', () => { // One {Module}Permissions.{Section}.cs is emitted per section, all declaring the // SAME module-grained class in the SAME namespace ("section stays the inner // class + filename" — lib/app-classification.ts). Without `partial`, the second // section of a module is CS0101 by construction. it('two sections of one module both declare `public static partial class`', () => { const a = generate(fixture({ section: 'budgets' })) const b = generate(fixture({ section: 'devis', navRoute: 'budgets.devis', permissionPrefix: 'crm.budgets.devis' })) const permA = a.find((f) => f.path.endsWith('BudgetsPermissions.Budgets.cs'))! const permB = b.find((f) => f.path.endsWith('BudgetsPermissions.Devis.cs'))! for (const perm of [permA, permB]) { expect(perm.content).toContain('public static partial class BudgetsPermissions') expect(perm.content).toContain('namespace TestV2.Api.Permissions.Crm.Budgets;') } }) }) describe('scaffold-controller / generate — payload body binding (client defect 2026-08-25 #3)', () => { it('payload WITHOUT required members keeps EmptyBodyBehavior.Allow + `dto ?? new()`', () => { const files = generate(fixture({ customActions: [{ code: 'suspend', scope: 'row', httpMethod: 'POST', payloadDto: 'SuspendBudgetDto', responseDto: 'NoContent', permissionAction: 'update', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] SuspendBudgetDto? dto = null') expect(ctrl.content).toContain('await _service.SuspendAsync(id, dto ?? new(), ct)') }) it('payload WITH a required member binds a MANDATORY [FromBody] — no `?? new()` (the record has no parameterless ctor)', () => { const files = generate(fixture({ customActions: [{ code: 'deactivate', scope: 'row', httpMethod: 'POST', payloadDto: 'DeactivateBudgetDto', payloadHasRequired: true, responseDto: 'NoContent', permissionAction: 'update', }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain('[FromBody] DeactivateBudgetDto dto') expect(ctrl.content).not.toContain('DeactivateBudgetDto? dto = null') expect(ctrl.content).toContain('await _service.DeactivateAsync(id, dto, ct)') expect(ctrl.content).not.toContain('dto ?? new()') }) }) describe('scaffold-controller / generate — GET queryParameters (client defect 2026-08-25 #4)', () => { it('a GET action with queryParameters binds nullable [FromQuery] scalars forwarded to the service', () => { const files = generate(fixture({ customActions: [{ code: 'impact', scope: 'row', httpMethod: 'GET', payloadDto: null, responseDto: 'VatImpactDto', permissionAction: 'read', queryParameters: [ { name: 'year', type: 'number' }, { name: 'validFrom', type: 'date' }, ], }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).toContain( 'public async Task> Impact(Guid id, [FromQuery] decimal? year, [FromQuery] DateOnly? validFrom, CancellationToken ct = default)', ) expect(ctrl.content).toContain('await _service.ImpactAsync(id, year, validFrom, ct)') // Still no body on a GET. expect(ctrl.content).not.toMatch(/Impact\([^)]*FromBody/) }) it('queryParameters are IGNORED on non-GET verbs (the body carries the payload there)', () => { const files = generate(fixture({ customActions: [{ code: 'archive', scope: 'row', httpMethod: 'POST', payloadDto: null, responseDto: 'NoContent', permissionAction: 'update', queryParameters: [{ name: 'reason', type: 'text' }], }], })) const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))! expect(ctrl.content).not.toContain('[FromQuery] string? reason') expect(ctrl.content).toContain('await _service.ArchiveAsync(id, ct)') }) }) describe('scaffold-controller / scheduled-job triggers', () => { it('emits POST jobs/{slug}/run gated on .Execute with the ?date replay seam', () => { const files = generate(fixture({ scheduledJobs: [{ ucCode: 'UC-X-003', jobId: 'x-mod-notify', slug: 'notify-due-dates', methodName: 'RunNotifyDueDatesAsync', cron: '0 3 * * *', emissionEntity: 'AlertEmission', }], } as Parameters[0])) const ctrl = files.find(f => f.path.endsWith('BudgetsController.cs'))!.content expect(ctrl).toContain('[HttpPost("jobs/notify-due-dates/run")]') expect(ctrl).toMatch(/\[RequirePermission\(\w+Permissions\.\w+\.Execute\)\]/) expect(ctrl).toContain('RunNotifyDueDatesTrigger([FromQuery] DateOnly? date = null, CancellationToken ct = default)') expect(ctrl).toContain('emitted = await _service.RunNotifyDueDatesAsync(date ?? default, ct)') }) }) describe('scaffold-controller / supplied-on-create — named Code forwarding', () => { it('forwards dto.Code as a NAMED argument when codedEntity.supplied is set', () => { const files = generate(fixture({ fields: [{ name: 'label', type: 'string', required: true }], codedEntity: { supplied: true } as never, })) const ctrl = files.find((f) => f.path.includes('Controller'))! expect(ctrl.content).toContain('new CreateBudgetCommand(dto.Label, Code: dto.Code);') }) it('OPT-IN STRICT: boolean flag / supplied:false / absent stay identical (no Code forwarding)', () => { const base = generate(fixture({ fields: [{ name: 'label', type: 'string', required: true }] })) for (const flag of [true, false, { supplied: false }] as const) { const files = generate(fixture({ fields: [{ name: 'label', type: 'string', required: true }], codedEntity: flag as never, })) expect(files.map((f) => f.content)).toEqual(base.map((f) => f.content)) } }) })