import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { PageSpec, ScaffoldScreenControllerSpec } from '../types.js' function spec(overrides: Partial = {}): ScaffoldScreenControllerSpec { return { section: 'opportunites', entity: 'Opportunity', module: 'pipeline', appCode: 'crm', namespace: 'Crm', moduleDir: '/ba/CRM/PIPELINE', projectPath: '/project', ...overrides, } } function pagespec(overrides: Partial = {}): PageSpec { return { screenCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-001', appCode: 'crm', module: 'pipeline', section: 'opportunites', entity: 'Opportunity', view: 'list', permission: 'pipeline.opportunites.read', linkedUseCases: [], linkedBusinessRules: [], columns: [], actions: [], ...overrides, } as PageSpec } function controllerOf(files: { path: string; content: string }[]): { path: string; content: string } | undefined { return files.find(f => f.path.endsWith('ScreenController.cs')) } describe('scaffold-screen-controller / generate — controller-level attributes', () => { it('emits a controller named {EntityPlural}ScreenController under Controllers/{App}/{Mod}/{Section}/', () => { const { files } = generate(spec(), [pagespec()]) const ctrl = controllerOf(files) expect(ctrl?.path).toBe('src/Crm.Api/Controllers/Crm/Pipeline/Opportunites/OpportunitiesScreenController.cs') // Section-grained: namespace gains ..
; usings track the // classified Application + Permissions namespaces. expect(ctrl?.content).toContain('namespace Crm.Api.Controllers.Crm.Pipeline.Opportunites;') expect(ctrl?.content).toContain('using Crm.Application.Crm.Pipeline.DTOs.Screens;') expect(ctrl?.content).toContain('using Crm.Api.Permissions.Crm.Pipeline;') }) it('tags the class with [ApiExplorerSettings(GroupName = "screens")]', () => { const { files } = generate(spec(), [pagespec()]) expect(controllerOf(files)?.content).toMatch(/\[ApiExplorerSettings\(GroupName\s*=\s*"screens"\)\]/) }) it('routes the controller under api/screens/{entityPluralLower}', () => { const { files } = generate(spec(), [pagespec()]) expect(controllerOf(files)?.content).toMatch(/\[Route\("api\/screens\/opportunities"\)\]/) }) it('injects I{Entity}Service as the only dependency', () => { const { files } = generate(spec(), [pagespec()]) const c = controllerOf(files)!.content expect(c).toMatch(/private readonly IOpportunityService _service;/) expect(c).toMatch(/public OpportunitiesScreenController\(IOpportunityService service\)/) }) }) describe('scaffold-screen-controller / generate — list view', () => { it('emits [HttpGet("list")] with paginated dto + read permission', () => { const { files } = generate(spec(), [pagespec({ view: 'list' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpGet\("list"\)\]/) expect(c).toMatch(/ActionResult>/) expect(c).toMatch(/\[RequirePermission\(PipelinePermissions\.Opportunites\.Read\)\]/) expect(c).toMatch(/_service\.GetForListScreenAsync\(new GetOpportunityListScreenQuery\(page, pageSize, search, sortBy, sortDir\), ct\)/) // Sort flows through the screen stratum too (server-side). expect(c).toMatch(/\[FromQuery\] string\? sortBy = null/) expect(c).toMatch(/\[FromQuery\] string\? sortDir = null/) }) it('exposes spec.fkFilters as optional [FromQuery] Guid? relation filters forwarded to the query', () => { const { files } = generate(spec({ fkFilters: ['clientId', 'contactId'] }), [pagespec({ view: 'list' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[FromQuery\] Guid\? clientId = null/) expect(c).toMatch(/\[FromQuery\] Guid\? contactId = null/) expect(c).toMatch(/_service\.GetForListScreenAsync\(new GetOpportunityListScreenQuery\(page, pageSize, search, sortBy, sortDir, clientId, contactId\), ct\)/) }) it('exposes pagespec filters[] as typed [FromQuery] params passed as NAMED args to the query', () => { const { files } = generate(spec({ fkFilters: ['clientId'] }), [pagespec({ view: 'list', filters: [ { field: 'search', control: 'text' }, // fused with the search param { field: 'statusId', control: 'lookup' }, // reference OUTSIDE fkFilters → own param { field: 'clientId', control: 'select' }, // already an FK param → skipped { field: 'settlementStatus', control: 'select' }, { field: 'overdue', control: 'boolean' }, { field: 'number', control: 'text' }, { field: 'invoiceDate', control: 'date-range' }, ], } as Partial)]) const c = controllerOf(files)!.content expect(c).toMatch(/\[FromQuery\] string\? settlementStatus = null/) expect(c).toMatch(/\[FromQuery\] bool\? overdue = null/) expect(c).toMatch(/\[FromQuery\] string\? number = null/) expect(c).toMatch(/\[FromQuery\] DateTime\? invoiceDateFrom = null/) expect(c).toMatch(/\[FromQuery\] DateTime\? invoiceDateTo = null/) // A `lookup` filter that is NOT on the Guid FK channel gets its own param. // It used to be skipped unconditionally, on the assumption the channel // covered it — here `fkFilters` is ['clientId'] only, so nothing did: the // filter reached the wire with no param to bind and silently did nothing. expect(c).toMatch(/\[FromQuery\] string\? statusId = null/) // No param for the fused search filter nor the FK-covered one. expect(c).not.toMatch(/\[FromQuery\] string\? search = null,[\s\S]*\[FromQuery\] string\? search = null/) expect(c).not.toMatch(/\[FromQuery\] string\? clientId/) // Named args keep two adjacent string? params from silently transposing. expect(c).toMatch(/new GetOpportunityListScreenQuery\(page, pageSize, search, sortBy, sortDir, clientId, StatusId: statusId, SettlementStatus: settlementStatus, Overdue: overdue, Number: number, InvoiceDateFrom: invoiceDateFrom, InvoiceDateTo: invoiceDateTo\), ct\)/) }) it('never double-declares a lookup filter the Guid FK channel already carries', () => { const { files } = generate(spec({ fkFilters: ['clientId', 'statusId'] }), [pagespec({ view: 'list', filters: [{ field: 'statusId', control: 'lookup' }, { field: 'number', control: 'text' }], })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[FromQuery\] Guid\? statusId = null/) expect(c).not.toMatch(/\[FromQuery\] string\? statusId/) expect(c).toMatch(/new GetOpportunityListScreenQuery\(page, pageSize, search, sortBy, sortDir, clientId, statusId, Number: number\), ct\)/) }) it('emits a {Entity}ListScreenDto record under DTOs/Screens/', () => { const { files } = generate(spec(), [pagespec({ view: 'list', columns: [ { key: 'amount', formatHint: 'currency' }, { key: 'name', formatHint: 'string' }, ], })]) const dto = files.find(f => f.path.endsWith('OpportunityListScreenDto.cs'))! expect(dto.path).toBe('src/Crm.Application/Crm/Pipeline/DTOs/Screens/OpportunityListScreenDto.cs') expect(dto.content).toMatch(/public record OpportunityListScreenDto/) expect(dto.content).toMatch(/public decimal Amount \{ get; init; \} = 0;/) expect(dto.content).toMatch(/public string Name \{ get; init; \} = "";/) }) it('handles list with no columns (safe minimum dto)', () => { const { files } = generate(spec(), [pagespec({ view: 'list', columns: [] })]) const dto = files.find(f => f.path.endsWith('OpportunityListScreenDto.cs'))! expect(dto.content).toMatch(/public Guid Id \{ get; init; \}/) expect(dto.content).toMatch(/No columns in pagespec/) }) }) describe('scaffold-screen-controller / generate — detail view', () => { it('emits [HttpGet("detail/{id:guid}")] with NotFound branch', () => { const { files } = generate(spec(), [pagespec({ view: 'detail' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpGet\("detail\/\{id:guid\}"\)\]/) expect(c).toMatch(/ActionResult/) expect(c).toMatch(/result is null \? NotFound\(\) : Ok\(result\)/) expect(c).toMatch(/_service\.GetForDetailScreenAsync\(id, ct\)/) }) it('emits a {Entity}DetailScreenDto record', () => { const { files } = generate(spec(), [pagespec({ view: 'detail', columns: [{ key: 'stage' }] })]) const dto = files.find(f => f.path.endsWith('OpportunityDetailScreenDto.cs'))! expect(dto.content).toMatch(/public record OpportunityDetailScreenDto/) expect(dto.content).toMatch(/public string Stage \{ get; init; \}/) }) }) describe('scaffold-screen-controller / generate — form view', () => { it('emits POST /form and PUT /form/{id:guid}', () => { const { files } = generate(spec(), [pagespec({ view: 'form', permission: 'pipeline.opportunites.update' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpPost\("form"\)\]/) expect(c).toMatch(/\[HttpPut\("form\/\{id:guid\}"\)\]/) expect(c).toMatch(/CreateOpportunityDto dto/) expect(c).toMatch(/UpdateOpportunityDto dto/) expect(c).toMatch(/_service\.CreateAsync/) expect(c).toMatch(/_service\.UpdateAsync/) }) it('emits NO DTO for form (consumes integration Create/UpdateDto)', () => { const { files } = generate(spec(), [pagespec({ view: 'form' })]) const dto = files.find(f => f.path.includes('FormScreenDto')) expect(dto).toBeUndefined() }) it('emits CreatedAtAction pointing to GetDetail', () => { const { files } = generate(spec(), [pagespec({ view: 'form' })]) const c = controllerOf(files)!.content expect(c).toMatch(/CreatedAtAction\(nameof\(GetDetailRoute\)/) }) }) describe('scaffold-screen-controller / generate — custom actions on a list view', () => { it('emits a row-scope POST action under {id:guid}/{endpoint} (matches the integration controller + the frontend URL — no detail/ prefix)', () => { const { files } = generate(spec(), [pagespec({ view: 'list', actions: [{ code: 'archive', scope: 'row', kind: 'api', httpMethod: 'POST', permission: 'pipeline.opportunites.update', }], })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/) // The former `detail/{id:guid}/...` form had no frontend counterpart → 404. expect(c).not.toMatch(/detail\/\{id:guid\}\/archive/) expect(c).toMatch(/public async Task Archive\(Guid id, CancellationToken/) expect(c).toMatch(/_service\.ArchiveAsync\(id, ct\)/) expect(c).toMatch(/\[RequirePermission\(PipelinePermissions\.Opportunites\.Update\)\]/) }) it('emits a bulk-scope action under bulk/{endpoint}', () => { const { files } = generate(spec(), [pagespec({ view: 'list', actions: [{ code: 'bulk-archive', scope: 'bulk', kind: 'api', httpMethod: 'POST', payloadDto: 'BulkArchiveRequest', permission: 'pipeline.opportunites.update', }], })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpPost\("bulk\/bulk-archive"\)\]/) // Optional body — EmptyBodyBehavior.Allow + nullable dto (no mandatory 415). expect(c).toMatch(/\[FromBody\(EmptyBodyBehavior = EmptyBodyBehavior\.Allow\)\] BulkArchiveRequest\? dto = null/) expect(c).toMatch(/BulkArchiveAsync\(dto \?\? new\(\), ct\)/) }) it('emits a header-scope action under {endpoint} (no id)', () => { const { files } = generate(spec(), [pagespec({ view: 'list', actions: [{ code: 'syncFromPce', scope: 'header', kind: 'api', endpoint: 'sync-from-proconcept', httpMethod: 'POST', permission: 'pipeline.opportunites.execute', }], })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpPost\("sync-from-proconcept"\)\]/) expect(c).toMatch(/_service\.SyncFromPceAsync\(ct\)/) }) it('omits [FromBody] on GET actions even when payloadDto is set', () => { const { files } = generate(spec(), [pagespec({ view: 'list', actions: [{ code: 'export', scope: 'header', kind: 'api', httpMethod: 'GET', payloadDto: 'ExportRequest', responseDto: 'FileContentResult', permission: 'pipeline.opportunites.read', }], })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpGet\("export"\)\]/) expect(c).not.toMatch(/Export\([^)]*FromBody/) }) it('skips navigation actions (kind="navigate") — those are pure frontend', () => { const { files } = generate(spec(), [pagespec({ view: 'list', actions: [{ code: 'open', kind: 'navigate', scope: 'row', targetScreen: 'SCR-X-001' }], })]) const c = controllerOf(files)!.content // Backend should NOT emit a method for a navigate action. expect(c).not.toMatch(/public async Task Open\(/) }) }) describe('scaffold-screen-controller / generate — unsupported views fall back gracefully', () => { // - dashboard → fully supported (Wave F3) // - kanban / card → legacy STANDALONE pagespecs (pre-fold shape): the // board/gallery are viewModes of the LIST page and // are served by ITS endpoint. Serve GET /list so // nothing 404s + push the fold-and-delete migration // TODO (derive-kanban-spec / viewModes). // - app/module/section-home → hub views; frontend emits NO API call, so // backend emits NO endpoint and NO TODO. Absence is // by design — see hub-views.test.ts for the lock. it('serves the list endpoint for a legacy standalone kanban pagespec + a single migration TODO', () => { const { todos, files } = generate(spec(), [pagespec({ view: 'kanban' })]) expect(todos.length).toBe(1) expect(todos[0]).toMatch(/standalone "kanban" pagespec is the pre-fold legacy shape/) expect(todos[0]).toMatch(/derive-kanban-spec/) const ctrl = controllerOf(files)! // The legacy pagespec still produces a real [HttpGet("list")] route so the // frontend's GET ${API_PATH}/list call never 404s during the migration. expect(ctrl.content).toMatch(/\[HttpGet\("list"\)\]/) }) }) describe('scaffold-screen-controller / generate — multiple pagespecs in one controller', () => { it('emits all three views (list + detail + form) in one controller class', () => { const { files } = generate(spec(), [ pagespec({ view: 'list', screenCode: 'SCR-A-001' }), pagespec({ view: 'detail', screenCode: 'SCR-A-002' }), pagespec({ view: 'form', screenCode: 'SCR-A-003', permission: 'pipeline.opportunites.update' }), ]) const ctrls = files.filter(f => f.path.endsWith('ScreenController.cs')) expect(ctrls.length).toBe(1) // ONE controller, not three const c = ctrls[0].content expect(c).toMatch(/HttpGet\("list"\)/) expect(c).toMatch(/HttpGet\("detail\/\{id:guid\}"\)/) expect(c).toMatch(/HttpPost\("form"\)/) expect(c).toMatch(/SCR-A-001/) expect(c).toMatch(/SCR-A-002/) expect(c).toMatch(/SCR-A-003/) }) }) describe('scaffold-screen-controller / generate — Wave F3 (dashboard view)', () => { it('emits a single /dashboard endpoint for view=dashboard (no consolidated/alerts split)', () => { const { files } = generate(spec(), [pagespec({ view: 'dashboard', screenCode: 'SCR-PIPE-DASH-001' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[HttpGet\("dashboard"\)\]/) expect(c).not.toMatch(/dashboard\/consolidated/) expect(c).not.toMatch(/dashboard\/alerts/) }) it('emits a self-contained OpportunityDashboardDto so the controller compiles standalone', () => { const { files } = generate(spec(), [pagespec({ view: 'dashboard' })]) const c = controllerOf(files)!.content expect(c).toMatch(/ActionResult/) const dashDto = files.find(f => /OpportunityDashboardDto\.cs$/.test(f.path)) expect(dashDto, 'OpportunityDashboardDto.cs').toBeDefined() expect(dashDto!.content).toMatch(/Dictionary Widgets/) }) it('forwards startDate + endDate and builds the payload (no phantom service call)', () => { const { files } = generate(spec(), [pagespec({ view: 'dashboard' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[FromQuery\] DateTime\? startDate = null/) expect(c).toMatch(/\[FromQuery\] DateTime\? endDate = null/) expect(c).toMatch(/var dto = new OpportunityDashboardDto\(\)/) expect(c).toMatch(/return Ok\(dto\)/) expect(c).not.toMatch(/_service\.GetDashboard/) }) it('populates each declared widget with a shape-correct default + a precise per-widget TODO', () => { const { files } = generate(spec(), [pagespec({ view: 'dashboard', widgets: [ { key: 'total', type: 'kpi', aggregation: 'count', entity: 'Opportunity' }, { key: 'byStage', type: 'chart-pie', aggregation: 'count', entity: 'Opportunity', field: 'Stage' }, { key: 'recent', type: 'list', entity: 'Opportunity' }, ], })]) const c = controllerOf(files)!.content // Shape-correct payload per widget type → the frontend renders the skeleton, not "no data". expect(c).toMatch(/dto\.Widgets\["total"\] = new \{ value = 0 \}/) expect(c).toMatch(/dto\.Widgets\["byStage"\] = new \{ slices = System\.Array\.Empty\(\) \}/) expect(c).toMatch(/dto\.Widgets\["recent"\] = new \{ rows = System\.Array\.Empty\(\), columns = System\.Array\.Empty\(\) \}/) // Precise per-widget aggregation TODO (type · entity · field · aggregation). expect(c).toMatch(/TODO\[DASH:byStage\]: chart-pie — aggregate count of Opportunity\.Stage/) }) it('applies [RequirePermission] derived from the pagespec', () => { const { files } = generate(spec(), [pagespec({ view: 'dashboard', permission: 'pipeline.opportunites.read' })]) const c = controllerOf(files)!.content expect(c).toMatch(/\[RequirePermission\(PipelinePermissions\.Opportunites\.Read\)\]/) }) it('does NOT push a TODO for view=dashboard (no longer unsupported)', () => { const { todos } = generate(spec(), [pagespec({ view: 'dashboard' })]) expect(todos.find(t => /view "dashboard"/.test(t))).toBeUndefined() }) it('TODO behaviour — a legacy standalone kanban pagespec surfaces the migration TODO; hub views (app-home) do NOT', () => { // A standalone kanban pagespec is the pre-fold legacy shape: it falls back // to [HttpGet("list")] and pushes the fold-and-delete migration TODO. // app-home / module-home / section-home are pure Slot containers on the // frontend — no API call, no endpoint, no TODO (absence is by design, // locked by hub-views.test.ts). const { todos } = generate(spec(), [ pagespec({ view: 'kanban', screenCode: 'SCR-K-1' }), pagespec({ view: 'app-home', screenCode: 'SCR-H-1' }), ]) expect(todos.some(t => /standalone "kanban" pagespec/.test(t))).toBe(true) expect(todos.some(t => /"app-home"/.test(t))).toBe(false) }) it('dashboard endpoints sit alongside list/detail/form in the same controller', () => { const { files } = generate(spec(), [ pagespec({ view: 'list', screenCode: 'SCR-L' }), pagespec({ view: 'detail', screenCode: 'SCR-D' }), pagespec({ view: 'form', screenCode: 'SCR-F', permission: 'pipeline.opportunites.update' }), pagespec({ view: 'dashboard', screenCode: 'SCR-DASH' }), ]) const ctrls = files.filter(f => f.path.endsWith('ScreenController.cs')) expect(ctrls.length).toBe(1) const c = ctrls[0].content expect(c).toMatch(/HttpGet\("list"\)/) expect(c).toMatch(/HttpGet\("detail\/\{id:guid\}"\)/) expect(c).toMatch(/HttpPost\("form"\)/) expect(c).toMatch(/HttpGet\("dashboard"\)/) }) })