From af7eb7a1838c38eb8a1b9bd3bd70af3b22d4d428 Mon Sep 17 00:00:00 2001 From: Lan Nguyen Si Date: Tue, 30 Jun 2026 10:14:49 +0200 Subject: [PATCH 1/2] test(coverage): cover scheduler + embed provider + generate cmd; enforce coverage in CI Closes the 4 agent-memory audit gaps (2 MED + 2 LOW). Toolchain is node:test. M1 agent-memory-sync cron scheduler: 28 tests for parseCron (wildcard/step/ range/list + 10 invalid-expr throws) and nextScheduleTick (step/range/list, hour+day-of-week boundaries, month + year-end rollover, the 525600-iteration cap), with exact-timestamp value assertions. M2 memory-router embed/provider.ts: 11 tests stubbing global fetch for embedBatch (ok -> exact URL/method/headers/body + sorted vectors; non-ok 429/500 -> throws; network + AbortError propagation; body-read fallback) and resolveProviderConfig (missing key -> null, default/custom model, custom baseUrl). L2 memory-digest-cli generate command: 8 tests for registerGenerateCommand option parsing (6 defaults, overrides, boolean flags, short aliases). L1 CI coverage enforcement: each package's test:coverage now passes --test-coverage-{lines,branches,functions} thresholds (node:test native, src only via --test-coverage-exclude); the CI step runs test:coverage so the gate is enforced. Calibrated below measured, negative-control verified. Also fixes two real CI gaps surfaced while wiring coverage: - memory-router `test` glob `tests/**/*.test.ts` was unquoted, so the shell expanded it to one-level subdirs and the 19 top-level tests/*.test.ts files (tool, query-cache, ...) never ran in CI. Quoting it lets Node's native glob run the full suite: 43 -> 231 tests now execute. - agent-memory-sync scheduler test now loads src (under tsx) instead of dist, so coverage measures source and the suite no longer depends on a prior build (the package had no pretest:build). Switched the package to node --import tsx. Refs: tc-audit-2026-06-27 --- .github/workflows/ci.yml | 8 +- packages/agent-memory-sync/package.json | 4 +- .../tests/unit/scheduler.test.ts | 200 +++++++++++ packages/memory-digest-cli/package.json | 2 +- .../memory-digest-cli/tests/generate.test.ts | 156 +++++++++ packages/memory-router/package.json | 3 +- .../tests/unit/embed-provider.test.ts | 315 ++++++++++++++++++ 7 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 packages/agent-memory-sync/tests/unit/scheduler.test.ts create mode 100644 packages/memory-digest-cli/tests/generate.test.ts create mode 100644 packages/memory-router/tests/unit/embed-provider.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be2ac87..e3f2183 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,5 +59,9 @@ jobs: - name: Lint run: npm run lint --if-present - - name: Test - run: npm test --if-present + - name: Test + coverage gate + # test:coverage runs the suite under node --test --experimental-test-coverage + # with --test-coverage-{lines,branches,functions} thresholds, so coverage + # regressions fail the build. Previously CI ran plain `npm test` (no + # coverage) and the declared thresholds were never evaluated. + run: npm run test:coverage --if-present diff --git a/packages/agent-memory-sync/package.json b/packages/agent-memory-sync/package.json index f667ad0..a3272df 100644 --- a/packages/agent-memory-sync/package.json +++ b/packages/agent-memory-sync/package.json @@ -15,8 +15,8 @@ "lint": "npm run typecheck", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test --experimental-strip-types tests/**/*.test.ts", - "test:coverage": "node --test --experimental-strip-types --experimental-test-coverage tests/**/*.test.ts" + "test": "node --import tsx --test 'tests/**/*.test.ts'", + "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-lines=86 --test-coverage-branches=65 --test-coverage-functions=86 'tests/**/*.test.ts'" }, "engines": { "node": ">=20" diff --git a/packages/agent-memory-sync/tests/unit/scheduler.test.ts b/packages/agent-memory-sync/tests/unit/scheduler.test.ts new file mode 100644 index 0000000..85bae20 --- /dev/null +++ b/packages/agent-memory-sync/tests/unit/scheduler.test.ts @@ -0,0 +1,200 @@ +// Unit tests for the cron scheduler. +// +// parseCron is not exported directly; it is exercised via: +// - validateCronExpression (throws on invalid input) +// - nextScheduleTick (verifies correct set membership by computing exact +// next-run times) +// +// All nextScheduleTick assertions use a fixed local-clock reference date so +// that results are deterministic regardless of the host timezone. +// waitMs = cursor.getTime() – after.getTime() is pure arithmetic and +// timezone-agnostic. Date-component assertions (getDate, getMonth, …) use +// local-time methods consistent with what the scheduler itself uses. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +// Load from TypeScript source so coverage measures src/ (the suite runs under +// tsx, which resolves scheduler.ts's extensionless `require("../errors")` that +// Node's --experimental-strip-types could not). +const { validateCronExpression, nextScheduleTick } = require("../../src/memory-sync/scheduler"); + +// ─── parseCron (via validateCronExpression) ────────────────────────────────── + +test("parseCron: wildcard '*' in all fields is valid", () => { + assert.doesNotThrow(() => validateCronExpression("* * * * *")); +}); + +test("parseCron: step */15 in minute field is valid", () => { + assert.doesNotThrow(() => validateCronExpression("*/15 * * * *")); +}); + +test("parseCron: range 1-5 in minute field is valid", () => { + assert.doesNotThrow(() => validateCronExpression("1-5 * * * *")); +}); + +test("parseCron: list 1,3,5 in minute field is valid", () => { + assert.doesNotThrow(() => validateCronExpression("1,3,5 * * * *")); +}); + +test("parseCron: fixed value in hour and minute fields is valid", () => { + assert.doesNotThrow(() => validateCronExpression("30 12 * * *")); +}); + +test("parseCron: multi-field expression with range, list, step is valid", () => { + assert.doesNotThrow(() => validateCronExpression("0 2 1-15 3,6 1-5")); +}); + +test("parseCron: step on range token (5-30/5) is valid", () => { + assert.doesNotThrow(() => validateCronExpression("5-30/5 * * * *")); +}); + +test("parseCron: invalid — too few fields throws CliError", () => { + assert.throws(() => validateCronExpression("* * * *"), /invalid/); +}); + +test("parseCron: invalid — too many fields throws CliError", () => { + assert.throws(() => validateCronExpression("* * * * * *"), /invalid/); +}); + +test("parseCron: invalid — minute 60 (out of range 0-59) throws CliError", () => { + assert.throws(() => validateCronExpression("60 * * * *"), /outside the allowed range/); +}); + +test("parseCron: invalid — hour 24 (out of range 0-23) throws CliError", () => { + assert.throws(() => validateCronExpression("* 24 * * *"), /outside the allowed range/); +}); + +test("parseCron: invalid — dayOfMonth 0 (out of range 1-31) throws CliError", () => { + assert.throws(() => validateCronExpression("* * 0 * *"), /outside the allowed range/); +}); + +test("parseCron: invalid — dayOfMonth 32 (out of range 1-31) throws CliError", () => { + assert.throws(() => validateCronExpression("* * 32 * *"), /outside the allowed range/); +}); + +test("parseCron: invalid — month 0 (out of range 1-12) throws CliError", () => { + assert.throws(() => validateCronExpression("* * * 0 *"), /outside the allowed range/); +}); + +test("parseCron: invalid — month 13 (out of range 1-12) throws CliError", () => { + assert.throws(() => validateCronExpression("* * * 13 *"), /outside the allowed range/); +}); + +test("parseCron: invalid — dayOfWeek 7 (out of range 0-6) throws CliError", () => { + assert.throws(() => validateCronExpression("* * * * 7"), /outside the allowed range/); +}); + +test("parseCron: invalid — range with start > end throws CliError", () => { + assert.throws(() => validateCronExpression("5-3 * * * *"), /invalid/); +}); + +test("parseCron: invalid — non-numeric field value throws CliError", () => { + assert.throws(() => validateCronExpression("* * * * abc"), /outside the allowed range/); +}); + +// ─── nextScheduleTick ──────────────────────────────────────────────────────── +// +// Reference date: Thursday, 2026-01-15, 10:30:00 local time. +// January 15, 2026 is a Thursday (getDay() === 4). + +const REF = () => new Date(2026, 0, 15, 10, 30, 0, 0); + +test("nextScheduleTick: wildcard '* * * * *' fires 1 minute after reference", () => { + const tick = nextScheduleTick("* * * * *", REF()); + assert.equal(tick.waitMs, 60_000, "should wait exactly 1 minute"); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getMinutes(), 31); + assert.equal(runAt.getHours(), 10); +}); + +test("nextScheduleTick: step */15 from :30 fires at :45 — 15 minutes away", () => { + // minutes in set: {0, 15, 30, 45}. cursor starts at :31, next match = :45 + const tick = nextScheduleTick("*/15 * * * *", REF()); + assert.equal(tick.waitMs, 15 * 60_000, "should wait 15 minutes"); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getMinutes(), 45); + assert.equal(runAt.getHours(), 10); +}); + +test("nextScheduleTick: step */15 from :00 fires at :15 — 15 minutes away", () => { + // cursor starts at :01 (after minute+1), walks to :15 + const ref = new Date(2026, 0, 15, 10, 0, 0, 0); + const tick = nextScheduleTick("*/15 * * * *", ref); + assert.equal(tick.waitMs, 15 * 60_000); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getMinutes(), 15); + assert.equal(runAt.getHours(), 10); +}); + +test("nextScheduleTick: range 1-5 from :00 fires at :01 — 1 minute away", () => { + const ref = new Date(2026, 0, 15, 10, 0, 0, 0); + const tick = nextScheduleTick("1-5 * * * *", ref); + assert.equal(tick.waitMs, 60_000); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getMinutes(), 1); +}); + +test("nextScheduleTick: list 1,3,5 from :02 fires at :03 — 1 minute away", () => { + const ref = new Date(2026, 0, 15, 10, 2, 0, 0); + const tick = nextScheduleTick("1,3,5 * * * *", ref); + assert.equal(tick.waitMs, 60_000); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getMinutes(), 3); +}); + +test("nextScheduleTick: fixed hour '0 11 * * *' from 10:30 fires at 11:00", () => { + // 30 minutes to reach 11:00 + const tick = nextScheduleTick("0 11 * * *", REF()); + assert.equal(tick.waitMs, 30 * 60_000, "should wait 30 minutes for 11:00"); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getHours(), 11); + assert.equal(runAt.getMinutes(), 0); + assert.equal(runAt.getDate(), 15); +}); + +test("nextScheduleTick: day-of-week constraint — next Monday noon from Thursday", () => { + // REF is Thursday Jan 15; next Monday is Jan 19. + // "0 12 * * 1" = noon on Monday + const tick = nextScheduleTick("0 12 * * 1", REF()); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getDay(), 1, "must land on a Monday"); + assert.equal(runAt.getHours(), 12); + assert.equal(runAt.getMinutes(), 0); + assert.equal(runAt.getDate(), 19, "next Monday is Jan 19"); + assert.equal(runAt.getMonth(), 0, "still in January"); +}); + +test("nextScheduleTick: month rollover — '0 0 1 * *' from Jan 31 midnight fires Feb 1", () => { + // Start at end of January; first-of-month midnight requires crossing to Feb. + const ref = new Date(2026, 0, 31, 0, 0, 0, 0); + const tick = nextScheduleTick("0 0 1 * *", ref); + // 24 hours from Jan 31 00:00 to Feb 1 00:00 (no DST between Jan and Feb) + assert.equal(tick.waitMs, 24 * 60 * 60_000, "should wait exactly 24 hours"); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getDate(), 1); + assert.equal(runAt.getMonth(), 1, "February = month index 1"); + assert.equal(runAt.getFullYear(), 2026); + assert.equal(runAt.getHours(), 0); + assert.equal(runAt.getMinutes(), 0); +}); + +test("nextScheduleTick: year-end rollover — '0 0 1 1 *' from Dec 31 midnight fires Jan 1 next year", () => { + const ref = new Date(2026, 11, 31, 0, 0, 0, 0); + const tick = nextScheduleTick("0 0 1 1 *", ref); + // 24 hours from Dec 31 00:00 to Jan 1 00:00 + assert.equal(tick.waitMs, 24 * 60 * 60_000); + const runAt = new Date(tick.runAt); + assert.equal(runAt.getDate(), 1); + assert.equal(runAt.getMonth(), 0, "January = month index 0"); + assert.equal(runAt.getFullYear(), 2027); + assert.equal(runAt.getHours(), 0); + assert.equal(runAt.getMinutes(), 0); +}); + +test("nextScheduleTick: iteration cap — impossible date '0 0 30 2 *' throws after 525600 iterations", { timeout: 10_000 }, () => { + // February never has 30 days; the loop exhausts 525600 iterations and throws. + assert.throws( + () => nextScheduleTick("0 0 30 2 *", new Date(2026, 0, 1, 0, 0, 0, 0)), + (err: Error) => err.message.includes("could not compute next run") + ); +}); diff --git a/packages/memory-digest-cli/package.json b/packages/memory-digest-cli/package.json index eb6feb2..eaadbb8 100644 --- a/packages/memory-digest-cli/package.json +++ b/packages/memory-digest-cli/package.json @@ -16,7 +16,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "tsx --test tests/*.test.ts", - "test:coverage": "tsx --test --experimental-test-coverage tests/*.test.ts" + "test:coverage": "tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-lines=90 --test-coverage-branches=80 --test-coverage-functions=92 tests/*.test.ts" }, "engines": { "node": ">=20" diff --git a/packages/memory-digest-cli/tests/generate.test.ts b/packages/memory-digest-cli/tests/generate.test.ts new file mode 100644 index 0000000..410f0bd --- /dev/null +++ b/packages/memory-digest-cli/tests/generate.test.ts @@ -0,0 +1,156 @@ +// Unit tests for packages/memory-digest-cli/src/commands/generate.ts +// +// Strategy: Register the command on a fresh Commander instance, then REPLACE +// the action handler with a spy before calling parseAsync. This drives the +// option-parsing layer without touching the filesystem or any external lib. +// +// The action override is the standard Commander API — calling .action() again +// replaces the previously registered handler. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Command } from "commander"; +import { registerGenerateCommand } from "../src/commands/generate.js"; + +// Helper: create a fresh program with the generate subcommand registered and +// return references to both. Each test call gets a completely isolated +// Commander tree so option-parser state never bleeds between tests. +function buildProgram(): { program: Command; generateCmd: Command } { + const program = new Command(); + // Prevent Commander from calling process.exit on unknown args or --help. + program.exitOverride(); + program.allowUnknownOption(false); + + registerGenerateCommand(program); + + const generateCmd = program.commands.find((c) => c.name() === "generate"); + assert.ok(generateCmd, "generate subcommand must be registered by registerGenerateCommand"); + return { program, generateCmd: generateCmd! }; +} + +// ─── registration ──────────────────────────────────────────────────────────── + +test("registerGenerateCommand: registers a subcommand named 'generate'", () => { + const { generateCmd } = buildProgram(); + assert.equal(generateCmd.name(), "generate"); +}); + +test("registerGenerateCommand: subcommand has a non-empty description", () => { + const { generateCmd } = buildProgram(); + assert.ok( + generateCmd.description().length > 0, + "description should not be empty" + ); +}); + +// ─── default option values ─────────────────────────────────────────────────── + +test("generate: default options are correct when no flags are passed", async () => { + const { program, generateCmd } = buildProgram(); + + let capturedOptions: Record | undefined; + generateCmd.action((opts: Record) => { + capturedOptions = opts; + }); + + await program.parseAsync(["generate"], { from: "user" }); + + assert.ok(capturedOptions !== undefined, "action must have been called"); + assert.equal(capturedOptions.dir, process.cwd(), "--dir default must be process.cwd()"); + assert.equal(capturedOptions.days, "7", "--days default must be '7'"); + assert.equal(capturedOptions.max, "50", "--max default must be '50'"); + assert.equal(capturedOptions.recursive, false, "--recursive default must be false"); + assert.equal(capturedOptions.json, false, "--json default must be false"); + assert.equal(capturedOptions.output, undefined, "--output default must be undefined"); +}); + +// ─── non-default flag mappings ─────────────────────────────────────────────── + +test("generate: --days and --max override their defaults", async () => { + const { program, generateCmd } = buildProgram(); + + let capturedOptions: Record | undefined; + generateCmd.action((opts: Record) => { + capturedOptions = opts; + }); + + await program.parseAsync( + ["generate", "--days", "30", "--max", "100"], + { from: "user" } + ); + + assert.equal(capturedOptions!.days, "30"); + assert.equal(capturedOptions!.max, "100"); + // Unaffected defaults must remain intact. + assert.equal(capturedOptions!.recursive, false); + assert.equal(capturedOptions!.json, false); +}); + +test("generate: --recursive and --json boolean flags are set to true", async () => { + const { program, generateCmd } = buildProgram(); + + let capturedOptions: Record | undefined; + generateCmd.action((opts: Record) => { + capturedOptions = opts; + }); + + await program.parseAsync(["generate", "--recursive", "--json"], { from: "user" }); + + assert.equal(capturedOptions!.recursive, true, "--recursive must become true"); + assert.equal(capturedOptions!.json, true, "--json must become true"); +}); + +test("generate: -d short alias sets the dir option", async () => { + const { program, generateCmd } = buildProgram(); + + let capturedOptions: Record | undefined; + generateCmd.action((opts: Record) => { + capturedOptions = opts; + }); + + await program.parseAsync(["generate", "-d", "/tmp/my-memories"], { from: "user" }); + + assert.equal(capturedOptions!.dir, "/tmp/my-memories"); +}); + +test("generate: -o short alias sets the output option", async () => { + const { program, generateCmd } = buildProgram(); + + let capturedOptions: Record | undefined; + generateCmd.action((opts: Record) => { + capturedOptions = opts; + }); + + await program.parseAsync(["generate", "-o", "digest.md"], { from: "user" }); + + assert.equal(capturedOptions!.output, "digest.md"); +}); + +test("generate: all non-default flags can be passed together", async () => { + const { program, generateCmd } = buildProgram(); + + let capturedOptions: Record | undefined; + generateCmd.action((opts: Record) => { + capturedOptions = opts; + }); + + await program.parseAsync( + [ + "generate", + "--dir", "/tmp/memories", + "--output", "out.json", + "--days", "14", + "--max", "200", + "--recursive", + "--json", + ], + { from: "user" } + ); + + assert.equal(capturedOptions!.dir, "/tmp/memories"); + assert.equal(capturedOptions!.output, "out.json"); + assert.equal(capturedOptions!.days, "14"); + assert.equal(capturedOptions!.max, "200"); + assert.equal(capturedOptions!.recursive, true); + assert.equal(capturedOptions!.json, true); +}); diff --git a/packages/memory-router/package.json b/packages/memory-router/package.json index 907acf5..9b4f400 100644 --- a/packages/memory-router/package.json +++ b/packages/memory-router/package.json @@ -45,7 +45,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "pretest": "npm run build", - "test": "node --import tsx --test tests/**/*.test.ts" + "test": "node --import tsx --test 'tests/**/*.test.ts'", + "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-lines=60 --test-coverage-branches=73 --test-coverage-functions=62 'tests/**/*.test.ts'" }, "engines": { "node": ">=22" diff --git a/packages/memory-router/tests/unit/embed-provider.test.ts b/packages/memory-router/tests/unit/embed-provider.test.ts new file mode 100644 index 0000000..bcf26ec --- /dev/null +++ b/packages/memory-router/tests/unit/embed-provider.test.ts @@ -0,0 +1,315 @@ +// Unit tests for packages/memory-router/src/embed/provider.ts +// +// Covers: embedBatch (ok response, non-ok responses, network errors, AbortError) +// and resolveProviderConfig (env-var branches, missing key, custom model/baseUrl). +// +// globalThis.fetch is stubbed per-test with save/restore in try/finally. +// process.env is mutated and restored per-test. +// Node:test runs top-level tests serially by default — no concurrency conflicts. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { embedBatch, resolveProviderConfig } = require('../../src/embed/provider'); + +// ─── embedBatch ────────────────────────────────────────────────────────────── + +test('embedBatch: ok response — returns sorted embeddings and validates request', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + let capturedUrl: string | undefined; + let capturedInit: RequestInit | undefined; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async (url: string, init?: RequestInit) => { + capturedUrl = url; + capturedInit = init; + return { + ok: true, + status: 200, + statusText: 'OK', + // Return data OUT of order to exercise the sort-by-index path. + json: async () => ({ + data: [ + { embedding: [0.2, 0.3], index: 1 }, + { embedding: [0.1, 0.2], index: 0 }, + ], + }), + text: async () => '', + } as unknown as Response; + }) as unknown as typeof fetch; + + const result = await embedBatch({ + apiKey: 'sk-test-key', + model: 'text-embedding-3-small', + inputs: ['hello', 'world'], + }); + + // Returned vectors must be sorted by index (index 0 first). + assert.deepEqual(result, [[0.1, 0.2], [0.2, 0.3]]); + + // Request URL must be the OpenAI default base + /v1/embeddings. + assert.equal(capturedUrl, 'https://api.openai.com/v1/embeddings'); + + // Method must be POST. + const init = capturedInit as RequestInit; + assert.equal(init.method, 'POST'); + + // Authorization and Content-Type headers must be correct. + const headers = init.headers as Record; + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['Authorization'], 'Bearer sk-test-key'); + + // Body must encode model + input array. + const body = JSON.parse(init.body as string) as { model: string; input: string[] }; + assert.equal(body.model, 'text-embedding-3-small'); + assert.deepEqual(body.input, ['hello', 'world']); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +test('embedBatch: custom baseUrl — trailing slash is stripped from URL', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + let capturedUrl: string | undefined; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async (url: string) => { + capturedUrl = url; + return { + ok: true, + json: async () => ({ data: [{ embedding: [0.5], index: 0 }] }), + text: async () => '', + } as unknown as Response; + }) as unknown as typeof fetch; + + await embedBatch({ + apiKey: 'sk-test', + model: 'any', + inputs: ['x'], + baseUrl: 'https://custom.api.com/', + }); + + assert.equal(capturedUrl, 'https://custom.api.com/v1/embeddings', + 'trailing slash must be stripped before appending /v1/embeddings'); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +test('embedBatch: non-ok 429 → throws "embedding request failed"', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async () => ({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => 'rate limited', + } as unknown as Response)) as unknown as typeof fetch; + + await assert.rejects( + () => embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'] }), + (err: Error) => { + assert.ok( + err.message.includes('embedding request failed'), + `expected "embedding request failed" in: ${err.message}` + ); + assert.ok(err.message.includes('429')); + return true; + } + ); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +test('embedBatch: non-ok 500 → throws "embedding request failed"', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'server error body', + } as unknown as Response)) as unknown as typeof fetch; + + await assert.rejects( + () => embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'] }), + (err: Error) => { + assert.ok(err.message.includes('embedding request failed')); + assert.ok(err.message.includes('500')); + return true; + } + ); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +test('embedBatch: network error — fetch rejection propagates as-is', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async () => { + throw new Error('network failure'); + }) as unknown as typeof fetch; + + await assert.rejects( + () => embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'] }), + (err: Error) => { + assert.equal(err.message, 'network failure'); + return true; + } + ); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +test('embedBatch: AbortError from fetch propagates unchanged', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async () => { + // DOMException with name 'AbortError' is what AbortSignal.timeout fires. + throw new DOMException('The operation was aborted.', 'AbortError'); + }) as unknown as typeof fetch; + + await assert.rejects( + () => embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'], timeoutMs: 1 }), + (err: DOMException) => { + assert.equal(err.name, 'AbortError'); + return true; + } + ); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +test('embedBatch: res.text() failure in non-ok path falls back to ""', async () => { + const origFetch = (globalThis as { fetch?: typeof fetch }).fetch; + + try { + (globalThis as { fetch: typeof fetch }).fetch = (async () => ({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + // text() rejects — should be caught and replaced with '' + text: async () => { throw new Error('cannot read body'); }, + } as unknown as Response)) as unknown as typeof fetch; + + await assert.rejects( + () => embedBatch({ apiKey: 'k', model: 'm', inputs: ['x'] }), + (err: Error) => { + assert.ok(err.message.includes('embedding request failed')); + assert.ok(err.message.includes('')); + return true; + } + ); + } finally { + if (origFetch) { + (globalThis as { fetch: typeof fetch }).fetch = origFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + } +}); + +// ─── resolveProviderConfig ─────────────────────────────────────────────────── + +test('resolveProviderConfig: OPENAI_API_KEY missing → returns null', () => { + const orig = process.env.OPENAI_API_KEY; + try { + delete process.env.OPENAI_API_KEY; + const cfg = resolveProviderConfig(); + assert.equal(cfg, null); + } finally { + if (orig === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = orig; + } +}); + +test('resolveProviderConfig: OPENAI_API_KEY set → returns config with default model', () => { + const origKey = process.env.OPENAI_API_KEY; + const origModel = process.env.MEMORY_ROUTER_EMBED_MODEL; + const origBase = process.env.OPENAI_BASE_URL; + try { + process.env.OPENAI_API_KEY = 'sk-real-key'; + delete process.env.MEMORY_ROUTER_EMBED_MODEL; + delete process.env.OPENAI_BASE_URL; + + const cfg = resolveProviderConfig(); + assert.ok(cfg !== null); + assert.equal(cfg.apiKey, 'sk-real-key'); + assert.equal(cfg.model, 'text-embedding-3-small', 'default model must be text-embedding-3-small'); + assert.equal(cfg.baseUrl, undefined); + } finally { + if (origKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = origKey; + if (origModel === undefined) delete process.env.MEMORY_ROUTER_EMBED_MODEL; + else process.env.MEMORY_ROUTER_EMBED_MODEL = origModel; + if (origBase === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = origBase; + } +}); + +test('resolveProviderConfig: MEMORY_ROUTER_EMBED_MODEL overrides default model', () => { + const origKey = process.env.OPENAI_API_KEY; + const origModel = process.env.MEMORY_ROUTER_EMBED_MODEL; + try { + process.env.OPENAI_API_KEY = 'sk-key'; + process.env.MEMORY_ROUTER_EMBED_MODEL = 'text-embedding-3-large'; + + const cfg = resolveProviderConfig(); + assert.ok(cfg !== null); + assert.equal(cfg.model, 'text-embedding-3-large'); + } finally { + if (origKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = origKey; + if (origModel === undefined) delete process.env.MEMORY_ROUTER_EMBED_MODEL; + else process.env.MEMORY_ROUTER_EMBED_MODEL = origModel; + } +}); + +test('resolveProviderConfig: OPENAI_BASE_URL is forwarded', () => { + const origKey = process.env.OPENAI_API_KEY; + const origBase = process.env.OPENAI_BASE_URL; + try { + process.env.OPENAI_API_KEY = 'sk-key'; + process.env.OPENAI_BASE_URL = 'https://my-proxy.example.com'; + + const cfg = resolveProviderConfig(); + assert.ok(cfg !== null); + assert.equal(cfg.baseUrl, 'https://my-proxy.example.com'); + } finally { + if (origKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = origKey; + if (origBase === undefined) delete process.env.OPENAI_BASE_URL; + else process.env.OPENAI_BASE_URL = origBase; + } +}); From dcf3b3ffd23d4b48910c7c075df6f7207e198c61 Mon Sep 17 00:00:00 2001 From: Lan Nguyen Si Date: Tue, 30 Jun 2026 10:25:34 +0200 Subject: [PATCH 2/2] test(coverage): fold reviewer notes: exclude dist from memory-router coverage Adversarial review (9/9 prod mutations killed) flagged that memory-router's coverage number double-counted dist + src: tests/coverage/real-corpus.test.ts loads ../../dist/*, and --test-coverage-exclude only dropped tests/**, so low-coverage compiled modules (drift.js 13%, applier.js 8%, ...) polluted the figure (65.28) the thresholds were calibrated against. - Add --test-coverage-exclude='dist/**' so the gate measures source only; true src coverage is 96.10/85.80/85.84, so recalibrated the thresholds to 90/80/80 (was 60/73/62, far too low). Negative-control verified. - Add pretest:coverage=npm run build so `npm run test:coverage` works on a fresh local checkout (real-corpus.test.ts requires dist; CI already had an explicit Build step so CI was unaffected). Pre-existing scheduler step=0 infinite-loop and the generate.ts action-body coverage are tracked in a follow-up (out of scope for this test PR). Refs: tc-audit-2026-06-27 --- packages/memory-router/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/memory-router/package.json b/packages/memory-router/package.json index 9b4f400..3c98b52 100644 --- a/packages/memory-router/package.json +++ b/packages/memory-router/package.json @@ -46,7 +46,8 @@ "format:check": "prettier --check .", "pretest": "npm run build", "test": "node --import tsx --test 'tests/**/*.test.ts'", - "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-lines=60 --test-coverage-branches=73 --test-coverage-functions=62 'tests/**/*.test.ts'" + "pretest:coverage": "npm run build", + "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-coverage-exclude='dist/**' --test-coverage-lines=90 --test-coverage-branches=80 --test-coverage-functions=80 'tests/**/*.test.ts'" }, "engines": { "node": ">=22"