import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { applyMarker, execute, naturalKeyOf, pluralSegmentOf, resolveSection } from '../execute.js' import { validate } from '../validate.js' import { DeriveExternalApiSpecInputSchema } from '../types.js' const ENTITE_MD = [ '# Entités — Ventes', '', '### ENT-001 — Facture (transactionnelle)', '', '- **Préfixe table** : `crm_`', '- **Affichage** : Numero', '- **API externe** : read, create', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Numero | string(30) | obligatoire | — |', '| Montant | decimal(18,2) | obligatoire | — |', '| Commentaire | string(500) | optionnel | — |', '', '- **Index** : (TenantId, Numero) unique', '', '### ENT-002 — Avoir (transactionnelle)', '', '- **Affichage** : Reference', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Reference | string(30) | obligatoire | — |', '', ].join('\n') /** * The screen is what binds an entity to its MENU SECTION — here `facturation`, * deliberately NOT the entity's plural, which is the case that used to break. */ const SCREEN_MD = [ '# Écrans — Facturation', '', '### SCR-001 — Liste des factures (SmartListView)', '', '- **Entité** : Facture (ENT-001)', '- **Permission** : `crm.ventes.facturation.read`', '', ].join('\n') function writeScreens(baRoot: string): void { const dir = path.join(baRoot, 'crm', 'ventes', 'facturation') fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'screen.md'), SCREEN_MD, 'utf8') } describe('derive-external-api-spec — reading the BA declaration', () => { let baRoot: string beforeEach(() => { baRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'derive-xapi-')) const dir = path.join(baRoot, 'crm', 'ventes') fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'entité.md'), ENTITE_MD, 'utf8') writeScreens(baRoot) }) afterEach(() => { fs.rmSync(baRoot, { recursive: true, force: true }) }) const run = (over: Record = {}) => execute(DeriveExternalApiSpecInputSchema.parse({ baRoot, applicationCode: 'crm', ...over })) it('reports the declaration entity by entity — one module can publish one entity only', () => { const { report } = run() expect(report.entities.map(e => [e.entity, e.operations, e.undeclared])).toEqual([ ['Facture', ['read', 'create'], false], ['Avoir', [], true], ]) expect(report.publishedCount).toBe(1) }) it('emits a scaffolder spec covering only what is declared', () => { const { report } = run({ appCode: 'TestV2', projectPath: 'D:/app' }) const spec = report.scaffoldSpec as { resources: Array> } expect(spec.resources).toHaveLength(1) expect(spec.resources[0]).toMatchObject({ entity: 'Facture', module: 'ventes', // The MENU section, not `factures` — this is the defect the audit found. section: 'facturation', operations: ['read', 'create'], naturalKey: ['Numero'], }) expect(report.entities.find(e => e.entity === 'Facture')!.sectionSource).toBe('screen') }) it('EXCLUDES an entity whose section cannot be resolved rather than guessing it', () => { // Two sections in the module and no screen binding Facture: nothing to // resolve from. Guessing would emit a permission constant that does not // exist, i.e. a spec that cannot compile. fs.rmSync(path.join(baRoot, 'crm', 'ventes', 'facturation'), { recursive: true, force: true }) for (const section of ['facturation', 'devis']) { const dir = path.join(baRoot, 'crm', 'ventes', section) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync( path.join(dir, 'screen.md'), ['### SCR-009 — Autre (SmartListView)', '', '- **Entité** : Autre (ENT-009)', ''].join('\n'), 'utf8', ) } const { report, warnings } = run({ appCode: 'TestV2', projectPath: 'D:/app' }) expect(report.entities.find(e => e.entity === 'Facture')!.sectionSource).toBe('unresolved') expect(report.scaffoldSpec).toBeNull() expect(warnings.join('\n')).toMatch(/menu section could not be resolved/) }) it('lets the caller settle an unresolvable section on the declaration', () => { fs.rmSync(path.join(baRoot, 'crm', 'ventes', 'facturation'), { recursive: true, force: true }) const { report } = execute( DeriveExternalApiSpecInputSchema.parse({ baRoot, applicationCode: 'crm', appCode: 'TestV2', projectPath: 'D:/app', declarations: [{ module: 'ventes', entity: 'Facture', section: 'facturation', operations: ['read'] }], }), ) const spec = report.scaffoldSpec as { resources: Array> } expect(spec.resources[0].section).toBe('facturation') }) it('projects attributes onto fields[], required-ness included, and drops Id', () => { const { report } = run() const facture = report.entities.find(e => e.entity === 'Facture')! expect(facture.fields).toEqual([ { name: 'Numero', type: 'string(30)', required: true }, { name: 'Montant', type: 'decimal(18,2)', required: true }, { name: 'Commentaire', type: 'string(500)', required: false }, ]) }) it('returns null and no spec when nothing is published', () => { fs.writeFileSync( path.join(baRoot, 'crm', 'ventes', 'entité.md'), ENTITE_MD.replace('- **API externe** : read, create\n', ''), 'utf8', ) const { report } = run() expect(report.publishedCount).toBe(0) expect(report.scaffoldSpec).toBeNull() }) it('warns when a published create has no unique index to lean on', () => { fs.writeFileSync( path.join(baRoot, 'crm', 'ventes', 'entité.md'), ENTITE_MD.replace('- **Index** : (TenantId, Numero) unique\n', ''), 'utf8', ) const { warnings } = run() expect(warnings.join('\n')).toMatch(/retried POST would duplicate/) }) }) describe('derive-external-api-spec — recording a decision', () => { let baRoot: string beforeEach(() => { baRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'derive-xapi-w-')) fs.mkdirSync(path.join(baRoot, 'crm', 'ventes'), { recursive: true }) fs.writeFileSync(path.join(baRoot, 'crm', 'ventes', 'entité.md'), ENTITE_MD, 'utf8') writeScreens(baRoot) }) afterEach(() => { fs.rmSync(baRoot, { recursive: true, force: true }) }) it('adds the canonical bullet to an entity that had none', () => { const { report } = execute( DeriveExternalApiSpecInputSchema.parse({ baRoot, applicationCode: 'crm', mode: 'write', declarations: [{ module: 'ventes', entity: 'Avoir', operations: ['read'] }], }), ) const written = fs.readFileSync(path.join(baRoot, 'crm', 'ventes', 'entité.md'), 'utf8') expect(written).toContain('- **API externe** : read') expect(report.filesModified).toEqual(['crm/ventes/entité.md']) // The other entity's declaration is untouched. expect(written).toContain('- **API externe** : read, create') }) it('replaces an existing bullet instead of stacking a second one', () => { execute( DeriveExternalApiSpecInputSchema.parse({ baRoot, applicationCode: 'crm', mode: 'write', declarations: [{ module: 'ventes', entity: 'Facture', operations: ['read'] }], }), ) const written = fs.readFileSync(path.join(baRoot, 'crm', 'ventes', 'entité.md'), 'utf8') expect(written.match(/\*\*API externe\*\*/g)).toHaveLength(1) expect(written).toContain('- **API externe** : read') expect(written).not.toContain('read, create') }) it('skips — never appends blindly — when the entity is not in the document', () => { const { report, warnings } = execute( DeriveExternalApiSpecInputSchema.parse({ baRoot, applicationCode: 'crm', mode: 'write', declarations: [{ module: 'ventes', entity: 'Inexistante', operations: ['read'] }], }), ) expect(report.filesModified).toEqual([]) expect(warnings.join('\n')).toMatch(/not found/) }) }) describe('derive-external-api-spec — helpers and guards', () => { it('resolves the section from the screen that binds the entity — never from its plural', () => { const screens = [ { module: 'ventes', section: 'facturation', entity: 'Facture' }, { module: 'ventes', section: 'facturation/lignes', entity: 'LigneFacture' }, ] as never[] expect(resolveSection('Facture', 'ventes', screens)).toEqual({ section: 'facturation', source: 'screen' }) // A resource-level screen still roots the permission at its SECTION. expect(resolveSection('LigneFacture', 'ventes', screens)).toEqual({ section: 'facturation', source: 'screen' }) }) it('falls back to the module single section, and refuses to guess beyond that', () => { const one = [{ module: 'ventes', section: 'facturation', entity: 'Autre' }] as never[] expect(resolveSection('Facture', 'ventes', one)).toEqual({ section: 'facturation', source: 'single-section' }) const many = [ { module: 'ventes', section: 'facturation', entity: 'Autre' }, { module: 'ventes', section: 'devis', entity: 'Devis' }, ] as never[] expect(resolveSection('Facture', 'ventes', many)).toEqual({ section: null, source: 'unresolved' }) }) it('lets an explicit declaration settle the section', () => { expect(resolveSection('Facture', 'ventes', [], 'Facturation')).toEqual({ section: 'facturation', source: 'declared' }) }) it('keeps the plural helper for messages only', () => { expect(pluralSegmentOf('TypeClient')).toBe('type-clients') }) it('reads the natural key off a unique index, tenant column excluded', () => { expect(naturalKeyOf({ indexes: [{ fields: ['TenantId', 'Numero'], unique: true, raw: '' }], initialValues: null } as never)) .toEqual(['Numero']) expect(naturalKeyOf({ indexes: [{ fields: ['Libelle'], unique: false, raw: '' }], initialValues: null } as never)) .toEqual([]) }) it('returns null from applyMarker when the entity heading is absent', () => { expect(applyMarker('### ENT-001 — Facture', 'Autre', ['read'], 'operation')).toBeNull() }) it('refuses a write with no declarations — that would silently retire an API', () => { const r = validate({ baRoot: '.', applicationCode: 'crm', mode: 'write' }) expect(r.errors.join('\n')).toMatch(/nothing to record/) }) it('refuses a write-only declaration', () => { const r = validate({ baRoot: '.', applicationCode: 'crm', mode: 'write', declarations: [{ module: 'ventes', entity: 'Facture', operations: ['create'] }], }) expect(r.errors.join('\n')).toMatch(/unable to read back/) }) }) // --------------------------------------------------------------------------- // Data sensitivity (lib/ba-sensitivity) — the exposure gate (DM-038 / DEV-XAPI-015) // --------------------------------------------------------------------------- describe('derive-external-api-spec — sensitivity gate', () => { let baRoot: string const SENSITIVE_MD = [ '# Entités — Paie', '', '### ENT-001 — FichePaie (agrégat racine)', '- **Sensibilité** : confidentiel — financier, rh', '- **Affichage** : Reference', '- **API externe** : read', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Reference | string(30) | obligatoire | — |', '', '### ENT-002 — Tarif (agrégat racine)', '- **Sensibilité** : restreint — commercial', '- **Affichage** : Code', '- **API externe** : read — décision client du 2026-10-03 : partenaire', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Code | string(30) | obligatoire | — |', '', '### ENT-003 — Contrat (agrégat racine)', '- **Sensibilité** : confidentiel — rh', '- **Affichage** : Reference', '- **API externe** : read — grain opération — décision client du 2026-10-03 : prestataire paie', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Reference | string(30) | obligatoire | — |', '', '### ENT-004 — Catalogue (agrégat racine)', '- **Sensibilité** : public', '- **Affichage** : Name', '- **API externe** : read', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Name | string(30) | obligatoire | — |', '', '### ENT-005 — Periode (lookup)', '- **Affichage** : Label', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Label | string(30) | obligatoire | — |', '', ].join('\n') beforeEach(() => { baRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'derive-xapi-sens-')) const dir = path.join(baRoot, 'rh', 'paie') fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, 'entité.md'), SENSITIVE_MD, 'utf8') // One section per entity so the section resolves (the module's screens bind them). for (const [section, entity] of [['fiches', 'FichePaie'], ['tarifs', 'Tarif'], ['contrats', 'Contrat'], ['catalogue', 'Catalogue'], ['periodes', 'Periode']] as const) { const sdir = path.join(dir, section) fs.mkdirSync(sdir, { recursive: true }) fs.writeFileSync(path.join(sdir, 'screen.md'), `### SCR-${section.toUpperCase()}-001 — Liste (SmartListView)\n\n- **Entité** : ${entity} (ENT-00x)\n- **Permission** : \`rh.paie.${section}.read\`\n`, 'utf8') } }) afterEach(() => fs.rmSync(baRoot, { recursive: true, force: true })) const run = (over: Record = {}) => execute(DeriveExternalApiSpecInputSchema.parse({ baRoot, applicationCode: 'rh', ...over })) it('check: a restreint entity (even with a decision) and a confidentiel one without a dated decision are REFUSED and excluded from the spec; public and sanctioned confidentiel pass', () => { const { report } = run({ appCode: 'X', projectPath: '/p' }) const by = Object.fromEntries(report.entities.map(e => [e.entity, e])) expect(by['FichePaie']!.sensitivity).toEqual({ level: 'confidentiel', categories: ['financier', 'rh'] }) expect(by['FichePaie']!.decision).toBeNull() expect(by['FichePaie']!.refused).toContain('confidentiel entity is exposed only on the client\'s DATED decision') expect(by['Tarif']!.refused).toContain('restreint entity is NEVER exposed') expect(by['Contrat']!.refused).toBeNull() expect(by['Contrat']!.decision).toEqual({ date: '2026-10-03', reason: 'prestataire paie' }) expect(by['Catalogue']!.refused).toBeNull() expect(by['Periode']!.sensitivity.level).toBe('interne') expect(report.errors).toHaveLength(2) expect(report.errors[0]).toContain('rh/paie/entité.md: FichePaie') const spec = report.scaffoldSpec as { resources: Array<{ entity: string }> } expect(spec.resources.map(r => r.entity).sort()).toEqual(['Catalogue', 'Contrat']) expect(report.publishedCount).toBe(4) }) it('write: a declaration on a restreint entity is refused and NOT written; on a confidentiel one it needs decision{date} and writes the facet', () => { const { report } = run({ mode: 'write', declarations: [ { module: 'paie', entity: 'Tarif', operations: ['read'], decision: { date: '2026-10-03', reason: 'x' } }, { module: 'paie', entity: 'FichePaie', operations: ['read'] }, { module: 'paie', entity: 'FichePaie', operations: ['read', 'create'], decision: { date: '2026-10-04', reason: 'export paie vers le prestataire' } }, ], }) expect(report.errors.filter(e => e.includes('declaration refused'))).toHaveLength(2) const written = fs.readFileSync(path.join(baRoot, 'rh', 'paie', 'entité.md'), 'utf8') expect(written).toContain('- **API externe** : read, create — décision client du 2026-10-04 : export paie vers le prestataire') // The restreint line is untouched (its own refusal is still reported in check), the confidentiel one is now sanctioned. expect(written).toContain('- **API externe** : read — décision client du 2026-10-03 : partenaire') const by = Object.fromEntries(report.entities.map(e => [e.entity, e])) expect(by['FichePaie']!.refused).toBeNull() expect(by['FichePaie']!.decision).toEqual({ date: '2026-10-04', reason: 'export paie vers le prestataire' }) }) it('an undated « décision client » on the line is a refusal too (the near-miss)', () => { const dir = path.join(baRoot, 'rh', 'paie') fs.writeFileSync(path.join(dir, 'entité.md'), SENSITIVE_MD.replace('- **API externe** : read\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Reference', '- **API externe** : read — décision client : on verra\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Reference'), 'utf8') const { report } = run() const fiche = report.entities.find(e => e.entity === 'FichePaie')! expect(fiche.decision).toEqual({ date: null, reason: 'on verra' }) expect(fiche.refused).toContain('carries no date') }) it('applyMarker renders and round-trips the decision facet', () => { const next = applyMarker('### ENT-001 — X\n\n- **Préfixe table** : `x_`\n', 'X', ['read'], 'operation', { date: '2026-10-03', reason: 'motif' })! expect(next).toContain('- **API externe** : read — décision client du 2026-10-03 : motif') }) })