import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { audit, loadServiceCalls } from '../audit.js' import type { ActionAlignmentArgs } from '../types.js' // ─── Fixtures ──────────────────────────────────────────────────────────── const PAGESPEC = ` \`\`\`json { "screenCode": "SCR-APP-CAT-TYPEAFFAIRE-001", "appCode": "app", "module": "referentiels", "section": "types-affaire", "entity": "TypeAffaire", "view": "list", "permission": "referentiels.types-affaire.read", "actions": [ { "code": "create", "scope": "header", "labelKey": "list.create", "permission": "referentiels.types-affaire.create", "variant": "primary" }, { "code": "syncFromPce", "kind": "api", "scope": "header", "endpoint": "sync-from-proconcept", "httpMethod": "POST", "labelKey": "list.actions.syncFromPce", "permission": "referentiels.types-affaire.execute", "ucReference": "UC-APP-CAT-TYPEAFFAIRE-007" }, { "code": "analyzeImpact", "kind": "api", "scope": "header", "endpoint": "impact", "httpMethod": "GET", "labelKey": "list.actions.analyzeImpact", "permission": "referentiels.types-affaire.read", "ucReference": "UC-APP-CAT-TYPEAFFAIRE-009" }, { "code": "open", "kind": "navigate", "scope": "row", "targetScreen": "SCR-APP-CAT-TYPEAFFAIRE-002", "targetRoute": "routes.referentiels.detail(item.id)", "labelKey": "list.actions.open", "permission": "referentiels.types-affaire.read" } ] } \`\`\` Some human prose follows. ` const CONTROLLER_CS = `using Microsoft.AspNetCore.Mvc; namespace TestApp.Api.Controllers.Catalog; [ApiController] [Route("api/referentiels/types-affaire")] public class TypesAffaireController : ControllerBase { private readonly ITypeAffaireService _service; public TypesAffaireController(ITypeAffaireService service) => _service = service; [HttpGet] public async Task GetAll() { return Ok(); } [HttpGet("{id:guid}")] public async Task GetById(Guid id) { return Ok(); } [HttpPost("sync-from-proconcept")] public async Task SyncFromProconcept(CancellationToken ct) { return NoContent(); } [HttpGet("impact")] public async Task Impact() { return Ok(); } } ` /** Legacy service.ts: calls /sync-from-pce instead of the pagespec's /sync-from-proconcept. */ const SERVICE_TS_DRIFT = `import { apiClient } from '@/services/apiClient' const API_PATH = '/api/referentiels/types-affaire' export const typeAffaireService = { getAll: async () => apiClient.get(\`\${API_PATH}\`), syncFromPce: async () => { await apiClient.post(\`\${API_PATH}/sync-from-pce\`) }, analyzeImpact: async () => { await apiClient.post(\`\${API_PATH}/analyze-impact\`) }, } ` /** Aligned service.ts: matches the pagespec verbatim. */ const SERVICE_TS_ALIGNED = `import { apiClient } from '@/services/apiClient' const API_PATH = '/api/referentiels/types-affaire' export const typeAffaireService = { getAll: async () => apiClient.get(\`\${API_PATH}\`), syncFromProconcept: async () => { await apiClient.post(\`\${API_PATH}/sync-from-proconcept\`) }, impact: async () => { await apiClient.get(\`\${API_PATH}/impact\`) }, } ` /** Page wiring BOTH api-action buttons via their hooks (the implemented state). */ const PAGE_TSX_WIRED = `import { useSyncFromPceTypeAffaire, useAnalyzeImpactTypeAffaire } from '@/features/referentiels/typeAffaire/hooks/useTypeAffaire' export function TypeAffaireListPage() { const sync = useSyncFromPceTypeAffaire() const impact = useAnalyzeImpactTypeAffaire() return (
) } ` // ─── Test scaffolding ──────────────────────────────────────────────────── let projectDir: string let backendDir: string let moduleDir: string function writeFixtures(serviceContent: string, pageContent: string = PAGE_TSX_WIRED) { // Frontend — service const servicesDir = path.join(projectDir, 'src/features/referentiels/typeAffaire/services') mkdirSync(servicesDir, { recursive: true }) writeFileSync(path.join(servicesDir, 'typeAffaireService.ts'), serviceContent) // Frontend — page component (the button/hook wiring source) const pagesDir = path.join(projectDir, 'src/pages/app/referentiels/types-affaire') mkdirSync(pagesDir, { recursive: true }) writeFileSync(path.join(pagesDir, 'TypeAffaireListPage.tsx'), pageContent) // Backend const controllersDir = path.join(backendDir, 'src/TestApp.Api/Controllers/Catalog') mkdirSync(controllersDir, { recursive: true }) writeFileSync(path.join(controllersDir, 'TypesAffaireController.cs'), CONTROLLER_CS) // Pagespec const pagespecsDir = path.join(moduleDir, 'pagespecs') mkdirSync(pagespecsDir, { recursive: true }) writeFileSync(path.join(pagespecsDir, 'TypeAffaire.list.md'), PAGESPEC) } function makeArgs(): ActionAlignmentArgs { return { projectPath: projectDir, backendPath: backendDir, modulePath: moduleDir, mode: 'report-only', sourceOfTruth: 'pagespec', writeReport: false, } } beforeAll(() => { const root = mkdtempSync(path.join(tmpdir(), 'ssaction-')) projectDir = path.join(root, 'project') backendDir = path.join(root, 'backend') moduleDir = path.join(root, 'ba/APP/CATALOG') mkdirSync(projectDir, { recursive: true }) mkdirSync(backendDir, { recursive: true }) mkdirSync(moduleDir, { recursive: true }) }) afterAll(() => { // Clean up — root is two levels up from projectDir const root = path.dirname(projectDir) rmSync(root, { recursive: true, force: true }) }) // ─── Tests ─────────────────────────────────────────────────────────────── describe('audit-dev-actions-alignment / endpoint drift detection', () => { it('detects pagespec ↔ service drift (ACTION-DRIFT-002, the canonical 405 bug)', async () => { writeFixtures(SERVICE_TS_DRIFT) const report = await audit(makeArgs()) // We should find at least the drift for syncFromPce const drift002 = report.findings.filter(f => f.code === 'ACTION-DRIFT-002') expect(drift002.length).toBeGreaterThan(0) const syncDrift = drift002.find(f => f.detail?.serviceUrl?.includes('sync-from-pce')) expect(syncDrift).toBeDefined() expect(syncDrift?.severity).toBe('err') expect(syncDrift?.entity).toBe('TypeAffaire') }) it('detects verb mismatch for analyze-impact (ACTION-DRIFT-005, GET vs POST)', async () => { writeFixtures(SERVICE_TS_DRIFT) const report = await audit(makeArgs()) // The pagespec says GET /impact but the service does POST /analyze-impact — // depending on candidate matching it surfaces as DRIFT-002 (URL diff) or // DRIFT-005 (verb diff). Either is acceptable; the canonical fix // applies the same way. const relevant = report.findings.filter( f => (f.detail?.serviceUrl?.includes('analyze-impact') || f.detail?.serviceUrl?.includes('impact')) && f.entity === 'TypeAffaire', ) expect(relevant.length).toBeGreaterThan(0) expect(relevant.every(f => f.severity === 'err')).toBe(true) }) it('produces zero err findings when pagespec, controller, and service are aligned', async () => { writeFixtures(SERVICE_TS_ALIGNED) const report = await audit(makeArgs()) expect(report.counts.err).toBe(0) // The OK finding is emitted when no drift is detected. const ok = report.findings.find(f => f.code === 'ACTION-DRIFT-OK') expect(ok).toBeDefined() }) it('inventory counts reflect what was scanned', async () => { writeFixtures(SERVICE_TS_ALIGNED) const report = await audit(makeArgs()) expect(report.inventory.pageActions).toBeGreaterThanOrEqual(2) // syncFromPce + analyzeImpact (open is navigate → excluded) expect(report.inventory.controllerEndpoints).toBeGreaterThanOrEqual(2) expect(report.inventory.serviceCalls).toBeGreaterThanOrEqual(2) expect(report.inventory.entitiesCovered).toContain('TypeAffaire') }) it('emits a markdown report with verdict header', async () => { writeFixtures(SERVICE_TS_DRIFT) const report = await audit(makeArgs()) expect(report.markdown).toContain('# Actions alignment audit') expect(report.markdown).toContain('Verdict :') // The drift report should highlight pagespec ↔ service issues expect(report.markdown).toContain('TypeAffaire') }) it('excludes kind:navigate actions from the alignment check', async () => { writeFixtures(SERVICE_TS_ALIGNED) const report = await audit(makeArgs()) // The `open` action is kind:navigate — it must NOT be flagged as missing // even though no endpoint backs it (intentional: navigations bypass the API). const navigateDrifts = report.findings.filter( f => f.detail?.pageActionEndpoint === 'open' || f.message.toLowerCase().includes('"open"'), ) expect(navigateDrifts).toEqual([]) }) it('flags a kind:api action whose button/hook is missing from the page (ACTION-DRIFT-006)', async () => { // Backend + service are fully aligned; only the analyzeImpact BUTTON is absent // from the page — the precise "action not implemented on the page" gap. const pageMissingImpact = `import { useSyncFromPceTypeAffaire } from '@/features/referentiels/typeAffaire/hooks/useTypeAffaire' export function TypeAffaireListPage() { const sync = useSyncFromPceTypeAffaire() return } ` writeFixtures(SERVICE_TS_ALIGNED, pageMissingImpact) const report = await audit(makeArgs()) const missing = report.findings.filter(f => f.code === 'ACTION-DRIFT-006') expect(missing).toHaveLength(1) expect(missing[0].severity).toBe('err') expect(missing[0].entity).toBe('TypeAffaire') expect(missing[0].message).toContain('useAnalyzeImpactTypeAffaire') // The fully-wired syncFromPce button must NOT be flagged. expect(missing.some(f => f.message.includes('useSyncFromPceTypeAffaire'))).toBe(false) }) it('passes with zero err when every api-action button is wired on the page', async () => { writeFixtures(SERVICE_TS_ALIGNED, PAGE_TSX_WIRED) const report = await audit(makeArgs()) expect(report.findings.filter(f => f.code === 'ACTION-DRIFT-006')).toEqual([]) expect(report.counts.err).toBe(0) }) }) describe('audit-dev-actions-alignment / service-call extraction (generated client shape)', () => { it('extracts api. calls and normalises the API_PATH / sub-resource prefix', async () => { const root = mkdtempSync(path.join(tmpdir(), 'ssaction-svc-')) // Flat resource — default client object is `api`, prefix is the const ${API_PATH}. const svcDir = path.join(root, 'src/features/crm/contact/services') mkdirSync(svcDir, { recursive: true }) writeFileSync(path.join(svcDir, 'contactService.ts'), `import { api } from '@atlashub/smartstack' const API_PATH = '/api/v1/integration/contacts' export const contactService = { list: async () => api.get(\`\${API_PATH}/list\`, { params: {} }), remove: async (id: string) => { await api.delete(\`\${API_PATH}/\${id}\`) }, } `) // Sub-resource — prefix is the helper CALL ${API_PATH(parentId)}. const subDir = path.join(root, 'src/features/crm/echange/services') mkdirSync(subDir, { recursive: true }) writeFileSync(path.join(subDir, 'echangeService.ts'), `import { api } from '@atlashub/smartstack' const API_PATH = (parentId: string) => \`/api/v1/integration/clients/\${parentId}/echanges\` export const echangeService = { list: async (parentId: string) => api.get(\`\${API_PATH(parentId)}/list\`, { params: {} }), } `) const calls = await loadServiceCalls(root) rmSync(root, { recursive: true, force: true }) const contactGet = calls.find(c => c.entity === 'Contact' && c.verb === 'get') expect(contactGet, 'api.get must be extracted (default client object is `api`)').toBeDefined() expect(contactGet?.urlPath).toBe('list') const contactDel = calls.find(c => c.entity === 'Contact' && c.verb === 'delete') expect(contactDel?.urlPath).toBe('{id}') // ${id} preserved through the strip const echangeGet = calls.find(c => c.entity === 'Echange' && c.verb === 'get') expect(echangeGet, 'sub-resource ${API_PATH(parentId)} prefix must be stripped, not mangled to {id}').toBeDefined() expect(echangeGet?.urlPath).toBe('list') }) })