// tests/kb-update/test-learn-api.test.mjs // Unit tests for scripts/kb-update/lib/learn-api.mjs (C3.1) — the Claude-free // network client for the Microsoft Learn Platform API. Covers the gate criteria // in c3-course-detection-plan.md §7 (C3.1): // - token-body building (client-credentials) // - buildUpdatedAtGt: throws on date-only, emits full ISO (gotcha #1) // - nextLink pagination + product param // - robustness contract (§6, decision §8 (a)): non-ok HTTP throws (fetch does // not), 429 retries respecting Retry-After, 4xx≠429 not retried, timeout // (AbortSignal) propagates fail-closed. // All network is stubbed via injected fetchImpl/sleep — no live calls in CI. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildUpdatedAtGt, buildApiUrl, getToken, paginate, } from '../../scripts/kb-update/lib/learn-api.mjs'; // --- test helpers --------------------------------------------------------- // A minimal Response-shaped object: the wrapper relies on .ok, .status, // .headers.get(), and .json() — nothing else. function res(body, { status = 200, headers = {} } = {}) { return { ok: status >= 200 && status < 300, status, headers: new Headers(headers), json: async () => body, }; } // fetch stub: a queue of responses (or (url,options)=>response fns). Records calls. function stubFetch(queue) { const calls = []; const impl = async (url, options) => { calls.push({ url, options }); const next = queue.shift(); if (next === undefined) throw new Error('stubFetch: no more queued responses'); return typeof next === 'function' ? next(url, options) : next; }; impl.calls = calls; return impl; } // sleep spy: records requested delays, resolves immediately (no real waiting). function spySleep() { const delays = []; const fn = async (ms) => { delays.push(ms); }; fn.delays = delays; return fn; } async function drain(asyncIter) { const out = []; for await (const x of asyncIter) out.push(x); return out; } // --- buildUpdatedAtGt (gotcha #1: full ISO required) ---------------------- test('buildUpdatedAtGt — throws on date-only input (fail-closed on server)', () => { assert.throws(() => buildUpdatedAtGt('2026-01-01'), /full ISO|datetime/i); }); test('buildUpdatedAtGt — accepts full ISO and emits canonical Z form', () => { assert.equal(buildUpdatedAtGt('2026-01-01T00:00:00Z'), '2026-01-01T00:00:00.000Z'); // an explicit offset is normalized to UTC Z assert.equal(buildUpdatedAtGt('2026-01-01T02:00:00+02:00'), '2026-01-01T00:00:00.000Z'); }); // --- buildApiUrl (api-version pin + product param) ------------------------ test('buildApiUrl — pins api-version and serializes the product param', () => { const u = new URL(buildApiUrl('modules', { products: 'azure-openai' })); assert.equal(u.pathname, '/api/v1/modules'); assert.equal(u.searchParams.get('api-version'), '2023-11-01-preview'); assert.equal(u.searchParams.get('products'), 'azure-openai'); }); test('buildApiUrl — comma-separated products survive (OR semantics)', () => { const u = new URL(buildApiUrl('modules', { products: 'azure-openai,power-automate' })); assert.equal(u.searchParams.get('products'), 'azure-openai,power-automate'); }); test('buildApiUrl — learning-paths is the kebab-case endpoint', () => { const u = new URL(buildApiUrl('learning-paths', {})); assert.equal(u.pathname, '/api/v1/learning-paths'); }); test('buildApiUrl — rejects an unknown endpoint', () => { assert.throws(() => buildApiUrl('catalog', {}), /endpoint/i); }); test('buildApiUrl — omits null/empty params', () => { const u = new URL(buildApiUrl('modules', { products: '', 'updatedAt.gt': null })); assert.equal(u.searchParams.has('products'), false); assert.equal(u.searchParams.has('updatedAt.gt'), false); }); // --- getToken (client-credentials body) ----------------------------------- test('getToken — builds the client-credentials body and returns access_token', async () => { const fetchImpl = stubFetch([res({ access_token: 'tok-123', expires_in: 3599 })]); const token = await getToken( { tenantId: 'TENANT', clientId: 'CLIENT', clientSecret: 'SECRET' }, { fetchImpl }, ); assert.equal(token, 'tok-123'); const { url, options } = fetchImpl.calls[0]; assert.equal(url, 'https://login.microsoftonline.com/TENANT/oauth2/v2.0/token'); assert.equal(options.method, 'POST'); const body = options.body; // URLSearchParams assert.equal(body.get('grant_type'), 'client_credentials'); assert.equal(body.get('client_id'), 'CLIENT'); assert.equal(body.get('client_secret'), 'SECRET'); assert.equal(body.get('scope'), 'https://learn.microsoft.com/.default'); }); test('getToken — throws if the response lacks an access_token', async () => { const fetchImpl = stubFetch([res({ error: 'invalid_client' })]); await assert.rejects( getToken({ tenantId: 't', clientId: 'c', clientSecret: 's' }, { fetchImpl }), /access_token/i, ); }); // --- paginate (nextLink cursor) ------------------------------------------- test('paginate — follows nextLink across pages and yields all items', async () => { const fetchImpl = stubFetch([ res({ value: [{ id: 'a' }, { id: 'b' }], nextLink: 'https://learn.microsoft.com/api/v1/modules?page=2' }), res({ value: [{ id: 'c' }], nextLink: null }), ]); const items = []; for await (const it of paginate('modules', { products: 'azure-openai' }, 'tok', { fetchImpl })) { items.push(it.id); } assert.deepEqual(items, ['a', 'b', 'c']); // the second request uses the nextLink verbatim assert.equal(fetchImpl.calls[1].url, 'https://learn.microsoft.com/api/v1/modules?page=2'); // bearer token on every request assert.equal(fetchImpl.calls[0].options.headers.authorization, 'Bearer tok'); assert.equal(fetchImpl.calls[1].options.headers.authorization, 'Bearer tok'); }); test('paginate — empty value array yields nothing', async () => { const fetchImpl = stubFetch([res({ value: [], nextLink: null })]); assert.equal((await drain(paginate('modules', {}, 'tok', { fetchImpl }))).length, 0); }); // --- robustness contract (§6 / decision §8 (a)) --------------------------- test('robustness — non-ok status throws (fetch does not; the wrapper must)', async () => { const fetchImpl = stubFetch([res({}, { status: 404 })]); const sleep = spySleep(); await assert.rejects(drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })), /404/); assert.equal(sleep.delays.length, 0, '404 is not retried'); }); test('robustness — 429 retries and respects Retry-After', async () => { const fetchImpl = stubFetch([ res({}, { status: 429, headers: { 'retry-after': '2' } }), res({ value: [{ id: 'a' }], nextLink: null }), ]); const sleep = spySleep(); const items = await drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })); assert.deepEqual(items.map((i) => i.id), ['a']); assert.deepEqual(sleep.delays, [2000], 'waited Retry-After seconds, in ms'); }); test('robustness — 4xx other than 429 is not retried (immediate throw)', async () => { const fetchImpl = stubFetch([res({}, { status: 401 })]); const sleep = spySleep(); await assert.rejects(drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })), /401/); assert.equal(fetchImpl.calls.length, 1, '401 is not retried'); assert.equal(sleep.delays.length, 0); }); test('robustness — 5xx retries up to maxAttempts then fails closed', async () => { const fetchImpl = stubFetch([ res({}, { status: 503 }), res({}, { status: 503 }), res({}, { status: 503 }), ]); const sleep = spySleep(); await assert.rejects( drain(paginate('modules', {}, 'tok', { fetchImpl, sleep, maxAttempts: 3 })), /503/, ); assert.equal(fetchImpl.calls.length, 3, 'three attempts'); assert.equal(sleep.delays.length, 2, 'slept between the three attempts'); }); test('robustness — timeout (AbortSignal) propagates fail-closed, not retried', async () => { let signalSeen = null; const fetchImpl = async (_url, options) => { signalSeen = options.signal; throw new DOMException('The operation timed out.', 'TimeoutError'); }; const sleep = spySleep(); await assert.rejects( drain(paginate('modules', {}, 'tok', { fetchImpl, sleep })), (e) => e.name === 'TimeoutError', ); assert.ok(signalSeen instanceof AbortSignal, 'the request wired an AbortSignal.timeout'); assert.equal(sleep.delays.length, 0, 'a timeout is not retried'); });