import { describe, it, expect } from 'vitest' import { PageCustomActionSchema, STANDARD_CRUD_CODES, defaultEndpoint, controllerMethodNameFromEndpoint, serviceMethodNameFromEndpoint, lastPermissionSegment, expectedUrlPath, expectedControllerRoute, customActionsOnly, normalizePageCustomAction, toControllerCustomAction, toBusinessCustomAction, toApiClientCustomAction, synthesizedPayloadDtoName, payloadParametersToShape, splitActions, type PageCustomAction, } from '../page-spec-actions.js' // The REAL generator schemas — imported ONLY in the test so the projections can // be round-trip-validated against them. lib itself never depends on development/*. import { ControllerCustomActionSchema } from '../../development/backend/controller/cli/scaffold-controller/types.js' import { BusinessCustomActionSchema } from '../../development/backend/business-layer/cli/scaffold-business/types.js' import { ApiCustomActionSchema } from '../../development/frontend/api-client/cli/scaffold-api-client/types.js' function apiAction(overrides: Partial = {}): PageCustomAction { return PageCustomActionSchema.parse({ code: 'syncFromPce', kind: 'api', scope: 'header', endpoint: 'sync-from-proconcept', httpMethod: 'POST', labelKey: 'list.actions.syncFromPce', permission: 'referentiels.types-affaire.execute', ucReference: 'UC-APP-REF-TYPEAFFAIRE-007', ...overrides, }) } function navAction(overrides: Partial = {}): PageCustomAction { return PageCustomActionSchema.parse({ code: 'open', kind: 'navigate', scope: 'row', targetScreen: 'SCR-APP-REF-TYPEAFFAIRE-002', labelKey: 'list.actions.open', permission: 'referentiels.types-affaire.read', ...overrides, }) } describe('lib/page-spec-actions — helpers', () => { it('defaultEndpoint kebabifies camelCase codes', () => { expect(defaultEndpoint('syncFromPce')).toBe('sync-from-pce') expect(defaultEndpoint('analyzeImpact')).toBe('analyze-impact') expect(defaultEndpoint('archive')).toBe('archive') expect(defaultEndpoint('mapToPce')).toBe('map-to-pce') }) it('controllerMethodNameFromEndpoint returns PascalCase', () => { expect(controllerMethodNameFromEndpoint('sync-from-proconcept')).toBe('SyncFromProconcept') expect(controllerMethodNameFromEndpoint('archive')).toBe('Archive') expect(controllerMethodNameFromEndpoint('analyze-impact')).toBe('AnalyzeImpact') }) it('serviceMethodNameFromEndpoint returns camelCase', () => { expect(serviceMethodNameFromEndpoint('sync-from-proconcept')).toBe('syncFromProconcept') expect(serviceMethodNameFromEndpoint('archive')).toBe('archive') expect(serviceMethodNameFromEndpoint('analyze-impact')).toBe('analyzeImpact') }) it('lastPermissionSegment returns the action segment', () => { expect(lastPermissionSegment('referentiels.types-affaire.execute')).toBe('execute') expect(lastPermissionSegment('budgets.budgets.update')).toBe('update') }) it('expectedUrlPath builds the right path per scope', () => { expect(expectedUrlPath('header', 'sync-from-pce')).toBe('/sync-from-pce') expect(expectedUrlPath('row', 'archive')).toBe('/{id}/archive') expect(expectedUrlPath('bulk', 'export-csv')).toBe('/bulk/export-csv') }) it('expectedControllerRoute mirrors with {id:guid} for row scope', () => { expect(expectedControllerRoute('header', 'sync-from-pce')).toBe('sync-from-pce') expect(expectedControllerRoute('row', 'archive')).toBe('{id:guid}/archive') expect(expectedControllerRoute('bulk', 'export-csv')).toBe('bulk/export-csv') }) it('STANDARD_CRUD_CODES excludes custom actions only', () => { expect(STANDARD_CRUD_CODES.has('create')).toBe(true) expect(STANDARD_CRUD_CODES.has('edit')).toBe(true) expect(STANDARD_CRUD_CODES.has('delete')).toBe(true) expect(STANDARD_CRUD_CODES.has('syncFromPce')).toBe(false) expect(STANDARD_CRUD_CODES.has('archive')).toBe(false) }) it('customActionsOnly filters out CRUD codes', () => { const all = [ { code: 'create' }, { code: 'edit' }, { code: 'syncFromPce' }, { code: 'archive' }, ] expect(customActionsOnly(all).map(a => a.code)).toEqual(['syncFromPce', 'archive']) }) }) describe('lib/page-spec-actions — schema validation (kind:api)', () => { it('accepts a minimal valid api action', () => { expect(() => apiAction()).not.toThrow() }) it('accepts api action WITHOUT explicit endpoint (filled by normalize)', () => { const parsed = PageCustomActionSchema.parse({ code: 'archive', kind: 'api', scope: 'row', labelKey: 'list.actions.archive', permission: 'budgets.budgets.update', ucReference: 'UC-APP-BUD-BUDGETS-004', }) expect(parsed.endpoint).toBeUndefined() const normalized = normalizePageCustomAction(parsed) expect(normalized.endpoint).toBe('archive') }) it('rejects code in kebab-case (must be camelCase)', () => { expect(() => apiAction({ code: 'sync-from-pce' as unknown as string })).toThrow(/camelCase/) }) it('rejects endpoint in camelCase (must be kebab-case)', () => { expect(() => apiAction({ endpoint: 'syncFromPce' })).toThrow(/kebab-case/) }) it('rejects malformed permission (must be 3-4 segments)', () => { expect(() => apiAction({ permission: 'flat' })).toThrow() expect(() => apiAction({ permission: 'two.segments' })).toThrow() expect(() => apiAction({ permission: 'app.module.section.resource.action' }), ).toThrow() }) it('accepts the resource grain (4 segments, app-less)', () => { expect(() => apiAction({ permission: 'pipeline.opportunites.devis.create' }), ).not.toThrow() }) it('accepts httpMethod GET/PUT/PATCH/DELETE (a GET declares its response)', () => { for (const m of ['PUT', 'PATCH', 'DELETE'] as const) { expect(() => apiAction({ httpMethod: m })).not.toThrow() } expect(() => apiAction({ httpMethod: 'GET', responseDto: 'ImpactDto' })).not.toThrow() }) it('rejects a GET action with no response contract (the [HttpGet] → 204 measure class)', () => { expect(() => apiAction({ httpMethod: 'GET' })).toThrow(/a GET action is a READ/) expect(() => apiAction({ httpMethod: 'GET', responseDto: 'NoContent' })).toThrow(/a GET action is a READ/) }) it('a GET with only a responseType (screens-era contract) passes', () => { expect(() => apiAction({ httpMethod: 'GET', responseType: 'ImpactDto' })).not.toThrow() }) it('rejects api action carrying targetScreen', () => { expect(() => apiAction({ targetScreen: 'SCR-APP-REF-TYPEAFFAIRE-002' }), ).toThrow(/api action must NOT carry targetScreen/) }) it('accepts ucReference and guardRules', () => { expect(() => apiAction({ ucReference: 'UC-APP-REF-TYPEAFFAIRE-007', guardRules: ['BR-001', 'BR-007'], }), ).not.toThrow() }) // T9 closure — the field doc always said « mandatory for kind:api »; the // schema now enforces it for CUSTOM actions (CRUD codes stay exempt: the // scaffolders own them, no UC anchors them). it('rejects a custom api action WITHOUT ucReference (the lost TODO[UC-…] anchor)', () => { expect(() => apiAction({ ucReference: undefined })).toThrow(/requires ucReference/) }) it('a standard CRUD code (kind:api) never needs ucReference', () => { expect(() => PageCustomActionSchema.parse({ code: 'create', kind: 'api', scope: 'header', labelKey: 'list.create', permission: 'budgets.budgets.create', }), ).not.toThrow() }) it('a navigate action never needs ucReference', () => { expect(() => navAction()).not.toThrow() }) it('accepts workflowTransition shape', () => { expect(() => apiAction({ workflowTransition: { fromStatus: ['pending'], toStatus: 'approved', flowParameters: ['reason'], }, }), ).not.toThrow() }) }) describe('lib/page-spec-actions — schema validation (kind:navigate)', () => { it('accepts a minimal navigate action with targetScreen', () => { expect(() => navAction()).not.toThrow() }) it('accepts navigate action with targetRoute instead of targetScreen', () => { expect(() => navAction({ targetScreen: undefined, targetRoute: 'routes.referentiels.typesAffaire.detail(item.id)', }), ).not.toThrow() }) it('rejects navigate action without targetScreen or targetRoute', () => { expect(() => navAction({ targetScreen: undefined, targetRoute: undefined }), ).toThrow(/navigate action requires targetRoute or targetScreen/) }) it('rejects navigate action carrying endpoint', () => { expect(() => navAction({ endpoint: 'open' }), ).toThrow(/navigate action must NOT carry endpoint/) }) it('rejects navigate action carrying payloadDto', () => { expect(() => navAction({ payloadDto: 'OpenRequest' }), ).toThrow(/navigate action must NOT carry payloadDto/) }) it('rejects navigate action carrying workflowTransition', () => { expect(() => navAction({ workflowTransition: { fromStatus: ['pending'], toStatus: 'approved', flowParameters: [], }, }), ).toThrow(/navigate action must NOT carry workflowTransition/) }) }) describe('lib/page-spec-actions — normalizePageCustomAction', () => { it('fills endpoint via defaultEndpoint when missing for kind:api', () => { const parsed = PageCustomActionSchema.parse({ code: 'analyzeImpact', kind: 'api', scope: 'header', httpMethod: 'GET', responseDto: 'ImpactDto', labelKey: 'list.actions.analyzeImpact', permission: 'referentiels.types-affaire.read', ucReference: 'UC-APP-REF-TYPEAFFAIRE-009', }) expect(normalizePageCustomAction(parsed).endpoint).toBe('analyze-impact') }) it('mirrors payloadType/responseType from C# names when missing', () => { const parsed = apiAction({ payloadDto: 'SyncRequest', responseDto: 'SyncResultDto' }) const norm = normalizePageCustomAction(parsed) expect(norm.payloadType).toBe('SyncRequest') expect(norm.responseType).toBe('SyncResultDto') }) it('returns navigate actions unchanged', () => { const parsed = navAction() expect(normalizePageCustomAction(parsed)).toEqual(parsed) }) }) describe('lib/page-spec-actions — toControllerCustomAction', () => { it('maps endpoint→code, last permission segment, default responseDto', () => { const out = toControllerCustomAction(apiAction()) expect(out.code).toBe('sync-from-proconcept') // endpoint, NOT the camelCase code expect(out.scope).toBe('header') expect(out.httpMethod).toBe('POST') // controller wants UPPERCASE expect(out.permissionAction).toBe('execute') expect(out.responseDto).toBe('NoContent') expect(out.payloadDto).toBeNull() }) it('falls back to defaultEndpoint(code) when endpoint omitted', () => { const out = toControllerCustomAction( apiAction({ code: 'archive', endpoint: undefined, scope: 'row' }), ) expect(out.code).toBe('archive') }) it('output validates against the REAL ControllerCustomActionSchema (round-trip)', () => { for (const scope of ['row', 'bulk', 'header'] as const) { const out = toControllerCustomAction(apiAction({ scope })) expect(() => ControllerCustomActionSchema.parse(out)).not.toThrow() } }) }) describe('lib/page-spec-actions — toBusinessCustomAction', () => { it('flattens workflowTransition into fromStatus[] / toStatus / flowParameters', () => { const out = toBusinessCustomAction( apiAction({ workflowTransition: { fromStatus: ['draft', 'rejected'], toStatus: 'submitted', flowParameters: ['reason'], }, }), ) expect(out.code).toBe('sync-from-proconcept') expect(out.fromStatus).toEqual(['draft', 'rejected']) expect(out.toStatus).toBe('submitted') expect(out.flowParameters).toEqual(['reason']) }) it('omits workflow fields when no transition', () => { const out = toBusinessCustomAction(apiAction()) expect(out.fromStatus).toBeUndefined() expect(out.toStatus).toBeUndefined() }) it('output validates against the REAL BusinessCustomActionSchema (round-trip)', () => { const withWf = toBusinessCustomAction( apiAction({ scope: 'row', guardRules: ['BR-001'], crossModuleDeps: ['referentiels'], workflowTransition: { fromStatus: ['draft'], toStatus: 'done', flowParameters: [] }, }), ) expect(() => BusinessCustomActionSchema.parse(withWf)).not.toThrow() const bare = toBusinessCustomAction(apiAction({ scope: 'bulk' })) expect(() => BusinessCustomActionSchema.parse(bare)).not.toThrow() }) }) describe('lib/page-spec-actions — toApiClientCustomAction', () => { it('kebabs the business code, keeps endpoint, lowercases httpMethod', () => { const out = toApiClientCustomAction(apiAction()) expect(out.code).toBe('sync-from-pce') // toKebabCase(syncFromPce) — drives the hook name expect(out.endpoint).toBe('sync-from-proconcept') // URL — matches controller route expect(out.httpMethod).toBe('post') // api-client wants lowercase expect(out.responseType).toBe('void') expect(out.kind).toBe('api') }) it('output validates against the REAL ApiCustomActionSchema (round-trip)', () => { for (const scope of ['row', 'bulk', 'header'] as const) { const out = toApiClientCustomAction(apiAction({ scope })) expect(() => ApiCustomActionSchema.parse(out)).not.toThrow() } // GET compute action with a response DTO const get = toApiClientCustomAction( apiAction({ code: 'analyzeImpact', endpoint: 'impact', httpMethod: 'GET', responseDto: 'ImpactDto' }), ) expect(get.httpMethod).toBe('get') expect(get.responseType).toBe('ImpactDto') expect(() => ApiCustomActionSchema.parse(get)).not.toThrow() }) it('derives payloadShape from payloadParameters and keeps payloadType (collectible body)', () => { const out = toApiClientCustomAction(apiAction({ code: 'joindreDocument', endpoint: 'joindre-document', payloadDto: 'JoindreDocumentRequest', payloadParameters: [ { name: 'note', type: 'text', required: true }, { name: 'annee', type: 'number' }, { name: 'fichier', type: 'file', accept: '.pdf' }, ], })) expect(out.payloadType).toBe('JoindreDocumentRequest') expect(out.payloadShape).toEqual({ note: 'string', annee: 'number', fichier: 'File' }) expect(() => ApiCustomActionSchema.parse(out)).not.toThrow() }) it('nulls payloadType (no payloadShape) when a payloadDto has no payloadParameters — optional body', () => { // The generate-legal case: a backend DTO exists but the UI collects nothing. // The frontend must NOT send a body it cannot build; the controller tolerates // the empty body (EmptyBodyBehavior.Allow). This is what removed the need to // hand-null payloadType to make the build pass (the historical 415 hack). const out = toApiClientCustomAction(apiAction({ code: 'genererLegaux', endpoint: 'generate-legal', payloadDto: 'GenerateLegalHolidaysRequest', })) expect(out.payloadType).toBeNull() expect(out.payloadShape).toBeUndefined() expect(() => ApiCustomActionSchema.parse(out)).not.toThrow() }) }) describe('lib/page-spec-actions — payloadParametersToShape', () => { it('maps each param type to its TS type (number→number, file→File, rest→string)', () => { expect(payloadParametersToShape([ { name: 'a', type: 'text' }, { name: 'b', type: 'textarea' }, { name: 'c', type: 'select' }, { name: 'd', type: 'date' }, { name: 'e', type: 'lookup' }, { name: 'f', type: 'number' }, { name: 'g', type: 'file' }, ])).toEqual({ a: 'string', b: 'string', c: 'string', d: 'string', e: 'string', f: 'number', g: 'File' }) }) }) describe('lib/page-spec-actions — splitActions', () => { it('drops CRUD, separates navigate, de-duplicates api by (scope,endpoint,httpMethod)', () => { const actions = [ PageCustomActionSchema.parse({ code: 'create', kind: 'api', scope: 'header', labelKey: 'list.create', permission: 'budgets.budgets.create', }), apiAction({ scope: 'header' }), // syncFromPce header POST apiAction({ scope: 'header' }), // duplicate (same scope/endpoint/verb) → deduped apiAction({ code: 'archive', endpoint: 'archive', scope: 'row', httpMethod: 'POST' }), navAction(), // open (navigate) ] const { apiActions, navigateActions } = splitActions(actions) expect(apiActions.map(a => a.endpoint)).toEqual(['sync-from-proconcept', 'archive']) expect(navigateActions.map(a => a.code)).toEqual(['open']) }) it('keeps same endpoint on different scope as distinct routes', () => { const actions = [ apiAction({ code: 'export', endpoint: 'export', scope: 'header' }), apiAction({ code: 'export', endpoint: 'export', scope: 'bulk' }), ] const { apiActions } = splitActions(actions) expect(apiActions).toHaveLength(2) }) }) describe('lib/page-spec-actions — payload DTO synthesis (payloadParameters without payloadDto)', () => { // The client-run drift: the pagespec declared payloadParameters only, the // api-client got a payloadShape while controller.payloadDto stayed null — // the dialog collected values the backend had nowhere to receive. With the // entity name supplied, ONE synthesized DTO name comes out of all three // projections, so the two halves of the wire cannot disagree. const collecting = () => apiAction({ code: 'deactivate', endpoint: undefined, scope: 'row', labelKey: 'list.actions.deactivate', permission: 'referentiels.types-vehicule.update', payloadParameters: [ { name: 'reason', type: 'textarea', required: true }, { name: 'targetValueId', type: 'lookup', required: false }, ], }) it('synthesizedPayloadDtoName pascalizes the code and appends the entity + Dto', () => { expect(synthesizedPayloadDtoName('deactivate', 'VehicleType')).toBe('DeactivateVehicleTypeDto') expect(synthesizedPayloadDtoName('bulk-archive', 'AlertRule')).toBe('BulkArchiveAlertRuleDto') }) it('controller + business + apiClient all carry the SAME synthesized name', () => { const ctrl = toControllerCustomAction(collecting(), 'VehicleType') const biz = toBusinessCustomAction(collecting(), 'VehicleType') const api = toApiClientCustomAction(collecting(), 'VehicleType') expect(ctrl.payloadDto).toBe('DeactivateVehicleTypeDto') expect(biz.payloadDto).toBe('DeactivateVehicleTypeDto') expect(api.payloadType).toBe('DeactivateVehicleTypeDto') expect(api.payloadShape).toEqual({ reason: 'string', targetValueId: 'string' }) }) it('business projection carries the payloadFields the DTO record is emitted from', () => { const biz = toBusinessCustomAction(collecting(), 'VehicleType') expect(biz.payloadFields).toEqual([ { name: 'reason', type: 'textarea', required: true }, { name: 'targetValueId', type: 'lookup', required: false }, ]) expect(() => BusinessCustomActionSchema.parse(biz)).not.toThrow() expect(() => ControllerCustomActionSchema.parse(toControllerCustomAction(collecting(), 'VehicleType'))).not.toThrow() expect(() => ApiCustomActionSchema.parse(toApiClientCustomAction(collecting(), 'VehicleType'))).not.toThrow() }) it('an authored payloadDto wins over the synthesis', () => { const authored = () => apiAction({ ...collecting(), payloadDto: 'CustomDeactivationRequest' }) expect(toControllerCustomAction(authored(), 'VehicleType').payloadDto).toBe('CustomDeactivationRequest') expect(toApiClientCustomAction(authored(), 'VehicleType').payloadType).toBe('CustomDeactivationRequest') }) it('without payloadParameters nothing is synthesized (payloadDto stays null)', () => { const bare = apiAction({ code: 'archive', endpoint: undefined, payloadParameters: undefined }) expect(toControllerCustomAction(bare, 'VehicleType').payloadDto).toBeNull() expect(toBusinessCustomAction(bare, 'VehicleType').payloadFields).toBeUndefined() expect(toApiClientCustomAction(bare, 'VehicleType').payloadType).toBeNull() }) it('legacy callers that omit the entity name keep the old null behaviour', () => { expect(toControllerCustomAction(collecting()).payloadDto).toBeNull() expect(toApiClientCustomAction(collecting()).payloadType).toBeNull() }) it('a required payload member flags payloadHasRequired on the controller projection (mandatory [FromBody])', () => { // Without the flag the controller emitted `dto ?? new()` on a record whose // required positional member has no default → CS7036 (client defect // 2026-08-25 #3). expect(toControllerCustomAction(collecting(), 'VehicleType').payloadHasRequired).toBe(true) const allOptional = apiAction({ code: 'suspend', endpoint: undefined, scope: 'row', payloadParameters: [ { name: 'reason', type: 'textarea', required: false }, { name: 'suspendedUntil', type: 'date', required: false }, ], }) expect(toControllerCustomAction(allOptional, 'AlertRule').payloadHasRequired).toBeUndefined() }) }) describe('lib/page-spec-actions — GET transport of payloadParameters (client defect 2026-08-25 #4)', () => { // A GET carries no body: the old projections left payloadDto in the business // signature the controller never passed (CS1503), and the parameters had no // transport at all. Now all three projections agree: payloadDto/payloadShape // nulled, the parameters ride the query string ([FromQuery] on the server). const getCompute = () => apiAction({ code: 'impact', endpoint: undefined, scope: 'row', httpMethod: 'GET', responseDto: 'ImpactDto', payloadParameters: [{ name: 'year', type: 'number', required: false }], }) it('controller projection: payloadDto null + queryParameters carried', () => { const ctrl = toControllerCustomAction(getCompute(), 'VatRate') expect(ctrl.payloadDto).toBeNull() expect(ctrl.payloadHasRequired).toBeUndefined() expect(ctrl.queryParameters).toEqual([{ name: 'year', type: 'number' }]) }) it('business projection: payloadDto/payloadFields null + the SAME queryParameters', () => { const biz = toBusinessCustomAction(getCompute(), 'VatRate') expect(biz.payloadDto).toBeNull() expect(biz.payloadFields).toBeUndefined() expect(biz.queryParameters).toEqual([{ name: 'year', type: 'number' }]) }) it('apiClient projection: payloadType/payloadShape null + queryShape carried', () => { const api = toApiClientCustomAction(getCompute(), 'VatRate') expect(api.payloadType).toBeNull() expect(api.payloadShape).toBeUndefined() expect(api.queryShape).toEqual({ year: 'number' }) }) it('non-GET verbs carry NO queryParameters/queryShape (the body is the transport)', () => { const post = apiAction({ code: 'deactivate', endpoint: undefined, scope: 'row', httpMethod: 'POST', payloadParameters: [{ name: 'reason', type: 'textarea', required: true }], }) expect(toControllerCustomAction(post, 'VehicleType').queryParameters).toBeUndefined() expect(toBusinessCustomAction(post, 'VehicleType').queryParameters).toBeUndefined() expect(toApiClientCustomAction(post, 'VehicleType').queryShape).toBeUndefined() }) })