Skip to content

Commit b042ac8

Browse files
committed
Pin the 23505 race arm via module mocks, not live concurrency
HTTP round-trips through createUser serialize concurrent callers so the winner typically commits its INSERT before any other caller reaches getConversation — everyone else then takes the pre-check ownership path and the 23505 arm never fires. The integration test's warn-count assertion was therefore flaky and failed in CI. - setup.race.test.ts: new deterministic unit test that uses mock.module on db/operations to throw a synthetic 23505 and drive the re-check to return a conflicting owner. Pins the conversation_create_race_23505 warn + no-generic-failure contract on every run with no DB needed. - setup.integration.test.ts: drop the warn-count assertion and the warnSpy that backed it; keep the concurrency-safety assertions (single winner, no generic failure log, DB row owned by the winner). Comment points at setup.race.test.ts for the branch-level coverage.
1 parent 12777f5 commit b042ac8

2 files changed

Lines changed: 66 additions & 13 deletions

File tree

src/services/chat/__tests__/setup.integration.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ describeIfSupabase("[integration] ensureUserAndConversation — concurrent 23505
4242
jest.restoreAllMocks();
4343
});
4444

45-
test("exactly one winner across N callers; the 23505 arm is exercised, not the pre-check arm", async () => {
46-
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
45+
test("exactly one winner under concurrency; no generic failure logged", async () => {
46+
// End-to-end concurrency lock: with N callers racing against the same
47+
// conversationId, the DB ends up with exactly one owning user, the losers
48+
// all get the standard "Access denied" string, and setup.ts never takes
49+
// the generic create_conversation_failed arm. The 23505 branch itself is
50+
// not reliably reachable from here (HTTP round-trips serialize the
51+
// createUser step so the winner typically commits before other callers
52+
// reach getConversation) — that branch is pinned deterministically by
53+
// setup.race.test.ts using module mocks.
4754
const errorSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined);
4855

4956
const results = await Promise.all(
@@ -58,17 +65,9 @@ describeIfSupabase("[integration] ensureUserAndConversation — concurrent 23505
5865
expect(loser.error).toBe("Access denied: conversation belongs to another user");
5966
}
6067

61-
// The 23505 branch is the only path that logs this message. Asserting it
62-
// fired at least once proves a racing caller actually reached the INSERT
63-
// and hit Postgres' unique-violation — i.e. the branch the fix targets
64-
// was exercised, not just the ownership-mismatch pre-check path.
65-
const raceWarns = warnSpy.mock.calls.filter(
66-
([, msg]) => msg === "conversation_create_race_23505"
67-
);
68-
expect(raceWarns.length).toBeGreaterThan(0);
69-
70-
// The race must be recognized via the errCode `in`-check, not fall through
71-
// to the generic failure arm that logs `create_conversation_failed`.
68+
// Whichever path each loser took (pre-check or 23505), none should have
69+
// landed in the generic failure arm — a resurgence of the `{...err}`
70+
// spread regression would log this for any caller that raced to INSERT.
7271
const genericFailureCall = errorSpy.mock.calls.find(
7372
([, msg]) => msg === "create_conversation_failed"
7473
);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { beforeAll, describe, expect, jest, mock, test } from "bun:test";
2+
import logger from "../../../utils/logger";
3+
4+
// Deterministic unit test for the Postgres 23505 unique-violation arm of
5+
// ensureUserAndConversation. Concurrency alone (see setup.integration.test.ts)
6+
// can't reliably force that branch because HTTP round-trips serialize the
7+
// createUser step ahead of the conversation race. Here we inject the 23505
8+
// failure directly via module mocks, so the branch is exercised on every run.
9+
describe("ensureUserAndConversation — 23505 race arm (unit)", () => {
10+
let ensureUserAndConversation: typeof import("../setup").ensureUserAndConversation;
11+
let getConversationCallCount = 0;
12+
13+
beforeAll(async () => {
14+
mock.module("../../../db/operations", () => ({
15+
createConversation: mock(async () => {
16+
throw Object.assign(new Error("duplicate key value"), { code: "23505" });
17+
}),
18+
// Unused on this path but required so dynamic import of setup.ts resolves
19+
// every named binding it declares at module scope.
20+
createConversationState: mock(async () => ({})),
21+
createState: mock(async () => ({})),
22+
createUser: mock(async () => null),
23+
// First call (pre-check): pretend the conversation doesn't exist yet.
24+
// Second call (re-check inside the 23505 arm): someone else now owns it.
25+
getConversation: mock(async () => {
26+
getConversationCallCount += 1;
27+
if (getConversationCallCount === 1) throw new Error("not found");
28+
return { id: "conv-1", user_id: "someone-else" };
29+
}),
30+
getConversationState: mock(async () => ({})),
31+
updateConversation: mock(async () => undefined),
32+
}));
33+
({ ensureUserAndConversation } = await import("../setup"));
34+
});
35+
36+
test("warns conversation_create_race_23505 and returns Access denied; no generic failure log", async () => {
37+
const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
38+
const errorSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined);
39+
40+
const result = await ensureUserAndConversation("user-1", "conv-1");
41+
42+
expect(result.success).toBe(false);
43+
expect(result.error).toBe("Access denied: conversation belongs to another user");
44+
45+
const raceWarn = warnSpy.mock.calls.find(([, msg]) => msg === "conversation_create_race_23505");
46+
expect(raceWarn).toBeDefined();
47+
48+
const genericFail = errorSpy.mock.calls.find(([, msg]) => msg === "create_conversation_failed");
49+
expect(genericFail).toBeUndefined();
50+
51+
warnSpy.mockRestore();
52+
errorSpy.mockRestore();
53+
});
54+
});

0 commit comments

Comments
 (0)