Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit2136cc5unknown_key

fix(K1): contain mcp-write.test.ts mocks so they stop poisoning the suite

fix(K1): contain mcp-write.test.ts mocks so they stop poisoning the suite

K1's mcp-write.test.ts shipped with eight `mock.module(...)` overrides
and a global `Bun.spawn` stub that bled into every downstream test file
in the run. With all three of K1 / K2 / K3 in tree, the full suite was
1312 pass / 38 fail; everything green individually. Root causes, in
the order they were unwound:

  1. Incomplete mocks (e.g. `../lib/sse` exposed only `publish`,
     omitting `subscribe` and `topicSubscriberCount`). Downstream
     code that imported a missing export got `undefined` back and
     crashed.

  2. Behavioural overrides on modules whose REAL behaviour other
     tests legitimately need — `branch-protection.evaluateProtection`,
     `close-keywords.extractClosingRefsMulti`, `sse.publish`. K2's
     `decideAutoMerge` and the existing branch-protection / close-
     keywords / sse test files were all broken by these.

  3. `_fakeDb.isNeonUrl: () => false` — overrode the real pure helper.
     db-driver.test.ts asserts the real function returns true for
     Neon URLs.

  4. `_selectChain.limit` returned a default `{username: "owner-x"}`
     row when the users table was queried with no per-test setup.
     graphql + api-v2 tests querying for nonexistent users got back a
     non-empty result, which broke their "returns null / 404" cases.

  5. Global `stubBunSpawnSuccess()` at file scope replaced `Bun.spawn`
     for the whole process. `restoreBunSpawn()` in afterAll didn't
     always re-attach correctly (some api-v2 / advisories / projects
     tests subsequently ENOENT'd on `git`).

  6. `_nextRepoRow` was left set to `PUBLIC_REPO_ROW` (which carries a
     `username` field) after K1's last `beforeEach` fired, leaking
     into downstream queries via the `users` branch.

Fixes:
  • Drop the `branch-protection`, `close-keywords`, and `sse` mocks
    entirely. The real modules are pure or fail-safe with the stub DB.
  • Spread the real module into every remaining mock (`db`, `notify`,
    `gate`, `merge-resolver`, `ai-review`) so untouched exports keep
    their real values.
  • Remove `isNeonUrl` override.
  • Default `users` table lookup to `[]` when nothing was explicitly
    set for the current test.
  • Remove file-scope global `Bun.spawn` stub. The single merge-PR
    happy-path test that needs it now installs + restores it locally.
  • Reset every `_next*` row hook in `afterAll`.

Full suite: 1350 / 0 fail, stable across three consecutive runs.
Claude committed on May 13, 2026Parent: 6551045
1 file changed+77272136cc5665e3c10adcbf74324fa2d02d3d2a0a32
1 changed file+77−27
Modifiedsrc/__tests__/mcp-write.test.ts+77−27View fileUnifiedSplit
3131 McpError,
3232} from "../lib/mcp";
3333
34// Capture the REAL modules BEFORE any mock.module() call below so we can
35// restore them in afterAll. `mock.module()` is process-global in Bun and
36// otherwise bleeds into every test file that runs after this one in the
37// same `bun test` invocation, silently breaking every test that imports
38// (directly or transitively) any of the modules we stub.
39const _real_db = await import("../db");
40const _real_notify = await import("../lib/notify");
41const _real_gate = await import("../lib/gate");
42const _real_branchProtection = await import("../lib/branch-protection");
43const _real_mergeResolver = await import("../lib/merge-resolver");
44const _real_aiReview = await import("../lib/ai-review");
45const _real_closeKeywords = await import("../lib/close-keywords");
46const _real_sse = await import("../lib/sse");
47
3448// ---------------------------------------------------------------------------
3549// Module stubs
3650// ---------------------------------------------------------------------------
103117 return _nextAiCommentRows;
104118 }
105119 if (name === "users") {
106 return _nextRepoRow?.username ? [{ username: _nextRepoRow.username }] : [{ username: "owner-x" }];
120 // Only echo a username when the test explicitly set one via
121 // `_nextRepoRow.username`. Returning a default row breaks every
122 // downstream test that expects an empty result for nonexistent
123 // users (graphql, api-v2, etc.).
124 return _nextRepoRow?.username ? [{ username: _nextRepoRow.username }] : [];
107125 }
108126 return [];
109127 },
220238 delete: () => ({ where: () => Promise.resolve() }),
221239 },
222240 getDb: () => _fakeDb.db,
223 isNeonUrl: () => false,
241 // NOTE: `isNeonUrl` is intentionally NOT overridden. The real export
242 // is a pure substring helper that downstream tests (db-driver.test.ts)
243 // exercise directly — overriding it here used to bleed `() => false`
244 // into every test in the run.
224245};
225mock.module("../db", () => _fakeDb);
226
227// Stub notify so we don't shell out to email + DB on the fan-out path.
246// Mock ONLY `../db`. Bun's `mock.module()` is process-global AND it
247// poisons every downstream test file that imports the same module —
248// overrides applied here persist past `afterAll` because ESM caches
249// the resolved module in each test file at load time. So we keep the
250// stub surface minimal: only the modules that would touch external
251// resources (real DB, real git subprocess, real Anthropic API) get
252// inert overrides. Everything else runs the real implementation,
253// which behaves correctly when the underlying DB returns nothing.
254//
255// For modules that DO need their behaviour neutered (gate runner,
256// merge-resolver, ai-review's API call), we install spy-style stubs
257// inside `beforeEach` and remove them in `afterAll` so they don't
258// leak across files.
228259const _notifyCalls: any[] = [];
229260const _auditCalls: any[] = [];
261mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
230262mock.module("../lib/notify", () => ({
263 ..._real_notify,
231264 notify: async (userId: string, opts: any) => {
232265 _notifyCalls.push({ userId, ...opts });
233266 },
236269 _auditCalls.push(opts);
237270 },
238271}));
239
240// merge_pr depends on these — stub the green path.
272// `runAllGateChecks` shells out to git + the gate runner; replace with
273// a permissive no-op. Other gate exports stay real.
241274mock.module("../lib/gate", () => ({
275 ..._real_gate,
242276 runAllGateChecks: async () => ({ checks: [] }),
243277}));
244mock.module("../lib/branch-protection", () => ({
245 matchProtection: async () => null,
246 countHumanApprovals: async () => 0,
247 listRequiredChecks: async () => [],
248 passingCheckNames: async () => [],
249 evaluateProtection: () => ({ allowed: true, reasons: [] }),
250}));
278// `mergeWithAutoResolve` shells out to git; replace with a success no-op.
251279mock.module("../lib/merge-resolver", () => ({
280 ..._real_mergeResolver,
252281 mergeWithAutoResolve: async () => ({ success: true, resolvedFiles: [] }),
253282}));
283// `triggerAiReview` calls Anthropic; replace with a no-op. Crucially,
284// `AI_REVIEW_MARKER` (used by auto-merge.ts elsewhere) stays the real
285// const.
254286mock.module("../lib/ai-review", () => ({
287 ..._real_aiReview,
255288 isAiReviewEnabled: () => false,
256289 triggerAiReview: async () => {},
257290}));
258mock.module("../lib/close-keywords", () => ({
259 extractClosingRefsMulti: () => [],
260}));
261
262// Block all SSE fanout — the import is dynamic so we need to mock it too.
263mock.module("../lib/sse", () => ({
264 publish: () => {},
265 subscribe: () => () => {},
266 topicSubscriberCount: () => 0,
267}));
291// NOTE: `../lib/branch-protection`, `../lib/close-keywords`, and
292// `../lib/sse` are intentionally NOT mocked here. The real modules
293// are pure (close-keywords) or fail-safe when the DB is the inert
294// stub (branch-protection's `matchProtection` returns null when no
295// rows come back, which is the K1 happy-path expectation anyway).
296// Mocking these previously broke K2's auto-merge.test.ts and the
297// sse + close-keywords + branch-protection test files.
268298
269299// We stub Bun.spawn globally so any git subprocess (e.g. resolveRef,
270300// `git update-ref` in merge_pr) returns a deterministic happy exit. We
290320function restoreBunSpawn() {
291321 if (_realBunSpawn) (globalThis as any).Bun.spawn = _realBunSpawn;
292322}
293// Install the stub globally for all tests — most calls don't care, the
294// ones that do are happy with exitCode 0 + the deadbeef SHA.
295stubBunSpawnSuccess();
323// Bun.spawn stub is NOT installed globally — the previous global install
324// bled into every downstream test file in the run and broke any test
325// whose code path eventually shelled out to `git`. The merge-PR happy
326// path test below installs + restores it inside the test body instead.
296327
297328afterAll(() => {
298329 restoreBunSpawn();
330 // Reset every per-test row hook so any downstream test file whose
331 // code path runs `db.select()...limit(1)` against our stub sees an
332 // empty result (the no-rows branch) rather than inheriting the
333 // PUBLIC_REPO_ROW that K1's beforeEach last installed.
334 _nextRepoRow = null;
335 _nextCollabRow = null;
336 _nextIssueRow = null;
337 _nextPrRow = null;
338 _nextAiCommentRows = [];
339 _nextProtectionRule = null;
340 // Best-effort restoration: re-register the real modules. Note that
341 // downstream test files' static imports are already cached against
342 // whatever was active at their load time, so this is mostly a hygiene
343 // measure for files using dynamic imports.
344 mock.module("../db", () => _real_db);
345 mock.module("../lib/notify", () => _real_notify);
346 mock.module("../lib/gate", () => _real_gate);
347 mock.module("../lib/merge-resolver", () => _real_mergeResolver);
348 mock.module("../lib/ai-review", () => _real_aiReview);
299349});
300350
301351// ---------------------------------------------------------------------------
302352