/** * url-parity.test.ts — Locks the frontend api-client URL ↔ backend scaffolder URL * agreement at every legal input. * * The contract (post NavRoute alignment, 2026-06-30): * integration → both sides resolve to /api/{module}/{section} from the SAME * navRoute. The api-client calls buildNavApiPath(entity.navRoute); * the controller carries [NavRoute(navRoute)] (and NO [Route]) which * the platform's NavigationRouteModelProvider rewrites to that exact * path (discarding any [Route]). The `/api/v1/integration/{plural}` * literal is dead — the platform rewrote it away → 404. * screens → both sides hit /api/screens/{plural-kebab}. * No third URL pattern is legal — `apiBasePath` + `routeMode:'direct'` were removed. */ import { describe, it, expect } from 'vitest' import { generate as generateApiClient } from '../generate.js' import { ScaffoldApiClientInputSchema, type ScaffoldApiClientInput } from '../types.js' import { generate as generateController } from '../../../../../backend/controller/cli/scaffold-controller/generate.js' import type { ScaffoldControllerInput } from '../../../../../backend/controller/cli/scaffold-controller/types.js' import { generate as generateScreenController } from '../../../../../backend/screen-controller/cli/scaffold-screen-controller/generate.js' import type { PageSpec, ScaffoldScreenControllerSpec } from '../../../../../backend/screen-controller/cli/scaffold-screen-controller/types.js' import { buildNavApiPath } from '../../../../../../lib/url-conventions.js' function apiClientFixture(overrides: Partial = {}): ScaffoldApiClientInput { return { module: 'affaires', appCode: 'gaf', entities: [ { name: 'Demande', pluralName: 'Demandes', section: 'demandes', hasDashboard: false, fields: [{ name: 'code', type: 'string', required: true }], }, ], projectPath: '/web', ...overrides, } as ScaffoldApiClientInput } function controllerFixture(overrides: Partial = {}): ScaffoldControllerInput { return { name: 'Demande', pluralName: 'Demandes', module: 'affaires', section: 'demandes', appCode: 'gaf', applicationCode: 'gaf', namespace: 'Gaf', navRoute: 'affaires.demandes', permissionPrefix: 'gaf.affaires.demandes', actions: ['read', 'create', 'update', 'delete'], customActions: [], fields: [{ name: 'code', type: 'string', required: true }], projectPath: '/api', ...overrides, } as ScaffoldControllerInput } function screenSpec(overrides: Partial = {}): ScaffoldScreenControllerSpec { return { section: 'demandes', entity: 'Demande', module: 'affaires', appCode: 'gaf', namespace: 'Gaf', moduleDir: '/ba/affaires', projectPath: '/api', ...overrides, } as ScaffoldScreenControllerSpec } function listPagespec(overrides: Partial = {}): PageSpec { return { screenCode: 'SCR-AFFAIRES-DEMANDES-LIST', appCode: 'gaf', module: 'affaires', section: 'demandes', entity: 'Demande', view: 'list', permission: 'affaires.demandes.read', linkedUseCases: [], linkedBusinessRules: [], columns: [], actions: [], ...overrides, } as PageSpec } describe('URL parity — frontend api-client ↔ backend controllers', () => { it('integration → both sides resolve to /api/{module}/{section} from the same navRoute', () => { const resolved = buildNavApiPath('affaires.demandes') // → /api/affaires/demandes const fe = generateApiClient(apiClientFixture({ routeMode: 'integration' })) const feService = fe.find((f) => f.path.endsWith('demandeService.ts'))! expect(feService.content).toContain(`const API_PATH = '${resolved}';`) const be = generateController(controllerFixture()) const beCtrl = be.find((f) => f.path.endsWith('DemandesController.cs'))! // The controller carries [NavRoute] (which the platform resolves to `resolved`) // and NO [Route] — so front == back by construction. expect(beCtrl.content).toMatch(/\[NavRoute\("affaires\.demandes"\)\]/) expect(beCtrl.content).not.toMatch(/\[Route\(/) }) it('DEFAULT (no routeMode, no useScreens) → both sides on the navRoute path — regression lock', () => { // The bug this locks: the api-client emitted the `/api/v1/integration/{plural}` // literal, which the platform rewrote away (every integration controller is served // at /api/{module}/{section} from [NavRoute]) → a 404 on EVERY data call. The // api-client now derives API_PATH from the entity's navRoute (default // `${module}.${section}`), matching the backend. const resolved = buildNavApiPath('affaires.demandes') const fe = generateApiClient(apiClientFixture()) // no routeMode, no useScreens const feService = fe.find((f) => f.path.endsWith('demandeService.ts'))! expect(feService.content).toContain(`const API_PATH = '${resolved}';`) // Neither the legacy direct shape (/api/{appCode}/...) nor the dead integration // literal must appear. expect(feService.content).not.toMatch(/\/api\/gaf\/affaires\/demandes/) expect(feService.content).not.toMatch(/\/api\/v1\/integration\//) const be = generateController(controllerFixture()) const beCtrl = be.find((f) => f.path.endsWith('DemandesController.cs'))! expect(beCtrl.content).toMatch(/\[NavRoute\("affaires\.demandes"\)\]/) expect(beCtrl.content).not.toMatch(/\[Route\(/) }) it('useScreens=true → both sides on /api/screens/{plural}; lookup/delete fall back to the navRoute path', () => { const fe = generateApiClient(apiClientFixture({ useScreens: true })) const feService = fe.find((f) => f.path.endsWith('demandeService.ts'))! expect(feService.content).toMatch(/const API_PATH = '\/api\/screens\/demandes';/) // Lookup + delete (omitted from the screen contract) fall back to the integration // controller's NavRoute-resolved path — NOT the dead /api/v1/integration literal. expect(feService.content).toContain(`const INTEGRATION_PATH = '${buildNavApiPath('affaires.demandes')}';`) expect(feService.content).not.toMatch(/\/api\/v1\/integration\//) const be = generateScreenController(screenSpec(), [listPagespec()]) const beCtrl = be.files.find((f) => f.path.endsWith('DemandesScreenController.cs'))! expect(beCtrl.content).toMatch(/\[Route\("api\/screens\/demandes"\)\]/) }) it('multi-word entities: integration via navRoute, screens via the SAME kebab plural', () => { // The integration URL is the navRoute path (no plural) — align the api-client // entity's navRoute and the controller's navRoute so both resolve identically. const fe = generateApiClient(apiClientFixture({ routeMode: 'integration', entities: [{ name: 'OrderLine', pluralName: 'OrderLines', section: 'order-lines', navRoute: 'affaires.order-lines', hasDashboard: false, fields: [{ name: 'qty', type: 'int', required: true }], }], })) const feService = fe.find((f) => f.path.endsWith('orderLineService.ts'))! expect(feService.content).toContain(`const API_PATH = '${buildNavApiPath('affaires.order-lines')}';`) const be = generateController(controllerFixture({ name: 'OrderLine', pluralName: 'OrderLines', section: 'order-lines', navRoute: 'affaires.order-lines', })) const beCtrl = be.find((f) => f.path.endsWith('OrderLinesController.cs'))! expect(beCtrl.content).toMatch(/\[NavRoute\("affaires\.order-lines"\)\]/) expect(beCtrl.content).not.toMatch(/\[Route\(/) // The screen stratum DOES carry the kebab plural — front == back there. const bes = generateScreenController( screenSpec({ entity: 'OrderLine' }), [listPagespec({ entity: 'OrderLine' })], ) const besCtrl = bes.files.find((f) => f.path.endsWith('OrderLinesScreenController.cs'))! expect(besCtrl.content).toMatch(/\[Route\("api\/screens\/order-lines"\)\]/) }) it('dashboard URL on the frontend points where scaffold-screen-controller emits it', () => { // The original 404 — /api/affaires/accueil/dashboard — happened because // the api-client's `apiBasePath` template injected `{section}` into the // path while the screen controller emits /api/screens/{plural}/dashboard/*. // With apiBasePath gone, the dashboard URL becomes the canonical one. const fe = generateApiClient( apiClientFixture({ useScreens: true, entities: [{ name: 'Demande', pluralName: 'Demandes', section: 'accueil', hasDashboard: true, fields: [], }], }), ) const feService = fe.find((f) => f.path.endsWith('demandeService.ts'))! // The OLD bug — apiBasePath injected {section} → /api/affaires/accueil/dashboard (404). // That exact URL must never appear. (The section may legitimately be the integration // fallback path segment now via navRoute, but the DASHBOARD lives on the screen stratum.) expect(feService.content).not.toMatch(/\/api\/affaires\/accueil\/dashboard/) // The dashboard ALWAYS targets the screen stratum (the only widget-aware backend), // emitted as a literal screen route so it is correct even in integration mode. expect(feService.content).toMatch(/\/api\/screens\/demandes\/dashboard`/) expect(feService.content).not.toMatch(/dashboard\/consolidated/) const be = generateScreenController( screenSpec(), [listPagespec({ view: 'dashboard', screenCode: 'SCR-AFFAIRES-DEMANDES-DASH' })], ) const beCtrl = be.files.find((f) => f.path.endsWith('DemandesScreenController.cs'))! expect(beCtrl.content).toMatch(/\[HttpGet\("dashboard"\)\]/) }) }) describe('URL parity — pluralization SSOT on the screen stratum (the URL segment)', () => { // The integration URL is navRoute-based (no plural), so a bad plural can't break it. // The plural IS the URL segment on the SCREEN stratum, so the SSOT (no "effectifss" // double-s; consecutive-uppercase kebab) is locked there — both sides derive the // segment from the SAME `pluralize`/`pluralSegment` helpers in lib/url-conventions. it('entity ending in "s" with no pluralName → SAME screen segment both sides, never "effectifss"', () => { const fe = generateApiClient(apiClientFixture({ useScreens: true, entities: [{ name: 'Effectifs', section: 'rh', hasDashboard: false, fields: [{ name: 'count', type: 'int', required: true }] }], })) const feService = fe.find((f) => f.content.includes('const API_PATH'))! expect(feService.content).not.toMatch(/effectifss/) expect(feService.content).toMatch(/\/api\/screens\/effectifses/) const be = generateScreenController(screenSpec({ entity: 'Effectifs', section: 'rh' }), [listPagespec({ entity: 'Effectifs', section: 'rh' })]) const beCtrl = be.files.find((f) => f.content.includes('[Route("api/screens'))! expect(beCtrl.content).not.toMatch(/effectifss/) expect(beCtrl.content).toMatch(/\[Route\("api\/screens\/effectifses"\)\]/) }) it('consecutive-uppercase entity (HRDepartment) → identical kebab on both screen sides', () => { // Regression: the api-client had a LOCAL toKebab() that did NOT split consecutive // uppercase, so "HRDepartments" → "hrdepartments" on the front while the backend // (pluralSegment/toKebabCase) emitted "hr-departments". Both use the shared helper now. const fe = generateApiClient(apiClientFixture({ useScreens: true, entities: [{ name: 'HRDepartment', pluralName: 'HRDepartments', section: 'rh', hasDashboard: false, fields: [{ name: 'code', type: 'string', required: true }] }], })) const feService = fe.find((f) => f.content.includes('const API_PATH'))! expect(feService.content).toMatch(/\/api\/screens\/hr-departments/) const be = generateScreenController(screenSpec({ entity: 'HRDepartment', section: 'rh' }), [listPagespec({ entity: 'HRDepartment', section: 'rh' })]) const beCtrl = be.files.find((f) => f.content.includes('[Route("api/screens'))! expect(beCtrl.content).toMatch(/\[Route\("api\/screens\/hr-departments"\)\]/) }) }) describe('URL parity — row custom action carries {id} on every emitter', () => { const rowAction = { code: 'archive', endpoint: 'archive', scope: 'row' as const } it('integration: api-client service URL `${id}/archive` == controller `[HttpPost("{id:guid}/archive")]`', () => { const fe = generateApiClient(apiClientFixture({ routeMode: 'integration', entities: [{ name: 'Demande', pluralName: 'Demandes', section: 'demandes', hasDashboard: false, fields: [{ name: 'code', type: 'string', required: true }], customActions: [{ ...rowAction, kind: 'api', httpMethod: 'post', payloadType: null, responseType: 'void' }], }], })) const feService = fe.find((f) => f.content.includes('const API_PATH'))! expect(feService.content).toMatch(/\$\{id\}\/archive/) const be = generateController(controllerFixture({ customActions: [{ ...rowAction, httpMethod: 'POST', payloadDto: null, responseDto: 'NoContent', permissionAction: 'update' }], })) const beCtrl = be.find((f) => f.path.endsWith('DemandesController.cs'))! expect(beCtrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/) }) it('screens: screen controller row action == `{id:guid}/archive` (no detail/ prefix — matches the frontend URL)', () => { const be = generateScreenController(screenSpec(), [listPagespec({ actions: [{ code: 'archive', scope: 'row', kind: 'api', httpMethod: 'POST', permission: 'affaires.demandes.update' } as PageSpec['actions'][number]], })]) const beCtrl = be.files.find((f) => f.content.includes('[Route('))! expect(beCtrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/) expect(beCtrl.content).not.toMatch(/detail\/\{id:guid\}\/archive/) }) }) describe('apiBasePath removal — Zod schema rejects orphaned URL templates', () => { it('rejects a spec carrying the deprecated apiBasePath field', () => { const result = ScaffoldApiClientInputSchema.safeParse({ ...apiClientFixture(), // The historical free-form template that produced /api/{appCode}/{module}/{section}/... // is no longer accepted: the schema is .strict() so unknown keys fail. apiBasePath: '/api/{appCode}/{module}/{section}', }) expect(result.success).toBe(false) if (!result.success) { const message = JSON.stringify(result.error.issues) expect(message.toLowerCase()).toMatch(/apibasepath|unrecognized/) } }) it('accepts a spec WITHOUT apiBasePath (the new default)', () => { const result = ScaffoldApiClientInputSchema.safeParse(apiClientFixture()) expect(result.success).toBe(true) }) it('rejects the removed routeMode: "direct" — two canonical strata only', () => { // `direct` was removed on 2026-06-25: it emitted /api/{app}/{module}/{section}, // a shape no backend scaffolder served. The enum now accepts only the two // strata that a controller actually exposes. const result = ScaffoldApiClientInputSchema.safeParse({ ...apiClientFixture(), routeMode: 'direct', }) expect(result.success).toBe(false) }) })