// tests/kb-update/test-course-diff.test.mjs // Unit tests for scripts/kb-update/lib/course-diff.mjs (C3.2) — the PURE course // diff + registry-fold + run-mode selector, plus the course-registry load/save // round-trip (registry-io). Mirrors the diff semantics in c3-course-detection-plan.md // §4.1/§4.2 and the §8 decisions: // (b) a baseline run emits NO leads (registry-establishing only), // (c) `removed` is computed ONLY on a full enumeration — never incrementally, // where an updatedAt.gt response omits everything unchanged and a naive // removed-diff would flag nearly the whole registry (false-positive flood). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { diffCourses, updateRegistry, selectMode, isFullEnumDue, normalizeCourse, FULL_ENUM_MAX_AGE_DAYS, } from '../../scripts/kb-update/lib/course-diff.mjs'; import { loadCourseRegistry, saveCourseRegistry } from '../../scripts/kb-update/lib/registry-io.mjs'; const NOW = '2026-06-23T12:00:00Z'; // A normalized course (the shape diffCourses/updateRegistry operate on). function course(uid, updated_at, over = {}) { return { uid, type: 'module', title: `t-${uid}`, url: `https://learn.microsoft.com/training/modules/${uid}`, products: ['azure-openai'], updated_at, ...over, }; } // A stored registry entry. function entry(updated_at, over = {}) { return { type: 'module', title: 'stored', url: 'stored-url', products: [], updated_at, first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z', ...over, }; } function registryWith(courses = {}, meta = {}) { return { version: 1, created_at: '2026-01-01T00:00:00Z', last_run: '2026-06-20T00:00:00Z', last_full_enum: '2026-06-20T00:00:00Z', courses, ...meta, }; } // --- normalizeCourse ------------------------------------------------------- test('normalizeCourse — maps API fields to registry shape (products flattened)', () => { const api = { id: 'learn.az.x', title: 'X', url: 'https://learn.microsoft.com/training/modules/x', updatedAt: '2026-06-01T00:00:00Z', products: [{ id: 'azure-openai' }, { id: 'ai-builder' }], }; const c = normalizeCourse(api, 'module'); assert.equal(c.uid, 'learn.az.x'); assert.equal(c.type, 'module'); assert.equal(c.title, 'X'); assert.equal(c.url, 'https://learn.microsoft.com/training/modules/x'); assert.equal(c.updated_at, '2026-06-01T00:00:00Z'); assert.deepEqual(c.products, ['azure-openai', 'ai-builder']); }); test('normalizeCourse — missing fields degrade safely', () => { const c = normalizeCourse({ id: 'u1' }, 'learning-path'); assert.equal(c.uid, 'u1'); assert.equal(c.type, 'learning-path'); assert.equal(c.title, ''); assert.equal(c.url, ''); assert.deepEqual(c.products, []); assert.equal(c.updated_at, null); }); // --- diffCourses: new / updated / no-op ------------------------------------ test('diffCourses — new UID (in API, not in registry) → new lead', () => { const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], registryWith({}), { mode: 'incremental' }); assert.equal(out.new.length, 1); assert.equal(out.new[0].uid, 'u1'); assert.equal(out.updated.length, 0); assert.deepEqual(out.removed, []); }); test('diffCourses — newer updatedAt on a known UID → updated lead', () => { const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') }); const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], reg, { mode: 'incremental' }); assert.equal(out.updated.length, 1); assert.equal(out.updated[0].uid, 'u1'); assert.equal(out.updated[0].updated_at, '2026-06-22T00:00:00Z'); assert.equal(out.new.length, 0); }); test('diffCourses — identical updatedAt → no-op (no lead)', () => { const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') }); const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' }); assert.equal(out.new.length, 0); assert.equal(out.updated.length, 0); }); test('diffCourses — older updatedAt → no-op (never a downgrade lead)', () => { const reg = registryWith({ u1: entry('2026-06-10T00:00:00Z') }); const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' }); assert.equal(out.new.length, 0); assert.equal(out.updated.length, 0); }); // --- diffCourses: baseline emits no leads (§8 (b)) ------------------------- test('diffCourses — baseline (empty registry) emits NO leads (§8 (b) tom-baseline)', () => { const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} }; const out = diffCourses( [course('u1', '2026-06-01T00:00:00Z'), course('u2', '2026-06-02T00:00:00Z')], empty, { mode: 'baseline' }, ); assert.deepEqual(out, { new: [], updated: [], removed: [] }); }); test('diffCourses — baseline emits no leads even against a non-empty registry', () => { const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') }); const out = diffCourses([course('u1', '2026-06-22T00:00:00Z'), course('u2', '2026-06-22T00:00:00Z')], reg, { mode: 'baseline' }); assert.deepEqual(out, { new: [], updated: [], removed: [] }); }); // --- full-enum-pin: `removed` only on full enumeration (§4.2) -------------- test('diffCourses — removed is emitted ONLY in full mode (§4.2 correctness pin)', () => { const reg = registryWith({ gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', url: 'gone-url' }), keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', url: 'keep-url' }), }); const apiSet = [course('keep', '2026-01-01T00:00:00Z')]; // 'gone' absent from the full API set const out = diffCourses(apiSet, reg, { mode: 'full' }); assert.deepEqual(out.removed.map((r) => r.uid), ['gone']); assert.equal(out.removed[0].title, 'Gone'); assert.equal(out.removed[0].url, 'gone-url'); }); test('diffCourses — incremental NEVER emits removed, even when every registry UID is absent from the filtered response', () => { const reg = registryWith({ a: entry('2026-01-01T00:00:00Z'), b: entry('2026-01-01T00:00:00Z') }); // An updatedAt.gt response with nothing changed returns []: all registry UIDs // are "missing" — a naive removed-diff would flag the whole registry. const out = diffCourses([], reg, { mode: 'incremental' }); assert.deepEqual(out.removed, []); }); test('diffCourses — baseline never emits removed', () => { const reg = registryWith({ a: entry('2026-01-01T00:00:00Z') }); const out = diffCourses([], reg, { mode: 'baseline' }); assert.deepEqual(out.removed, []); }); // --- selectMode / isFullEnumDue (full-enum kadens) ------------------------- test('selectMode — no last_run → baseline', () => { assert.equal(selectMode({ last_run: null, last_full_enum: null, courses: {} }, NOW), 'baseline'); }); test('selectMode — recent full enum → incremental', () => { const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-06-20T00:00:00Z' }); assert.equal(selectMode(reg, NOW), 'incremental'); }); test('selectMode — last_full_enum missing but has run → full', () => { const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: null }); assert.equal(selectMode(reg, NOW), 'full'); }); test('selectMode — last_full_enum older than 30 days → full', () => { const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-05-01T00:00:00Z' }); assert.equal(selectMode(reg, NOW), 'full'); }); test('isFullEnumDue — exactly 30 days is NOT due; one second over IS due', () => { // NOW = 2026-06-23T12:00:00Z; exactly 30 days earlier = 2026-05-24T12:00:00Z. assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T12:00:00Z' }, NOW), false); assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T11:59:59Z' }, NOW), true); }); test('FULL_ENUM_MAX_AGE_DAYS — pinned at 30', () => { assert.equal(FULL_ENUM_MAX_AGE_DAYS, 30); }); // --- updateRegistry (pure fold) -------------------------------------------- test('updateRegistry — incremental merges (keep untouched, update changed, add new) and leaves last_full_enum', () => { const reg = registryWith( { keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }), chg: entry('2026-01-01T00:00:00Z', { title: 'Old', first_seen: '2026-02-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }), }, { last_full_enum: '2026-06-20T00:00:00Z' }, ); const next = updateRegistry( reg, [course('chg', '2026-06-22T00:00:00Z', { title: 'New' }), course('add', '2026-06-21T00:00:00Z')], NOW, { mode: 'incremental' }, ); // untouched survives, last_seen NOT advanced assert.ok(next.courses.keep); assert.equal(next.courses.keep.last_seen, '2026-06-20T00:00:00Z'); // changed: updated_at refreshed, first_seen preserved, last_seen advanced assert.equal(next.courses.chg.title, 'New'); assert.equal(next.courses.chg.updated_at, '2026-06-22T00:00:00Z'); assert.equal(next.courses.chg.first_seen, '2026-02-01T00:00:00Z'); assert.equal(next.courses.chg.last_seen, NOW); // new: first_seen = now assert.equal(next.courses.add.first_seen, NOW); assert.equal(next.courses.add.last_seen, NOW); // metadata assert.equal(next.last_run, NOW); assert.equal(next.last_full_enum, '2026-06-20T00:00:00Z'); }); test('updateRegistry — full rebuilds from the API set (drops retired UIDs) and stamps last_full_enum', () => { const reg = registryWith( { gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', first_seen: '2026-01-01T00:00:00Z' }), keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-03-01T00:00:00Z' }), }, { last_full_enum: '2026-05-01T00:00:00Z' }, ); const next = updateRegistry(reg, [course('keep', '2026-01-01T00:00:00Z')], NOW, { mode: 'full' }); assert.deepEqual(Object.keys(next.courses), ['keep']); // 'gone' dropped assert.equal(next.courses.keep.first_seen, '2026-03-01T00:00:00Z'); // preserved assert.equal(next.courses.keep.last_seen, NOW); assert.equal(next.last_full_enum, NOW); assert.equal(next.last_run, NOW); }); test('updateRegistry — baseline on empty registry seeds created_at + last_full_enum', () => { const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} }; const next = updateRegistry(empty, [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' }); assert.equal(next.created_at, NOW); assert.equal(next.last_run, NOW); assert.equal(next.last_full_enum, NOW); assert.ok(next.courses.u1); assert.equal(next.courses.u1.first_seen, NOW); }); test('updateRegistry — does not mutate the input registry', () => { const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') }); const before = JSON.stringify(reg); updateRegistry(reg, [course('u1', '2026-06-22T00:00:00Z')], NOW, { mode: 'incremental' }); assert.equal(JSON.stringify(reg), before); }); // --- course-registry load/save round-trip (registry-io) -------------------- test('loadCourseRegistry — absent file returns the empty scaffold (schema incl. last_full_enum)', () => { const dir = mkdtempSync(join(tmpdir(), 'course-reg-')); try { assert.deepEqual(loadCourseRegistry(dir), { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {}, }); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('saveCourseRegistry → loadCourseRegistry round-trips deeply', () => { const dir = mkdtempSync(join(tmpdir(), 'course-reg-')); try { const reg = updateRegistry(loadCourseRegistry(dir), [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' }); saveCourseRegistry(reg, dir); assert.deepEqual(loadCourseRegistry(dir), reg); } finally { rmSync(dir, { recursive: true, force: true }); } });