C3 kurs-deteksjon, fase C3.1 (spec docs/c3-course-detection-plan.md §6/§7). Claude-frie, fil-frie infrastruktur-lag for kurs-detektoren (C3.4): - lib/keychain.mjs: readSecret() leser én Keychain-item via `security`-CLI, fail-soft (manglende item / ikke-macOS → null). execImpl-DI for test. - lib/learn-api.mjs: getToken (Entra client-credentials), paginate (async-gen, følger nextLink), buildApiUrl (pinner api-version), buildUpdatedAtGt (kaster på dato-only, canon Z — gotcha #1). Robusthetskontrakt §6: response.ok-sjekk (fetch kaster ikke på 4xx/5xx), AbortSignal.timeout, 429/5xx-retry som respekterer Retry-After, 4xx≠429 ikke-retry, fail-closed. fetchImpl/sleep-DI. Tester (24, alle grønne): test-learn-api (token-body, buildUpdatedAtGt, nextLink-paginering, produkt-param, alle robusthets-asserts), test-keychain, test-courses-invariant (Claude-fri + skriver-ingen-filer). Live-probe (efemer) grønn mot ekte Platform API m/ ekte creds: token + full-enum + inkrementell. Intern infrastruktur (ingen brukervendt kommando/hook/atferd endret ennå — surfacing + docs lander i C3.6). kb-update 213→237 · validate 239 · null regresjon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
8.3 KiB
JavaScript
211 lines
8.3 KiB
JavaScript
// 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');
|
|
});
|