/** * derive-extension-request — the writer of `/demande-extension.md` is a * PROJECTION: a new block carries the requester's rows verbatim, the Studio's * `Statut` is never written (« en attente » at birth), a pending block whose * delta grew is completed in place with its Statut/Décision kept, a validated * block lacking newer columns yields a NEW block, a refused one is never * re-asked, and codes are never reused. Idempotent: a second write is a no-op. */ import { describe, expect, it } from 'vitest' import { parseEntityDoc } from '../../../../../lib/ba-entities.js' import { parseExtensionRequestsDoc } from '../../../../../lib/ba-extension-requests.js' import { deriveExtensionRequests, type DeriveExtensionRequestInputs } from '../execute.js' const EMPTY_DOC = { exists: false, relPath: null, app: null, requests: [], headingsSeen: 0, warnings: [] } const memos = (attrs: string[]): ReturnType['entities'] => parseEntityDoc( [ '### ENT-001 — Vehicule (agrégat racine)', '- **Description** : le véhicule associé à un mémo.', '- **Traçabilité** : UC-NOTES-MEMOS-001', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Plaque | string(20) | requis | — |', ...attrs.map((a) => `| ${a} | string(30) | requis | — |`), '', ].join('\n'), 'NOTES/MEMOS', ).entities const agenda = parseEntityDoc('### ENT-001 — Vehicule (agrégat racine)\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n| Plaque | string(20) | requis | — |\n| Immatriculation | string(30) | requis | — |\n', 'NOTES/AGENDA').entities const owner = parseEntityDoc('### ENT-001 — Vehicule (agrégat racine)\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n| Plaque | string(20) | requis | — |\n', 'GESTION_FLOTTE/VEHICULES').entities function inputs(over: Partial = {}): DeriveExtensionRequestInputs { return { app: 'NOTES', focusModule: 'MEMOS', modules: [{ module: 'AGENDA', entities: agenda }, { module: 'MEMOS', entities: memos(['Immatriculation']) }], foreign: [{ modulePath: 'GESTION_FLOTTE/VEHICULES', entities: owner }], scan: null, doc: EMPTY_DOC, rawDoc: null, mode: 'write', today: '2026-10-03', warnings: [], ...over, } } describe('deriveExtensionRequests', () => { it('check never writes; the report says what is missing', () => { const { report, rewritten } = deriveExtensionRequests(inputs({ mode: 'check' })) expect(rewritten).toBeNull() expect(report.totals).toMatchObject({ expected: 1, missing: 1, errors: 1 }) expect(report.missing[0]).toMatchObject({ entity: 'Vehicule', target: 'GESTION_FLOTTE/VEHICULES', attributes: ['Immatriculation'] }) expect(report.codeLeg).toBe(false) }) it('write creates the doc with ONE block per (entity, owner) merging the requesters, Statut « en attente », rows verbatim', () => { const { report, rewritten } = deriveExtensionRequests(inputs()) expect(report.writes).toEqual({ added: ['REQ-001'], updated: [] }) expect(rewritten).not.toBeNull() expect(rewritten).toContain('') const parsed = parseExtensionRequestsDoc(rewritten!, null) expect(parsed.warnings).toEqual([]) expect(parsed.requests).toHaveLength(1) expect(parsed.requests[0]).toMatchObject({ code: 'REQ-001', entity: 'Vehicule', status: 'pending', statusRaw: 'en attente', requesters: ['AGENDA', 'MEMOS'], date: '2026-10-03', decision: null, target: { kind: 'application', app: 'GESTION_FLOTTE', module: 'VEHICULES' }, }) expect(parsed.requests[0]!.attributes).toEqual([{ name: 'Immatriculation', type: 'string(30)', constraints: 'requis', computed: '—' }]) expect(parsed.requests[0]!.reason).toBe('le véhicule associé à un mémo. — Traçabilité : UC-NOTES-MEMOS-001') }) it('is idempotent: writing again over its own output changes nothing', () => { const first = deriveExtensionRequests(inputs()).rewritten! const again = deriveExtensionRequests(inputs({ doc: parseExtensionRequestsDoc(first, null), rawDoc: first })) expect(again.rewritten).toBeNull() expect(again.report.totals).toMatchObject({ covered: 1, missing: 0, stale: 0, pending: 1 }) }) it('a grown delta completes the PENDING block in place — Statut, Décision and Date kept verbatim', () => { const first = deriveExtensionRequests(inputs()).rewritten!.replace('- **Statut** : en attente', '- **Statut** : en attente — vu par le Studio le 2026-10-04') const grown = inputs({ modules: [{ module: 'MEMOS', entities: memos(['Immatriculation', 'Couleur']) }], doc: parseExtensionRequestsDoc(first, null), rawDoc: first }) const { report, rewritten } = deriveExtensionRequests(grown) expect(report.writes).toEqual({ added: [], updated: ['REQ-001'] }) const parsed = parseExtensionRequestsDoc(rewritten!, null) expect(parsed.requests).toHaveLength(1) expect(parsed.requests[0]!.statusRaw).toBe('en attente — vu par le Studio le 2026-10-04') expect(parsed.requests[0]!.date).toBe('2026-10-03') expect(parsed.requests[0]!.attributes.map((a) => a.name)).toEqual(['Immatriculation', 'Couleur']) }) it('a VALIDATED block lacking newer columns → a NEW block (next free code), the validated one untouched', () => { const first = deriveExtensionRequests(inputs()).rewritten!.replace('- **Statut** : en attente', '- **Statut** : validée\n- **Décision** : livré en 5.3') const grown = inputs({ modules: [{ module: 'MEMOS', entities: memos(['Immatriculation', 'Couleur']) }], doc: parseExtensionRequestsDoc(first, null), rawDoc: first }) const { report, rewritten } = deriveExtensionRequests(grown) expect(report.writes).toEqual({ added: ['REQ-002'], updated: [] }) const parsed = parseExtensionRequestsDoc(rewritten!, null) expect(parsed.requests.map((r) => [r.code, r.status, r.attributes.map((a) => a.name)])).toEqual([ ['REQ-001', 'validated', ['Immatriculation']], ['REQ-002', 'pending', ['Couleur']], ]) expect(parsed.requests[0]!.decision).toBe('livré en 5.3') }) it('a REFUSED block is never re-asked', () => { const first = deriveExtensionRequests(inputs()).rewritten!.replace('- **Statut** : en attente', '- **Statut** : refusée') const grown = inputs({ modules: [{ module: 'MEMOS', entities: memos(['Immatriculation', 'Couleur']) }], doc: parseExtensionRequestsDoc(first, null), rawDoc: first }) const { report, rewritten } = deriveExtensionRequests(grown) expect(rewritten).toBeNull() expect(report.totals).toMatchObject({ refused: 1, missing: 0, stale: 0 }) }) it('CRLF documents are rewritten with CRLF', () => { const first = deriveExtensionRequests(inputs()).rewritten!.replace(/\n/g, '\r\n') const grown = inputs({ modules: [{ module: 'MEMOS', entities: memos(['Immatriculation', 'Couleur']) }], doc: parseExtensionRequestsDoc(first, null), rawDoc: first }) const { rewritten } = deriveExtensionRequests(grown) expect(rewritten).toContain('\r\n') expect(rewritten!.split('\r\n').length).toBeGreaterThan(10) expect(/[^\r]\n/.test(rewritten!)).toBe(false) }) }) // --------------------------------------------------------------------------- // Nature « accès » — the writer projects a block WITHOUT a table // --------------------------------------------------------------------------- describe('deriveExtensionRequests — access requests (data sensitivity)', () => { const planning = parseEntityDoc( [ '### ENT-001 — Affectation (agrégat racine)', '- **Description** : une affectation.', '- **Traçabilité** : UC-NOTES-PLANNING-001', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '', '- **Relations** : Affectation *→1 FichePaie — FK FichePaieId, scope cross-module (RH/PAIE), onDelete restrict.', '', ].join('\n'), 'NOTES/PLANNING', ).entities const paie = parseEntityDoc('### ENT-001 — FichePaie (agrégat racine)\n- **Sensibilité** : confidentiel — financier\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n', 'RH/PAIE').entities const accessInputs = (over: Partial = {}): DeriveExtensionRequestInputs => inputs({ focusModule: 'PLANNING', modules: [{ module: 'PLANNING', entities: planning, screens: [], rbacRows: [] }], foreign: [{ modulePath: 'RH/PAIE', entities: paie }], ...over, }) it('writes an access block: Nature accès, the owner Sensibilité copied, the FK fact as motive, NO table', () => { const { report, rewritten } = deriveExtensionRequests(accessInputs()) expect(report.missing).toEqual([ expect.objectContaining({ entity: 'FichePaie', nature: 'acces', sensitivity: 'confidentiel — financier', target: 'RH/PAIE', attributes: [] }), ]) expect(report.writes.added).toEqual(['REQ-001']) expect(rewritten).toContain('### REQ-001 — FichePaie → RH/PAIE\n- **Nature** : accès\n- **Statut** : en attente\n- **Demandeurs** : PLANNING\n- **Sensibilité** : confidentiel — financier\n') expect(rewritten).toContain('- **Motif** : accès en lecture via FK Affectation.FichePaieId → FichePaie — Traçabilité : UC-NOTES-PLANNING-001') expect(rewritten).not.toContain('| Attribut |') }) it('is idempotent: the written doc covers the ask, a second write is a no-op and the status is never touched', () => { const first = deriveExtensionRequests(accessInputs()) const doc = parseExtensionRequestsDoc(first.rewritten!, 'NOTES/demande-extension.md') expect(doc.warnings).toEqual([]) const second = deriveExtensionRequests(accessInputs({ doc, rawDoc: first.rewritten })) expect(second.rewritten).toBeNull() expect(second.report.totals).toMatchObject({ expected: 1, missing: 0, covered: 1, pending: 1, errors: 0 }) expect(second.report.covered[0]).toMatchObject({ code: 'REQ-001', nature: 'acces', sensitivity: 'confidentiel — financier' }) }) it('an interne owner (no Sensibilité) asks nothing', () => { const interne = parseEntityDoc('### ENT-001 — FichePaie (agrégat racine)\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n', 'RH/PAIE').entities const { report } = deriveExtensionRequests(accessInputs({ foreign: [{ modulePath: 'RH/PAIE', entities: interne }], mode: 'check' })) expect(report.totals).toMatchObject({ expected: 0, missing: 0 }) }) })