/** * lib/ba-extension-requests — the `/demande-extension.md` contract * (doctrine 2026-10-03): the grammar parses and renders round-trip, an unknown * Statut freezes (pending, said), a near-miss heading is counted, the * expectations come from BOTH legs (the owner's generated class under another * application's folder, the owner's entité.md) and only carry the EXTRA * attributes, and the reconciliation never re-asks a refused request nor * touches a validated block (a grown delta is a NEW block). */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { parseEntityDoc } from '../ba-entities.js' import { blockingExtensionRequests, expectedAccessRequests, expectedExtensionRequests, loadAppExtensionRequests, nextExtensionRequestCode, parseExtensionRequestTarget, parseExtensionRequestsDoc, reconcileExtensionRequests, renderExtensionRequestBlock, renderExtensionRequestsDocHeader, toRequestAttribute, } from '../ba-extension-requests.js' import { resetDomainScanCache, scanDomain } from '../domain-scan.js' const DOC = ` # Demandes d'extension — NOTES ### REQ-001 — Vehicule → GESTION_FLOTTE/VEHICULES - **Statut** : en attente - **Demandeurs** : MEMOS, AGENDA - **Classe** : \`src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs\` - **Motif** : le véhicule associé à un mémo — Traçabilité : UC-NOTES-MEMOS-001 - **Date** : 2026-10-03 | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Immatriculation | string(20) | requis | — | ### REQ-002 — Contact → CRM - **Statut** : validée — Studio 2026-10-04 - **Demandeurs** : MEMOS - **Classe** : non développée — entité.md CRM/CLIENTS - **Motif** : contact cité - **Date** : 2026-10-03 - **Décision** : ajouté en 5.3 | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Surnom | string(50) | — | — | ### REQ-003 — Site → Core Office via HR - **Statut** : à voir - **Demandeurs** : MEMOS | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Etage | int | — | — | ### REQ-4 Broken heading ` describe('parseExtensionRequestsDoc', () => { const doc = parseExtensionRequestsDoc(DOC, 'NOTES/demande-extension.md') it('reads the marker, every well-formed block, its fields and its attribute rows', () => { expect(doc.app).toBe('NOTES') expect(doc.requests.map((r) => r.code)).toEqual(['REQ-001', 'REQ-002', 'REQ-003']) const r1 = doc.requests[0]! expect(r1.entity).toBe('Vehicule') expect(r1.target).toEqual({ kind: 'application', app: 'GESTION_FLOTTE', module: 'VEHICULES' }) expect(r1.status).toBe('pending') expect(r1.requesters).toEqual(['MEMOS', 'AGENDA']) expect(r1.classFile).toBe('src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs') expect(r1.attributes).toEqual([{ name: 'Immatriculation', type: 'string(20)', constraints: 'requis', computed: '—' }]) expect(r1.date).toBe('2026-10-03') }) it('a validated status with a trailing note still reads validated; Décision is kept verbatim', () => { const r2 = doc.requests[1]! expect(r2.status).toBe('validated') expect(r2.decision).toBe('ajouté en 5.3') expect(r2.target).toEqual({ kind: 'application', app: 'CRM', module: null }) }) it('an unknown Statut reads as PENDING and is said; a Core target parses with its via', () => { const r3 = doc.requests[2]! expect(r3.status).toBe('pending') expect(r3.target).toEqual({ kind: 'core', entity: 'Office', via: 'HR' }) expect(doc.warnings.some((w) => w.includes('REQ-003') && w.includes('à voir'))).toBe(true) }) it('a near-miss heading is counted (headingsSeen) and warned, never silently dropped', () => { expect(doc.headingsSeen).toBe(4) expect(doc.warnings.some((w) => w.includes('near-miss heading'))).toBe(true) }) it('blockingExtensionRequests = pending + refused', () => { expect(blockingExtensionRequests(doc).map((r) => r.code)).toEqual(['REQ-001', 'REQ-003']) }) it('block boundaries are exact (startLine/endLine) for in-place rewrites', () => { const lines = DOC.split('\n') const r1 = doc.requests[0]! expect(lines[r1.startLine]).toContain('### REQ-001') expect(lines[r1.endLine]).toContain('### REQ-002') }) it('parseExtensionRequestTarget: app, app/module, core', () => { expect(parseExtensionRequestTarget('CRM')).toEqual({ kind: 'application', app: 'CRM', module: null }) expect(parseExtensionRequestTarget('Core User')).toEqual({ kind: 'core', entity: 'User', via: null }) }) }) describe('render ↔ parse round-trip', () => { it('a rendered block parses back to the same request', () => { const block = renderExtensionRequestBlock({ code: 'REQ-007', entity: 'Vehicule', target: { kind: 'application', app: 'GESTION_FLOTTE', module: 'VEHICULES' }, statusRaw: 'en attente', requesters: ['MEMOS'], classFile: null, reason: 'motif', date: '2026-10-03', decision: null, attributes: [{ name: 'Couleur', type: 'string(20)', constraints: '—', computed: '—' }], }) const parsed = parseExtensionRequestsDoc(`${renderExtensionRequestsDocHeader('NOTES')}${block}`, null) expect(parsed.requests).toHaveLength(1) expect(parsed.requests[0]).toMatchObject({ code: 'REQ-007', entity: 'Vehicule', status: 'pending', requesters: ['MEMOS'], classFile: null, reason: 'motif', date: '2026-10-03' }) expect(parsed.requests[0]!.attributes).toEqual([{ name: 'Couleur', type: 'string(20)', constraints: '—', computed: '—' }]) expect(parsed.warnings).toEqual([]) }) it('nextExtensionRequestCode never reuses a number', () => { const doc = parseExtensionRequestsDoc(DOC, null) expect(nextExtensionRequestCode(doc)).toBe('REQ-004') expect(nextExtensionRequestCode(doc, new Set(['REQ-004']))).toBe('REQ-005') }) }) describe('expectedExtensionRequests — both legs, extra attributes only', () => { let root: string beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ss-ext-req-')) resetDomainScanCache() }) afterEach(() => rmSync(root, { recursive: true, force: true })) const memos = 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 | — |', '| Immatriculation | string(20) | requis | — |', '| CreatedAt | datetime | — | — |', '', '### ENT-002 — Contact (agrégat racine)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Nom | string(100) | requis | — |', '| Surnom | string(50) | — | — |', '', ].join('\n'), 'NOTES/MEMOS', ).entities const crmContact = parseEntityDoc('### ENT-001 — Contact (agrégat racine)\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n| Nom | string(100) | requis | — |\n', 'CRM/CLIENTS').entities it('code leg: the owner class under another application → the attributes the class lacks, framework out', () => { const p = join(root, 'src', 'Demo.Domain', 'Entities', 'GestionFlotte', 'Vehicules', 'Vehicule.cs') mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, 'public class Vehicule : ExtensionBaseEntity\n{\n public string Plaque { get; set; } = "";\n}\n', 'utf8') const scan = scanDomain(root) const out = expectedExtensionRequests({ app: 'NOTES', module: 'MEMOS', entities: memos, foreign: [{ modulePath: 'GESTION_FLOTTE/VEHICULES', entities: [] }], scan }) expect(out).toHaveLength(1) expect(out[0]).toMatchObject({ entity: 'Vehicule', entityCode: 'ENT-001', requester: 'MEMOS', evidence: ['code'], classFile: 'src/Demo.Domain/Entities/GestionFlotte/Vehicules/Vehicule.cs' }) // The folder segment is mapped back to the BA application code when the tree knows it. expect(out[0]!.target).toEqual({ kind: 'application', app: 'GESTION_FLOTTE', module: 'VEHICULES' }) expect(out[0]!.attributes.map((a) => a.name)).toEqual(['Immatriculation']) expect(out[0]!.reason).toBe('le véhicule associé à un mémo. — Traçabilité : UC-NOTES-MEMOS-001') }) it('BA leg: the owner entité.md of another application → the attributes it lacks; no projectRoot needed', () => { const out = expectedExtensionRequests({ app: 'NOTES', module: 'MEMOS', entities: memos, foreign: [{ modulePath: 'CRM/CLIENTS', entities: crmContact }], scan: null }) expect(out).toHaveLength(1) expect(out[0]).toMatchObject({ entity: 'Contact', evidence: ['ba'], classFile: null, target: { kind: 'application', app: 'CRM', module: 'CLIENTS' } }) expect(out[0]!.attributes.map((a) => a.name)).toEqual(['Surnom']) }) it('a collision with NO extra attribute is pure reuse — no expectation', () => { const same = parseEntityDoc('### ENT-001 — Contact (agrégat racine)\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n| Nom | string(100) | requis | — |\n| Surnom | string(50) | — | — |\n', 'CRM/CLIENTS').entities const out = expectedExtensionRequests({ app: 'NOTES', module: 'MEMOS', entities: memos, foreign: [{ modulePath: 'CRM/CLIENTS', entities: same }], scan: null }) expect(out).toEqual([]) }) it('the module\'s OWN application is never an owner', () => { const out = expectedExtensionRequests({ app: 'NOTES', module: 'MEMOS', entities: memos, foreign: [{ modulePath: 'NOTES/AGENDA', entities: crmContact }], scan: null }) expect(out).toEqual([]) }) }) describe('reconcileExtensionRequests', () => { const doc = parseExtensionRequestsDoc(DOC, null) const x = (entity: string, app: string, attrs: string[], module: string | null = null) => ({ entity, entityCode: 'ENT-001', requester: 'MEMOS', target: { kind: 'application' as const, app, module }, classFile: null, evidence: ['ba' as const], attributes: attrs.map((name) => toRequestAttribute({ name, type: 'string(10)', constraints: '—', computed: null })), reason: 'r', }) it('covered when a block of the pair carries every attribute (requester membership not required)', () => { const rec = reconcileExtensionRequests([x('Vehicule', 'GestionFlotte', ['Immatriculation'])], doc) expect(rec.covered.map((c) => c.request.code)).toEqual(['REQ-001']) expect(rec.missing).toEqual([]) }) it('a PENDING block lacking a newer attribute is stale (to complete in place)', () => { const rec = reconcileExtensionRequests([x('Vehicule', 'GESTION_FLOTTE', ['Immatriculation', 'Couleur'])], doc) expect(rec.stale).toHaveLength(1) expect(rec.stale[0]).toMatchObject({ missingAttributes: ['Couleur'] }) expect(rec.stale[0]!.request.code).toBe('REQ-001') }) it('a VALIDATED block lacking a newer attribute → a NEW request for the delta only', () => { const rec = reconcileExtensionRequests([x('Contact', 'CRM', ['Surnom', 'Telephone'])], doc) expect(rec.stale).toEqual([]) expect(rec.missing).toHaveLength(1) expect(rec.missing[0]!.attributes.map((a) => a.name)).toEqual(['Telephone']) }) it('a REFUSED block is never re-asked mechanically', () => { const refused = parseExtensionRequestsDoc(DOC.replace('- **Statut** : validée — Studio 2026-10-04', '- **Statut** : refusée'), null) const rec = reconcileExtensionRequests([x('Contact', 'CRM', ['Surnom', 'Telephone'])], refused) expect(rec.missing).toEqual([]) expect(rec.refused.map((r) => r.code)).toEqual(['REQ-002']) }) it('no block at all → missing; blocks nothing matches → orphan', () => { const rec = reconcileExtensionRequests([x('Memo', 'CRM', ['Titre'])], doc) expect(rec.missing).toHaveLength(1) expect(rec.orphan.map((r) => r.code)).toEqual(['REQ-001', 'REQ-002', 'REQ-003']) }) }) describe('loadAppExtensionRequests', () => { it('absence is a result (exists:false), never an error', () => { const root = mkdtempSync(join(tmpdir(), 'ss-ext-load-')) try { expect(loadAppExtensionRequests(root, 'NOTES')).toMatchObject({ exists: false, requests: [] }) mkdirSync(join(root, 'NOTES'), { recursive: true }) writeFileSync(join(root, 'NOTES', 'demande-extension.md'), DOC, 'utf8') const doc = loadAppExtensionRequests(root, 'NOTES') expect(doc.exists).toBe(true) expect(doc.relPath).toBe('NOTES/demande-extension.md') expect(doc.requests).toHaveLength(3) } finally { rmSync(root, { recursive: true, force: true }) } }) }) describe('ba-menu-tree — renameExtensionRequestRefs / countExtensionRequestRefs (menu-node rename/delete)', () => { const ownDoc = DOC // NOTES's own doc: MEMOS is a requester, GESTION_FLOTTE/VEHICULES a target it('renames the target `→ APP/OLD` in any application\'s doc, and the Demandeurs token only in the module\'s OWN application doc', async () => { const { renameExtensionRequestRefs, countExtensionRequestRefs } = await import('../ba-menu-tree.js') // Another application renames its module VEHICULES → FLEET: NOTES's doc targets it. const r = renameExtensionRequestRefs(ownDoc, 'GESTION_FLOTTE', 'VEHICULES', 'FLEET', { ownApp: false }) expect(r.count).toBe(1) expect(r.content).toContain('### REQ-001 — Vehicule → GESTION_FLOTTE/FLEET') expect(r.content).toContain('- **Demandeurs** : MEMOS, AGENDA') // untouched // NOTES renames its own module MEMOS → NOTES_MEMOS: the Demandeurs tokens move, targets do not. const own = renameExtensionRequestRefs(ownDoc, 'NOTES', 'MEMOS', 'NOTES_MEMOS', { ownApp: true }) expect(own.count).toBe(3) expect(own.content).toContain('- **Demandeurs** : NOTES_MEMOS, AGENDA') expect(own.content).toContain('- **Demandeurs** : NOTES_MEMOS\n') expect(countExtensionRequestRefs(ownDoc, 'GESTION_FLOTTE', 'VEHICULES', { ownApp: false })).toEqual({ targets: 1, requesters: 0 }) expect(countExtensionRequestRefs(ownDoc, 'NOTES', 'MEMOS', { ownApp: true })).toEqual({ targets: 0, requesters: 3 }) // Every other byte is identical (CRLF-safe line splitting). expect(renameExtensionRequestRefs(ownDoc, 'X', 'Y', 'Z', { ownApp: true })).toEqual({ content: ownDoc, count: 0 }) }) }) // --------------------------------------------------------------------------- // Nature « accès » — reaching a sensitive entity of another application // --------------------------------------------------------------------------- describe('nature accès — grammar, render, reconciliation', () => { it('parses the Nature and Sensibilité lines; an access block needs no attribute row (no warning); absent Nature = extension', () => { const doc = parseExtensionRequestsDoc( [ '', "# Demandes d'extension — NOTES", '', '### REQ-001 — FichePaie → RH/PAIE', '- **Nature** : accès', '- **Statut** : en attente', '- **Demandeurs** : PLANNING', '- **Sensibilité** : confidentiel — financier', '- **Classe** : non développée — entité.md RH/PAIE', '- **Motif** : accès en lecture via FK Affectation.FichePaieId → FichePaie', '- **Date** : 2026-10-03', '', '### REQ-002 — Vehicule → GESTION_FLOTTE/VEHICULES', '- **Statut** : validée', '- **Demandeurs** : MEMOS', '- **Classe** : non développée — entité.md GESTION_FLOTTE/VEHICULES', '- **Motif** : x', '- **Date** : 2026-10-03', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Couleur | string(20) | — | — |', '', ].join('\n'), 'NOTES/demande-extension.md', ) expect(doc.requests.map((r) => [r.code, r.nature, r.natureRaw, r.sensitivityRaw])).toEqual([ ['REQ-001', 'acces', 'accès', 'confidentiel — financier'], ['REQ-002', 'extension', null, null], ]) expect(doc.warnings).toEqual([]) expect(blockingExtensionRequests(doc).map((r) => r.code)).toEqual(['REQ-001']) }) it('an unknown Nature is read as extension and SAID', () => { const doc = parseExtensionRequestsDoc('### REQ-001 — X → APP\n- **Nature** : partage\n- **Statut** : en attente\n- **Demandeurs** : M\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| A | string | — | — |\n', 'x') expect(doc.requests[0]!.nature).toBe('extension') expect(doc.warnings.some((w) => w.includes('Nature « partage »'))).toBe(true) }) it('renders an access block without a table and with the Sensibilité line; an extension block stays byte-identical to before', () => { const access = renderExtensionRequestBlock({ code: 'REQ-003', entity: 'FichePaie', target: { kind: 'application', app: 'RH', module: 'PAIE' }, nature: 'acces', statusRaw: 'en attente', requesters: ['PLANNING'], sensitivity: 'confidentiel — financier', classFile: null, reason: 'accès en lecture via FK Affectation.FichePaieId → FichePaie', date: '2026-10-03', decision: null, attributes: [], }) expect(access).toBe( [ '### REQ-003 — FichePaie → RH/PAIE', '- **Nature** : accès', '- **Statut** : en attente', '- **Demandeurs** : PLANNING', '- **Sensibilité** : confidentiel — financier', '- **Classe** : non développée — entité.md RH/PAIE', '- **Motif** : accès en lecture via FK Affectation.FichePaieId → FichePaie', '- **Date** : 2026-10-03', '', ].join('\n'), ) const parsed = parseExtensionRequestsDoc(`\n# x\n\n${access}`, 'x') expect(parsed.requests[0]).toMatchObject({ code: 'REQ-003', nature: 'acces', sensitivityRaw: 'confidentiel — financier', attributes: [] }) expect(parsed.warnings).toEqual([]) const extension = renderExtensionRequestBlock({ code: 'REQ-001', entity: 'Vehicule', target: { kind: 'application', app: 'GESTION_FLOTTE', module: 'VEHICULES' }, statusRaw: 'en attente', requesters: ['MEMOS'], classFile: null, reason: 'x', date: '2026-10-03', decision: null, attributes: [{ name: 'Couleur', type: 'string(20)', constraints: '—', computed: '—' }], }) expect(extension).not.toContain('**Nature**') expect(extension).not.toContain('**Sensibilité**') expect(extension).toContain('| Couleur | string(20) | — | — |') }) it('expectedAccessRequests — the three triggers, same application excluded, interne target excluded, reasons merged', () => { const planning = parseEntityDoc( [ '### ENT-001 — Affectation (agrégat racine)', '- **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', ' - Affectation *→1 Periode — FK PeriodeId, scope cross-module (RH/PAIE), onDelete restrict', ' - Affectation *→1 Memo — FK MemoId, scope cross-module (NOTES/MEMOS), onDelete restrict', '', ].join('\n'), 'NOTES/PLANNING', ).entities const paie = parseEntityDoc( [ '### ENT-001 — FichePaie (agrégat racine)', '- **Sensibilité** : confidentiel — financier', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '', '### ENT-002 — Periode (lookup)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '', '### ENT-003 — Tarif (agrégat racine)', '- **Sensibilité** : restreint — commercial', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '', ].join('\n'), 'RH/PAIE', ).entities const memos = parseEntityDoc('### ENT-001 — Memo (agrégat racine)\n- **Sensibilité** : restreint — juridique\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | — |\n', 'NOTES/MEMOS').entities const screens = [ { code: 'SCR-NOTES-PLANNING-DETAIL-001', title: 'Affectation', screenType: 'SmartForm', file: 'NOTES/PLANNING/affectations/screen.md', app: 'NOTES', module: 'PLANNING', section: 'affectations', entity: 'Affectation', declaredTabs: [ { label: 'Paies', key: 'paies', relatedEntity: 'FichePaie', relationFk: 'affectationId', displayMode: 'table', relatedApp: 'rh', line: '' }, { label: 'Mémos', key: 'memos', relatedEntity: 'Memo', relationFk: 'affectationId', displayMode: 'table', line: '' }, ], fieldTabLabels: [], }, ] const rbacRows = [ { actorCode: 'BA-001-AC-001', path: 'rh.paie.tarifs.read', portee: 'toutes' }, { actorCode: 'BA-001-AC-001', path: 'rh.paie.periodes.read', portee: 'toutes' }, { actorCode: 'BA-001-AC-001', path: 'notes.memos.memos.read', portee: 'toutes' }, ] const out = expectedAccessRequests({ app: 'NOTES', module: 'PLANNING', entities: planning, screens, rbacRows, foreign: [ { modulePath: 'RH/PAIE', entities: paie }, { modulePath: 'NOTES/MEMOS', entities: memos }, // same application — never a request ], }) expect(out.map((x) => [x.entity, x.nature, x.sensitivity, x.target])).toEqual([ ['FichePaie', 'acces', 'confidentiel — financier', { kind: 'application', app: 'RH', module: 'PAIE' }], ['Tarif', 'acces', 'restreint — commercial', { kind: 'application', app: 'RH', module: 'PAIE' }], ]) expect(out[0]!.reason).toBe('accès en lecture via FK Affectation.FichePaieId → FichePaie — Traçabilité : UC-NOTES-PLANNING-001 ; accès via l’onglet lié « Paies » de SCR-NOTES-PLANNING-DETAIL-001') expect(out[1]!.reason).toBe('accès via la permission `rh.paie.tarifs.read` (BA-001-AC-001)') expect(out.every((x) => x.attributes.length === 0)).toBe(true) }) it('reconciliation matches on nature: an extension block never covers an access expectation', () => { const doc = parseExtensionRequestsDoc( '\n# x\n\n### REQ-001 — FichePaie → RH/PAIE\n- **Statut** : validée\n- **Demandeurs** : PLANNING\n- **Classe** : x\n- **Motif** : x\n- **Date** : 2026-10-03\n\n| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Note | string | — | — |\n', 'x', ) const access = { entity: 'FichePaie', entityCode: 'ENT-001', requester: 'PLANNING', target: { kind: 'application' as const, app: 'RH', module: 'PAIE' }, nature: 'acces' as const, sensitivity: 'confidentiel — financier', classFile: null, evidence: ['ba' as const], attributes: [], reason: 'r' } const rec = reconcileExtensionRequests([access], doc) expect(rec.missing).toHaveLength(1) expect(rec.orphan.map((r) => r.code)).toEqual(['REQ-001']) const withAccess = parseExtensionRequestsDoc( '\n# x\n\n### REQ-002 — FichePaie → RH/PAIE\n- **Nature** : accès\n- **Statut** : en attente\n- **Demandeurs** : PLANNING\n- **Classe** : x\n- **Motif** : r\n- **Date** : 2026-10-03\n', 'x', ) const rec2 = reconcileExtensionRequests([access], withAccess) expect(rec2.missing).toEqual([]) expect(rec2.covered.map((c) => c.request.code)).toEqual(['REQ-002']) expect(rec2.pending.map((r) => r.code)).toEqual(['REQ-002']) }) })