import { describe, expect, it } from 'vitest' import { parseSourceDoc, type SourcesIndex } from '../../../../../lib/ba-sources.js' import { buildIngest, resolveAllocation, RAW_COPY_MAX_BYTES, RENDER_HARD_CAP, RENDER_WARN_CAP, type BuildIngestInput, } from '../execute.js' import type { IngestAnalysis } from '../types.js' const TODAY = '2026-09-02' const analysis = (over: Partial = {}): IngestAnalysis => ({ title: 'Cahier des charges v2', summary: 'Le client décrit son cycle de vente complet, du prospect au devis signé.\nLa facturation est déléguée.', tags: ['processus-vente'], scopes: ['CRM'], sections: [ { title: 'Processus de vente', tags: ['processus-vente'], body: 'Cycle en 4 étapes.' }, { title: 'Plafond de remise', tags: ['regles'], body: '« Toute remise supérieure à 20 % requiert validation. »', verbatim: true, where: 'p. 12' }, ], scopeNotes: ['Rien sur les permissions.'], ...over, }) const base = (over: Partial = {}): BuildIngestInput => ({ mode: 'write', kind: 'file', format: 'pdf', fingerprint: 'a1b2c3d4e5f6', origin: { path: 'docs/cdc-v2.pdf' }, analysis: analysis(), fallbackTitle: 'cdc-v2.pdf', today: TODAY, index: null, raw: { basename: 'cdc-v2.pdf', bytes: 1024, copyRaw: true }, ...over, }) const indexWith = (codes: Record, nextSeq: number): SourcesIndex => ({ version: 1, nextSeq, sources: Object.fromEntries( Object.entries(codes).map(([code, o]) => [ code, { code, kind: 'file' as const, title: `T-${code}`, fingerprint: o.fingerprint, origin: { path: `docs/${code}.pdf` }, format: 'pdf' as const, ingestedAt: '2026-08-01', updatedAt: '2026-08-01', status: (o.status ?? 'ingested') as never, tags: ['x-tag'], scopes: [], summary: 's', sections: 1, extracts: 0, }, ]), ), }) describe('resolveAllocation', () => { it('new / existing / as', () => { const idx = indexWith({ 'SRC-001': { fingerprint: 'ffffffffffff' } }, 2) expect(resolveAllocation(null, 'abc')).toMatchObject({ kind: 'new', code: 'SRC-001' }) expect(resolveAllocation(idx, 'abcabcabcabc')).toMatchObject({ kind: 'new', code: 'SRC-002' }) expect(resolveAllocation(idx, 'ffffffffffff')).toMatchObject({ kind: 'existing', code: 'SRC-001' }) expect(resolveAllocation(idx, 'abc', 'SRC-001')).toMatchObject({ kind: 'as', code: 'SRC-001' }) }) it('`as` on an unknown code is an ERROR — as attaches, never allocates', () => { expect(resolveAllocation(null, 'abc', 'SRC-009').error).toContain('SRC-009') }) }) describe('buildIngest — write', () => { it('renders a source.md the PRODUCTION parser reads back intact (round-trip)', () => { const r = buildIngest(base()) expect(r.ok).toBe(true) if (!r.ok) return const p = parseSourceDoc(r.rendered.sourceMd, 'SRC-001/source.md') expect(p).not.toBeNull() expect(p!.code).toBe('SRC-001') expect(p!.status).toBe('ingested') expect(p!.fingerprint).toBe('a1b2c3d4e5f6') expect(p!.tags).toEqual(['processus-vente']) expect(p!.scopes).toEqual(['CRM']) expect(p!.sections).toHaveLength(2) expect(p!.sections[0]).toMatchObject({ anchor: 1, verbatim: false }) expect(p!.sections[1]).toMatchObject({ anchor: 2, verbatim: true, where: 'p. 12', tags: ['regles'] }) expect(p!.scopeNotes).toEqual(['Rien sur les permissions.']) }) it('builds the entry + a rebuilt index with the seq bumped only on NEW', () => { const r = buildIngest(base()) if (!r.ok) throw new Error('refused') expect(r.rendered.entry).toMatchObject({ code: 'SRC-001', status: 'ingested', sections: 2, extracts: 1, rawFile: 'raw/cdc-v2.pdf', ingestedAt: TODAY, updatedAt: TODAY, }) expect(r.rendered.index.nextSeq).toBe(2) // dedup hit: same fingerprint → SAME code, ingestedAt preserved, no bump const idx = r.rendered.index const again = buildIngest(base({ index: idx })) if (!again.ok) throw new Error('refused') expect(again.allocation).toMatchObject({ kind: 'existing', code: 'SRC-001' }) expect(again.rendered.index.nextSeq).toBe(2) expect(Object.keys(again.rendered.index.sources)).toEqual(['SRC-001']) }) it('REFUSES a web source with zero verbatim extract', () => { const r = buildIngest( base({ kind: 'web', origin: { url: 'https://exemple.ch/normes', fetchedAt: TODAY }, raw: undefined, analysis: analysis({ sections: [{ title: 'Généralités', tags: ['vocabulaire'], body: 'Du blabla non sourcé.' }] }), }), ) expect(r.ok).toBe(false) if (r.ok) return expect(r.errors.join(' ')).toContain('verbatim') }) it('accepts a web source WITH a verbatim extract and renders the fetch date', () => { const r = buildIngest( base({ kind: 'web', origin: { url: 'https://exemple.ch/normes', fetchedAt: TODAY }, raw: undefined, }), ) expect(r.ok).toBe(true) if (!r.ok) return expect(r.rendered.sourceMd).toContain('récupérée le 2026-09-02') expect(r.rendered.entry.extracts).toBe(1) }) it('hard cap refuses, warn cap warns — never a silent dump', () => { const hard = buildIngest( base({ analysis: analysis({ summary: 'x'.repeat(RENDER_HARD_CAP + 10) }) }), ) expect(hard.ok).toBe(false) if (!hard.ok) expect(hard.errors.join(' ')).toContain('SCINDE') const warn = buildIngest( base({ analysis: analysis({ summary: 'x'.repeat(RENDER_WARN_CAP + 10) }) }), ) expect(warn.ok).toBe(true) if (warn.ok) expect(warn.rendered.warnings.join(' ')).toContain('dump') }) it('raw over the cap → rawOmitted + warning, path stays the only trace', () => { const r = buildIngest(base({ raw: { basename: 'gros.pdf', bytes: RAW_COPY_MAX_BYTES + 1, copyRaw: true } })) if (!r.ok) throw new Error('refused') expect(r.rendered.entry.rawFile).toBeUndefined() expect(r.rendered.entry.rawOmitted).toBe(true) expect(r.rendered.warnings.join(' ')).toContain('raw/ non copié') }) it('`as` attach keeps the ORIGINAL origin and flips the status to ingested', () => { const idx = indexWith({ 'SRC-003': { fingerprint: 'oldoldoldold', status: 'blocked/needs-export' } }, 4) const r = buildIngest( base({ asCode: 'SRC-003', fingerprint: 'newnewnewnew', origin: { path: 'exports/cdc-v2.md' }, index: idx, }), ) if (!r.ok) throw new Error('refused') expect(r.rendered.entry.code).toBe('SRC-003') expect(r.rendered.entry.status).toBe('ingested') expect(r.rendered.entry.origin).toEqual({ path: 'docs/SRC-003.pdf' }) expect(r.rendered.entry.fingerprint).toBe('newnewnewnew') expect(r.rendered.index.nextSeq).toBe(4) }) it('supersedes flips the OLD entry (status + supersededBy) — codes never die', () => { const idx = indexWith({ 'SRC-001': { fingerprint: 'oldoldoldold' } }, 2) const r = buildIngest(base({ index: idx, supersedes: 'SRC-001', fingerprint: 'newnewnewnew' })) if (!r.ok) throw new Error('refused') expect(r.rendered.entry.code).toBe('SRC-002') expect(r.rendered.index.sources['SRC-001']).toMatchObject({ status: 'superseded', supersededBy: 'SRC-002', updatedAt: TODAY }) // refusals: unknown code, self-replacement const bad = buildIngest(base({ index: idx, supersedes: 'SRC-009', fingerprint: 'newnewnewnew' })) expect(bad.ok).toBe(false) const self = buildIngest(base({ index: idx, supersedes: 'SRC-002', fingerprint: 'newnewnewnew' })) expect(self.ok).toBe(false) }) it('scrubs secrets before anything reaches the rendered doc or the summary', () => { const r = buildIngest( base({ analysis: analysis({ summary: 'Le serveur utilise password: hunter22 pour le SGBD, à remplacer avant la mise en production.', }), }), ) if (!r.ok) throw new Error('refused') expect(r.rendered.sourceMd).not.toContain('hunter22') expect(r.rendered.sourceMd).toContain('') expect(r.rendered.entry.summary).not.toContain('hunter22') }) }) describe('buildIngest — register-blocked', () => { it('reserves the code with a TYPED status and a parseable skeleton doc', () => { const r = buildIngest( base({ mode: 'register-blocked', format: 'msg', analysis: undefined, reason: 'format .msg sans extracteur', blockedStatus: 'blocked/needs-export', }), ) if (!r.ok) throw new Error('refused') expect(r.rendered.entry).toMatchObject({ status: 'blocked/needs-export', sections: 0, extracts: 0, tags: [] }) const p = parseSourceDoc(r.rendered.sourceMd, 'SRC-001/source.md') expect(p).not.toBeNull() expect(p!.status).toBe('blocked/needs-export') expect(p!.resume).toContain('NON LU') expect(p!.scopeNotes.join(' ')).toContain('"as":"SRC-001"') }) })