import { describe, it, expect } from 'vitest' import { PageCustomActionSchema, toControllerCustomAction, toApiClientCustomAction, toBusinessCustomAction, type PageCustomAction, } from '../page-spec-actions.js' import { BusinessCustomActionSchema } from '../../development/backend/business-layer/cli/scaffold-business/types.js' import { generate as generateController } from '../../development/backend/controller/cli/scaffold-controller/generate.js' import { generate as generateApiClient } from '../../development/frontend/api-client/cli/scaffold-api-client/generate.js' import { generate as generateComponent } from '../../development/frontend/component/cli/scaffold-component/generate.js' import { generate as generateBusiness } from '../../development/backend/business-layer/cli/scaffold-business/generate.js' /** * AC4 — contract integration test for custom "api" actions with a payloadDto. * * One canonical `PageCustomAction` is projected through `lib/page-spec-actions` * into the three generator inputs, then each generator is RUN, and the emitted * trio (controller ⊕ api-client ⊕ component) is asserted to be coherent — the * body flows end-to-end (Case A) OR is optional end-to-end (Case B), and the * build stays green WITHOUT anyone hand-nulling `payloadType` (the 415 hack). */ function controllerInput(action: PageCustomAction) { return { name: 'JourFerie', pluralName: 'JoursFeries', module: 'configuration', section: 'jours-feries', appCode: 'TestV2', applicationCode: 'crm', namespace: 'TestV2', navRoute: 'configuration.jours-feries', permissionPrefix: 'crm.configuration.jours-feries', actions: ['read', 'create', 'update', 'delete'], customActions: [toControllerCustomAction(action)], fields: [{ name: 'code', type: 'string', required: true }], projectPath: '/tmp/project', } as never } function apiClientInput(action: PageCustomAction) { return { module: 'configuration', appCode: 'TestV2', entities: [{ name: 'JourFerie', section: 'jours-feries', hasDashboard: false, fields: [{ name: 'Code', type: 'string', required: true }], customActions: [toApiClientCustomAction(action)], }], projectPath: '/test', routeMode: 'integration', } as never } function componentInput(action: PageCustomAction) { return { module: 'configuration', appCode: 'TestV2', entity: 'JourFerie', section: 'jours-feries', views: ['list'], fields: [{ name: 'code', type: 'string', required: true }], projectPath: '/web', pageSpec: { screenCode: 'SCR-JOURFERIE-LIST', module: 'configuration', appCode: 'TestV2', section: 'jours-feries', entity: 'JourFerie', view: 'list', filePath: 'src/pages/configuration/jours-feries/JoursFeriesListPage.tsx', permission: 'configuration.jours-feries.read', actions: [action], i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, }, } as never } const BASE = { // code kebabs to the endpoint (generateLegal → generate-legal) so the hook name // is identical on both sides — keeps this test about the body contract, not the // orthogonal code≠endpoint hook-naming divergence. code: 'generateLegal', kind: 'api' as const, scope: 'header' as const, endpoint: 'generate-legal', httpMethod: 'POST' as const, labelKey: 'list.actions.generateLegal', permission: 'configuration.jours-feries.execute', ucReference: 'UC-CONF-JF-001', } const joined = (files: Array<{ content: string }>) => files.map(f => f.content).join('\n\n') describe('custom-action contract — header POST WITH payloadParameters (collectible body)', () => { const action = PageCustomActionSchema.parse({ ...BASE, payloadDto: 'GenerateLegalHolidaysRequest', responseDto: 'GenerateLegalHolidaysResult', payloadParameters: [{ name: 'year', type: 'number', required: true }], }) it('controller binds a MANDATORY [FromBody] — the payload has a required member (2026-08-25 #3)', () => { // `year` is required: an empty body could never satisfy it, and the // synthesized record has no parameterless ctor for `dto ?? new()` (CS7036). // Model binding 400s on a missing body instead. const c = joined(generateController(controllerInput(action))) expect(c).toMatch(/public async Task> GenerateLegal\(\[FromBody\] GenerateLegalHolidaysRequest dto/) expect(c).toMatch(/_service\.GenerateLegalAsync\(dto, ct\)/) expect(c).not.toMatch(/GenerateLegalAsync\(dto \?\? new\(\)/) }) it('api-client emits a TYPED interface (not the placeholder) + sends the payload + hook takes (payload)', () => { const a = joined(generateApiClient(apiClientInput(action))) // Typed interface from payloadShape — NOT the permissive placeholder. expect(a).toMatch(/export interface GenerateLegalHolidaysRequest \{[\s\S]*?year: number;/) expect(a).not.toMatch(/GenerateLegalHolidaysRequest \{\s*\[key: string\]: unknown/) // Service member sends `payload` as the body. expect(a).toMatch(/generateLegal: async \(payload: GenerateLegalHolidaysRequest\)/) expect(a).toMatch(/`\$\{API_PATH\}\/generate-legal`, payload/) // Hook takes (payload) — arity 1. expect(a).toMatch(/const mutateAsync = async \(payload: GenerateLegalHolidaysRequest\)/) }) it('component renders a dialog and fires mutateAsync(payload)', () => { const p = joined(generateComponent(componentInput(action))) expect(p).toMatch(/import \{ CustomActionDialog \} from '@\/components\/ui\/CustomActionDialog'/) expect(p).toMatch(/const \[generateLegalDialogOpen, setGenerateLegalDialogOpen\] = useState\(false\)/) expect(p).toMatch(/const handleGenerateLegal = \(\) => \{ setGenerateLegalDialogOpen\(true\) \}/) expect(p).toMatch(/generateLegalMutation\.mutateAsync\(payload as never\)/) }) }) describe('custom-action contract — header POST WITHOUT payloadParameters (optional body)', () => { const action = PageCustomActionSchema.parse({ ...BASE, payloadDto: 'GenerateLegalHolidaysRequest', // no payloadParameters, no responseDto → the frontend collects/sends nothing }) it('controller still tolerates an empty body (EmptyBodyBehavior.Allow + nullable dto)', () => { const c = joined(generateController(controllerInput(action))) expect(c).toMatch(/public async Task GenerateLegal\(\[FromBody\(EmptyBodyBehavior = EmptyBodyBehavior\.Allow\)\] GenerateLegalHolidaysRequest\? dto = null/) expect(c).toMatch(/_service\.GenerateLegalAsync\(dto \?\? new\(\), ct\)/) }) it('api-client sends NO body and the hook takes no argument — deterministically, no payloadType:null hack', () => { // Projection drops payloadType because there are no collectible fields. expect(toApiClientCustomAction(action).payloadType).toBeNull() const a = joined(generateApiClient(apiClientInput(action))) // Bodyless POST — no second argument. expect(a).toMatch(/`\$\{API_PATH\}\/generate-legal`\)/) expect(a).not.toMatch(/generate-legal`, payload/) // Hook takes no argument — arity 0 (matches the page's mutateAsync()). expect(a).toMatch(/const mutateAsync = async \(\): Promise/) }) it('component renders a plain button that fires mutateAsync() — arity matches the hook', () => { const p = joined(generateComponent(componentInput(action))) expect(p).not.toMatch(/CustomActionDialog/) expect(p).toMatch(/const handleGenerateLegal = async \(\) => \{[\s\S]*?generateLegalMutation\.mutateAsync\(\)/) }) }) describe('cross-CLI plural parity (client defect 2026-08-25 #1)', () => { // scaffold-business used the naive `e + 's'` fallback while scaffold-controller // used the shared `pluralize()` — the controller consumed a Get{Plural}Query // record the business layer never declared (CS0246 on any irregular plural). // Both generators must derive the SAME record name from the SAME entity name. it('controller consumes exactly the Get{Plural}Query record business declares (no pluralName, irregular plural)', () => { const shared = { name: 'DrivingLicenceCategory', module: 'configuration', section: 'categories-permis', appCode: 'TestV2', applicationCode: 'crm', namespace: 'TestV2', fields: [{ name: 'Code', type: 'string', required: true, isKey: false }], } const business = joined(generateBusiness({ ...shared, fields: [{ name: 'Code', type: 'string', required: true, isKey: false }], businessRules: [], customActions: [], projectPath: '/tmp/project', } as never)) const controller = joined(generateController({ ...shared, navRoute: 'configuration.categories-permis', permissionPrefix: 'crm.configuration.categories-permis', actions: ['read', 'create', 'update', 'delete'], customActions: [], fields: [{ name: 'code', type: 'string', required: true }], projectPath: '/tmp/project', } as never)) // The exact query record the controller instantiates… const consumed = controller.match(/new (Get\w+Query)\(/)?.[1] expect(consumed).toBe('GetDrivingLicenceCategoriesQuery') // …is declared by scaffold-business under the SAME name. expect(business).toContain(`public record ${consumed}(`) expect(business).not.toContain('GetDrivingLicenceCategorysQuery') }) }) describe('custom-action contract — action↔field binding (payloadParameters[].field → fieldAssignments)', () => { const MARK_PAID = PageCustomActionSchema.parse({ code: 'markPaid', kind: 'api', scope: 'row', endpoint: 'mark-paid', httpMethod: 'POST', labelKey: 'list.actions.markPaid', permission: 'billing.invoices.update', ucReference: 'UC-BILL-001', payloadDto: 'MarkPaidRequest', payloadParameters: [ // Bound param: wire name ≠ entity attribute — the binding is explicit. { name: 'datePaiement', type: 'date', required: true, field: 'paymentDate' }, // Unbound param: stays on the legacy actionParams.* label branch. { name: 'commentaire', type: 'textarea' }, ], workflowTransition: { fromStatus: ['SENT'], toStatus: 'PAID', flowParameters: ['datePaiement'] }, }) function businessInput(action: PageCustomAction) { return { name: 'Invoice', pluralName: 'Invoices', module: 'billing', section: 'invoices', appCode: 'TestV2', applicationCode: 'crm', namespace: 'TestV2', fields: [ { name: 'Label', type: 'string', required: true, isKey: false }, { name: 'PaymentDate', type: 'date', required: false, isKey: false }, ], businessRules: [], customActions: [toBusinessCustomAction(action)], projectPath: '/tmp/project', } as never } it('projects field → fieldAssignments and round-trips through the REAL scaffold-business schema', () => { const projected = toBusinessCustomAction(MARK_PAID) expect(projected.fieldAssignments).toEqual([{ param: 'datePaiement', field: 'paymentDate' }]) // Drift lock: the projection must parse against scaffold-business's own Zod. expect(() => BusinessCustomActionSchema.parse(projected)).not.toThrow() }) it('scaffold-business assigns the BOUND entity attribute and dedupes the covered flowParameter', () => { const b = joined(generateBusiness(businessInput(MARK_PAID))) expect(b).toContain('entity.PaymentDate = payload.DatePaiement;') // The legacy identity assignment for the SAME param must not double-fire. expect(b).not.toContain('entity.DatePaiement = payload.DatePaiement;') expect(b).toContain('entity.Status = "PAID";') }) it('legacy flowParameters WITHOUT field keep the identity-mapped assignment verbatim', () => { const legacy = PageCustomActionSchema.parse({ ...(MARK_PAID as object), payloadParameters: [{ name: 'datePaiement', type: 'date', required: true }], } as never) expect(toBusinessCustomAction(legacy).fieldAssignments).toBeUndefined() const b = joined(generateBusiness(businessInput(legacy))) expect(b).toContain('entity.DatePaiement = payload.DatePaiement;') }) it('the dialog label of a bound param reuses the SHARED form.fields. key', () => { const p = joined(generateComponent(componentInput(MARK_PAID))) expect(p).toContain("label: t('jourFerie.form.fields.paymentDate', { defaultValue: 'datePaiement' })") // The unbound param keeps its actionParams.* sibling branch. expect(p).toContain("label: t('jourFerie.list.actionParams.markPaid.commentaire', { defaultValue: 'commentaire' })") }) })