feat(ms-ai-architect): C3.1 — keychain + Learn Platform API-klient (TDD) [skip-docs]
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>
This commit is contained in:
parent
215437cb5d
commit
0390cc10ca
5 changed files with 488 additions and 0 deletions
28
scripts/kb-update/lib/keychain.mjs
Normal file
28
scripts/kb-update/lib/keychain.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// keychain.mjs — Read a single secret from the macOS Keychain at runtime.
|
||||
// Zero dependencies. The C3 course detector reads its Platform API credentials
|
||||
// straight from the Keychain (never repo/env) — see c3-course-detection-plan.md §3.
|
||||
//
|
||||
// Fail-soft: a missing item (or a non-macOS host where `security` is absent)
|
||||
// returns null so the caller can skip cleanly rather than crash the pipeline.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Read a generic-password secret from the macOS Keychain.
|
||||
* @param {string} service — the keychain item's service name (-s)
|
||||
* @param {string} [account='ktg'] — the account name (-a)
|
||||
* @param {{ execImpl?: typeof execFileSync }} [opts] — inject exec for tests
|
||||
* @returns {string|null} the secret (trimmed) or null if the item is missing
|
||||
*/
|
||||
export function readSecret(service, account = 'ktg', { execImpl = execFileSync } = {}) {
|
||||
try {
|
||||
return execImpl(
|
||||
'security',
|
||||
['find-generic-password', '-s', service, '-a', account, '-w'],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
} catch {
|
||||
// item missing / not macOS → caller fail-softs
|
||||
return null;
|
||||
}
|
||||
}
|
||||
166
scripts/kb-update/lib/learn-api.mjs
Normal file
166
scripts/kb-update/lib/learn-api.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// learn-api.mjs — Headless (no-LLM) network client for the Microsoft Learn Platform API.
|
||||
// Zero dependencies (native global `fetch`, Node 18+). Writes NO files — it only
|
||||
// fetches: token via Entra client-credentials, then paginates the per-type
|
||||
// endpoints. See c3-course-detection-plan.md §6 + the verified contract in
|
||||
// c3-course-detection-spike.md.
|
||||
//
|
||||
// Robustness contract (§6, decision §8 (a)) — what separates "works" from
|
||||
// "professional", since raw fetch has two traps:
|
||||
// 1. fetch does NOT reject on HTTP 4xx/5xx → we check response.ok explicitly.
|
||||
// 2. fetch has NO default timeout → every request gets AbortSignal.timeout().
|
||||
// Retry: 429 + 5xx only, honouring Retry-After; 4xx≠429 fails immediately; a
|
||||
// timeout propagates fail-closed (never retried, never swallowed).
|
||||
|
||||
const API_BASE = 'https://learn.microsoft.com/api/v1';
|
||||
const API_VERSION = '2023-11-01-preview';
|
||||
const SCOPE = 'https://learn.microsoft.com/.default';
|
||||
const TOKEN_ENDPOINT = (tenantId) =>
|
||||
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const VALID_ENDPOINTS = new Set(['modules', 'learning-paths']);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const BASE_BACKOFF_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Serialize a currency cursor for the `updatedAt.gt` filter.
|
||||
* Gotcha #1 (verified empirically): the API needs a FULL ISO 8601 datetime —
|
||||
* a date-only value (e.g. "2026-01-01") fails closed server-side (0 hits). We
|
||||
* reject date-only input outright and canonicalize to the UTC "Z" form.
|
||||
* @param {string} iso — a full ISO 8601 datetime
|
||||
* @returns {string} canonical full ISO datetime (…Z)
|
||||
*/
|
||||
export function buildUpdatedAtGt(iso) {
|
||||
// Require a time component with an explicit zone (Z or ±hh:mm). Date-only is rejected.
|
||||
if (typeof iso !== 'string' ||
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(iso)) {
|
||||
throw new Error(
|
||||
`updatedAt.gt needs a full ISO 8601 datetime (e.g. 2026-01-01T00:00:00Z); got: ${iso}`,
|
||||
);
|
||||
}
|
||||
return new Date(iso).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Platform API URL with the api-version pinned and params serialized.
|
||||
* Null/empty params are omitted. Comma-separated values pass through (OR semantics).
|
||||
* @param {'modules'|'learning-paths'} endpoint
|
||||
* @param {Record<string, string|null|undefined>} [params]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildApiUrl(endpoint, params = {}) {
|
||||
if (!VALID_ENDPOINTS.has(endpoint)) {
|
||||
throw new Error(`Unknown endpoint: ${endpoint} (expected 'modules' | 'learning-paths')`);
|
||||
}
|
||||
const url = new URL(`${API_BASE}/${endpoint}`);
|
||||
url.searchParams.set('api-version', API_VERSION);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the retry delay for a retryable response. Honours Retry-After
|
||||
* (delta-seconds form, the one Microsoft's rate-limiter uses); otherwise a
|
||||
* linear backoff scaled by attempt number.
|
||||
* @param {{ headers: { get(name: string): string|null } }} response
|
||||
* @param {number} attempt — 1-based
|
||||
* @returns {number} delay in ms
|
||||
*/
|
||||
function retryDelayMs(response, attempt) {
|
||||
const retryAfter = response.headers?.get?.('retry-after');
|
||||
if (retryAfter != null && retryAfter !== '') {
|
||||
const secs = Number(retryAfter);
|
||||
if (Number.isFinite(secs)) return secs * 1000;
|
||||
}
|
||||
return BASE_BACKOFF_MS * attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request with the robustness contract and return parsed JSON.
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [requestOptions]
|
||||
* @param {{ fetchImpl?, sleep?, timeoutMs?, maxAttempts? }} [opts]
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function requestJson(url, requestOptions = {}, opts = {}) {
|
||||
const {
|
||||
fetchImpl = globalThis.fetch,
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
||||
} = opts;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
attempt++;
|
||||
// Trap #2: no default timeout — abort each attempt after timeoutMs. A timeout
|
||||
// (or any network error) rejects here and propagates fail-closed; it is NOT
|
||||
// retried (only HTTP 429/5xx are).
|
||||
const response = await fetchImpl(url, {
|
||||
...requestOptions,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
// Trap #1: fetch does not reject on 4xx/5xx — check explicitly.
|
||||
if (response.ok) return response.json();
|
||||
|
||||
const status = response.status;
|
||||
const retryable = status === 429 || (status >= 500 && status < 600);
|
||||
if (retryable && attempt < maxAttempts) {
|
||||
await sleep(retryDelayMs(response, attempt));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Learn Platform API HTTP ${status} for ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a bearer token via Entra client-credentials.
|
||||
* @param {{ tenantId: string, clientId: string, clientSecret: string }} creds
|
||||
* @param {object} [opts] — passed through to requestJson (fetchImpl/timeoutMs/…)
|
||||
* @returns {Promise<string>} the access token
|
||||
*/
|
||||
export async function getToken({ tenantId, clientId, clientSecret }, opts = {}) {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
scope: SCOPE,
|
||||
});
|
||||
const json = await requestJson(
|
||||
TOKEN_ENDPOINT(tenantId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
if (!json.access_token) throw new Error('Token response had no access_token');
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate a Platform API endpoint, yielding each item across all pages.
|
||||
* The token is acquired once by the caller and reused on every request.
|
||||
* @param {'modules'|'learning-paths'} endpoint
|
||||
* @param {Record<string, string>} params — products / updatedAt.gt / maxpagesize / …
|
||||
* @param {string} token — bearer token
|
||||
* @param {object} [opts] — passed through to requestJson
|
||||
* @yields {object} an item from `value`
|
||||
*/
|
||||
export async function* paginate(endpoint, params, token, opts = {}) {
|
||||
let url = buildApiUrl(endpoint, params);
|
||||
while (url) {
|
||||
const json = await requestJson(
|
||||
url,
|
||||
{ headers: { authorization: `Bearer ${token}` } },
|
||||
opts,
|
||||
);
|
||||
for (const item of json.value ?? []) yield item;
|
||||
url = json.nextLink || null;
|
||||
}
|
||||
}
|
||||
45
tests/kb-update/test-courses-invariant.test.mjs
Normal file
45
tests/kb-update/test-courses-invariant.test.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// tests/kb-update/test-courses-invariant.test.mjs
|
||||
// Architecture-invariant guard for the C3 course-detection libs (spec §2, §6).
|
||||
// The network/secret layers are Claude-free and write NO files:
|
||||
// - learn-api.mjs is a pure network client (writes nothing, no KB/ledger).
|
||||
// - keychain.mjs reads secrets via the `security` CLI only.
|
||||
// As with test-discover-invariant, the checks are import/source-specific: a
|
||||
// comment that merely *describes* the invariant must not be able to satisfy it.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const LIB = join(__dirname, '..', '..', 'scripts', 'kb-update', 'lib');
|
||||
const learnApiSrc = readFileSync(join(LIB, 'learn-api.mjs'), 'utf8');
|
||||
const keychainSrc = readFileSync(join(LIB, 'keychain.mjs'), 'utf8');
|
||||
|
||||
const importLines = (src) => src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n');
|
||||
|
||||
test('learn-api is Claude-free (no claude/anthropic references)', () => {
|
||||
assert.doesNotMatch(learnApiSrc, /claude|anthropic/i);
|
||||
});
|
||||
|
||||
test('keychain is Claude-free (no claude/anthropic references)', () => {
|
||||
assert.doesNotMatch(keychainSrc, /claude|anthropic/i);
|
||||
});
|
||||
|
||||
test('learn-api writes NO files (no fs / atomic-write / save* imports)', () => {
|
||||
const imports = importLines(learnApiSrc);
|
||||
assert.doesNotMatch(imports, /node:fs/);
|
||||
assert.doesNotMatch(imports, /atomic-write/);
|
||||
assert.doesNotMatch(imports, /\bsaveRegistry\b|\bsaveDecisions\b|\bsaveReport\b|\bsaveTaxonomy\b/);
|
||||
});
|
||||
|
||||
test('learn-api also does not call fs write functions inline', () => {
|
||||
assert.doesNotMatch(learnApiSrc, /\bwriteFileSync\b|\brenameSync\b|\bmkdirSync\b/);
|
||||
});
|
||||
|
||||
test('keychain imports only child_process (reads via the security CLI)', () => {
|
||||
const imports = importLines(keychainSrc);
|
||||
assert.match(imports, /node:child_process/);
|
||||
assert.doesNotMatch(imports, /node:fs|atomic-write|decisions-io|registry-io/);
|
||||
});
|
||||
38
tests/kb-update/test-keychain.test.mjs
Normal file
38
tests/kb-update/test-keychain.test.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// tests/kb-update/test-keychain.test.mjs
|
||||
// Unit tests for scripts/kb-update/lib/keychain.mjs (C3.1).
|
||||
// readSecret reads a single Keychain item via the `security` CLI. Tests inject a
|
||||
// stub exec impl so no real Keychain / no real secret ever touches the suite
|
||||
// (spec §3: "Tester injiserer falske creds via en stub").
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readSecret } from '../../scripts/kb-update/lib/keychain.mjs';
|
||||
|
||||
test('readSecret — returns the trimmed secret via injected exec', () => {
|
||||
const calls = [];
|
||||
const execImpl = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
return 'secret-value\n';
|
||||
};
|
||||
const v = readSecret('learn-platform-tenant-id', 'ktg', { execImpl });
|
||||
assert.equal(v, 'secret-value');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].cmd, 'security');
|
||||
assert.deepEqual(calls[0].args, [
|
||||
'find-generic-password', '-s', 'learn-platform-tenant-id', '-a', 'ktg', '-w',
|
||||
]);
|
||||
assert.equal(calls[0].opts.encoding, 'utf8');
|
||||
});
|
||||
|
||||
test('readSecret — returns null when the item is missing (exec throws)', () => {
|
||||
const execImpl = () => { throw new Error('SecKeychain item not found'); };
|
||||
assert.equal(readSecret('does-not-exist', 'ktg', { execImpl }), null);
|
||||
});
|
||||
|
||||
test('readSecret — defaults account to ktg', () => {
|
||||
let captured;
|
||||
const execImpl = (_cmd, args) => { captured = args; return 'x'; };
|
||||
readSecret('svc', undefined, { execImpl });
|
||||
assert.ok(captured.includes('-a'));
|
||||
assert.equal(captured[captured.indexOf('-a') + 1], 'ktg');
|
||||
});
|
||||
211
tests/kb-update/test-learn-api.test.mjs
Normal file
211
tests/kb-update/test-learn-api.test.mjs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue