// tests/kb-update/test-decisions-io.test.mjs // Unit tests for scripts/kb-update/lib/decisions-io.mjs — the lag-2 decision // ledger (data/decisions.json), the ONLY write-authorized bridge between // detection and the KB/registry. Dedup policy = A (operatør 2026-06-19): // isDecided(url) is true for ANY ledger entry (pending | approved | rejected), // so discovery re-proposes only URLs entirely absent from the ledger. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createLedger, loadDecisions, saveDecisions, isDecided, recordDecision, filterUndecided, } from '../../scripts/kb-update/lib/decisions-io.mjs'; function withTmp(fn) { const dir = mkdtempSync(join(tmpdir(), 'dec-test-')); try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } const U1 = 'https://learn.microsoft.com/azure/foundry/concepts/built-in-evaluators'; const U2 = 'https://learn.microsoft.com/azure/databricks/sql/language-manual/sql-ref-sharing'; const U3 = 'https://learn.microsoft.com/azure/ai-services/openai/whats-new'; // --- scaffold --- test('createLedger — empty scaffold shape', () => { const led = createLedger(); assert.equal(led.version, 1); assert.equal(led.updated_at, null); assert.deepEqual(led.decisions, {}); }); // --- load / save (atomic) --- test('loadDecisions — missing file returns empty scaffold', () => { withTmp((dir) => { const led = loadDecisions(dir); assert.equal(led.version, 1); assert.deepEqual(led.decisions, {}); }); }); test('saveDecisions + loadDecisions — round-trips', () => { withTmp((dir) => { let led = createLedger(); led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19', suggested_skill: 'ms-ai-engineering', suggested_category: 'data-engineering', }); saveDecisions(led, dir); const back = loadDecisions(dir); assert.deepEqual(back, led); }); }); test('saveDecisions — leaves no .tmp orphan and writes decisions.json', () => { withTmp((dir) => { saveDecisions(createLedger(), dir); const files = readdirSync(dir); assert.ok(existsSync(join(dir, 'decisions.json'))); assert.deepEqual(files.filter((f) => f.includes('.tmp')), []); }); }); // --- isDecided — DEDUP POLICY A --- test('isDecided — true for ANY entry status (pending | approved | rejected)', () => { let led = createLedger(); led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19' }); led = recordDecision(led, U2, { status: 'approved', decided_at: '2026-06-19' }); led = recordDecision(led, U3, { status: 'pending', decided_at: '2026-06-19' }); assert.equal(isDecided(led, U1), true, 'rejected must dedup'); assert.equal(isDecided(led, U2), true, 'approved must dedup (policy A)'); assert.equal(isDecided(led, U3), true, 'pending must dedup (policy A)'); }); test('isDecided — false for URL absent from ledger', () => { const led = createLedger(); assert.equal(isDecided(led, U1), false); }); // --- recordDecision — pure --- test('recordDecision — sets entry, returns new ledger, does not mutate input', () => { const led = createLedger(); const next = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19', suggested_skill: 'ms-ai-engineering', suggested_category: 'data-engineering', note: 'off-domain databricks page', }); assert.equal(next.decisions[U1].status, 'rejected'); assert.equal(next.decisions[U1].note, 'off-domain databricks page'); // input untouched assert.deepEqual(led.decisions, {}); assert.notEqual(next, led); }); test('recordDecision — advances updated_at to the decision date', () => { const led = createLedger(); const next = recordDecision(led, U1, { status: 'approved', decided_at: '2026-06-19' }); assert.equal(next.updated_at, '2026-06-19'); }); // --- filterUndecided — the dedup gate discovery applies --- test('filterUndecided — drops decided candidates (any status), keeps fresh ones', () => { let led = createLedger(); led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19' }); led = recordDecision(led, U2, { status: 'approved', decided_at: '2026-06-19' }); const candidates = [ { url: U1, suggested_skill: 'ms-ai-engineering' }, // rejected → drop { url: U2, suggested_skill: 'ms-ai-engineering' }, // approved → drop { url: U3, suggested_skill: 'ms-ai-engineering' }, // fresh → keep ]; const out = filterUndecided(led, candidates); assert.deepEqual(out.map((c) => c.url), [U3]); }); // --- ACCEPTANCE CRITERION (roadmap §165): discovery 2x does not re-propose a rejected URL --- test('dedup-diff — a rejected URL is re-proposed in round 1 but NOT in round 2', () => { // Round 1: empty ledger — the URL is a fresh candidate, passes the filter. let led = createLedger(); const round1 = filterUndecided(led, [{ url: U1, suggested_skill: 'ms-ai-engineering' }]); assert.deepEqual(round1.map((c) => c.url), [U1], 'round 1 should surface the new URL'); // Operatør rejects it through the gate — the ONLY write path. led = recordDecision(led, U1, { status: 'rejected', decided_at: '2026-06-19' }); // Round 2: same discovery output, now the rejected URL must be filtered out. const round2 = filterUndecided(led, [{ url: U1, suggested_skill: 'ms-ai-engineering' }]); assert.deepEqual(round2, [], 'round 2 must NOT re-propose the rejected URL'); });