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/) }) })