Skip to content

Commit 970db1c

Browse files
authored
Run integration tests in CI against a Supabase local stack (#166)
CI: new `integration` job in .github/workflows/ci.yml gated on the fast `test` job. Spins up Redis 7 and Supabase via supabase/setup-cli, installs pdf-parse native deps, exports SUPABASE_URL/SUPABASE_SERVICE_KEY/SUPABASE_ANON_KEY from `supabase status -o env` (fails fast on missing vars), and runs `bun test --test-name-pattern '\[integration\]'` with RUN_PDF_INTEGRATION=1. Drop the `pull_request.branches: [main, dev]` filter so stacked PRs targeting feature branches run lint/test/ integration; push events stay scoped to main/dev to avoid double runs. Stack: supabase/config.toml enables api/db/auth only — auth kept because `supabase status -o env` omits keys when it is off and supabase-js requires a key string even for unauthenticated PostgREST. Other services disabled for a fast cold start. Tests are tagged [integration] and gated by helpers in src/utils/__testHelpers__/integrationEnv.ts that skip on empty/whitespace/"undefined"/"null". DB-touching tests use dynamic imports because db/operations instantiates the Supabase client at module load. - description.integration.test.ts: corrupted-PDF buffer through the now-exported extractPDFText asserts a real error message surfaces and logger.error fires with err.message, pinning the regression where `{...error}` produced message: undefined. - setup.integration.test.ts: concurrent ensureUserAndConversation with the same conversationId — one winner, ownership-mismatch loser, no generic create_conversation_failed log. - cleanValues.integration.test.ts: legacy rawFiles buffer+parsedText migration on updateState round-trip. - setup.race.test.ts: deterministic unit test that mocks db/operations to throw a synthetic 23505 and pins the new conversation_create_race_23505 warn from setup.ts. HTTP round-trips serialize concurrent callers, so live-concurrency tests rarely drive the race arm. setup.ts emits the conversation_create_race_23505 warn from its 23505 branch so the race arm is observable. AGENTS.md adds an "Integration tests" subsection documenting the local workflow (supabase start → export env → bun test pattern).
1 parent 52b7d06 commit 970db1c

12 files changed

Lines changed: 810 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
name: CI
22
on:
3+
# Run on every PR regardless of base branch so stacked PRs (e.g. targeting
4+
# another feature branch) still get verified before merge.
35
pull_request:
4-
branches: [main, dev]
56
push:
67
branches: [main, dev]
78

@@ -45,24 +46,67 @@ jobs:
4546
- name: Test
4647
run: bun test
4748

48-
docker-build:
49-
name: Docker Build
50-
if: github.event_name == 'pull_request'
49+
integration:
50+
name: Integration (Supabase + Redis)
51+
needs: [test]
5152
runs-on: ubuntu-latest
53+
services:
54+
redis:
55+
image: redis:7-alpine
56+
ports: ["6379:6379"]
57+
options: >-
58+
--health-cmd "redis-cli ping"
59+
--health-interval 10s
60+
--health-timeout 5s
61+
--health-retries 5
5262
steps:
5363
- uses: actions/checkout@v4
5464

55-
- uses: docker/setup-buildx-action@v3
65+
- uses: oven-sh/setup-bun@v2
66+
67+
- run: bun install --frozen-lockfile
68+
69+
- name: Install pdf-parse native deps
70+
run: |
71+
sudo apt-get update
72+
sudo apt-get install -y --no-install-recommends \
73+
libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
5674
57-
- name: Build image (load locally, no push)
58-
uses: docker/build-push-action@v5
75+
- uses: supabase/setup-cli@v1
5976
with:
60-
context: .
61-
push: false
62-
load: true
63-
cache-from: type=gha
64-
cache-to: type=gha,mode=max
65-
tags: biosagent/bios:pr-${{ github.event.pull_request.number }}
66-
67-
- name: Smoke-test image
68-
run: docker run --rm biosagent/bios:pr-${{ github.event.pull_request.number }} bun --version
77+
version: latest
78+
79+
- name: Start Supabase (applies migrations automatically)
80+
run: supabase start
81+
82+
- name: Export Supabase env vars
83+
shell: bash
84+
run: |
85+
set -euo pipefail
86+
supabase status -o env \
87+
| sed -n -E 's/^API_URL="?([^"]+)"?$/SUPABASE_URL=\1/p;
88+
s/^SERVICE_ROLE_KEY="?([^"]+)"?$/SUPABASE_SERVICE_KEY=\1/p;
89+
s/^ANON_KEY="?([^"]+)"?$/SUPABASE_ANON_KEY=\1/p' \
90+
>> "$GITHUB_ENV"
91+
# Fail fast if the `supabase status` format drifts — otherwise the
92+
# integration tests would silently skip (describeIfSupabase degrades
93+
# to describe.skip when env is absent) and the job would turn green.
94+
for key in SUPABASE_URL SUPABASE_SERVICE_KEY SUPABASE_ANON_KEY; do
95+
if ! grep -q "^${key}=" "$GITHUB_ENV"; then
96+
echo "::error::supabase status env parse missing ${key}"
97+
supabase status -o env
98+
exit 1
99+
fi
100+
done
101+
102+
- name: Run integration tests
103+
env:
104+
REDIS_URL: redis://localhost:6379
105+
BIOAGENTS_SECRET: ci-integration-secret
106+
RUN_PDF_INTEGRATION: "1"
107+
run: bun test --test-name-pattern '\[integration\]'
108+
109+
- name: Stop Supabase
110+
if: always()
111+
continue-on-error: true
112+
run: supabase stop --no-backup

AGENTS.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,27 @@ Other commands:
2626
- `bun format:check` / `bun format:write` — Biome format
2727
- `bun style:check` / `bun style:write` — Biome lint + format + import sorting (prefer pre-commit hook)
2828
- `bun typecheck` — TypeScript type checking
29-
- `bun test` — bun:test
29+
- `bun test` — bun:test (integration tests skip cleanly when env is absent)
3030
- `bun build:client` — Build Preact frontend
3131

32+
### Integration tests
33+
34+
Integration tests (describe blocks tagged `[integration]`) need a local Supabase stack and/or `RUN_PDF_INTEGRATION=1`. They skip when env is missing, so `bun test` on its own is always safe.
35+
36+
```bash
37+
supabase start # pulls images on first run
38+
eval "$(supabase status -o env \
39+
| sed -n -E 's/^API_URL="?([^"]+)"?$/export SUPABASE_URL=\1/p;
40+
s/^SERVICE_ROLE_KEY="?([^"]+)"?$/export SUPABASE_SERVICE_KEY=\1/p;
41+
s/^ANON_KEY="?([^"]+)"?$/export SUPABASE_ANON_KEY=\1/p')"
42+
export RUN_PDF_INTEGRATION=1
43+
bun test --test-name-pattern '\[integration\]' # just integration suites
44+
bun test # full suite (unit + integration)
45+
supabase stop
46+
```
47+
48+
CI runs these in a dedicated `integration` job (see `.github/workflows/ci.yml`) that spins up Supabase via `supabase/setup-cli` and Redis as a service container. No repo secrets required — the local stack generates its own keys.
49+
3250
## Tech Stack
3351

3452
- **Runtime**: Bun (not Node.js) — use `bun` everywhere, not `node`/`npm`/`ts-node`
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
2+
import type { StateValues } from "../../types/core";
3+
import { describeIfSupabase } from "../../utils/__testHelpers__/integrationEnv";
4+
5+
describeIfSupabase("[integration] cleanValues strips legacy rawFiles", () => {
6+
// Dynamic imports — operations.ts instantiates the Supabase client at module
7+
// load time; loading it without env throws.
8+
let createState: typeof import("../../db/operations").createState;
9+
let getState: typeof import("../../db/operations").getState;
10+
let updateState: typeof import("../../db/operations").updateState;
11+
let getServiceClient: typeof import("../../db/client").getServiceClient;
12+
13+
beforeAll(async () => {
14+
({ createState, getState, updateState } = await import("../../db/operations"));
15+
({ getServiceClient } = await import("../../db/client"));
16+
});
17+
18+
let stateId: string = "";
19+
20+
beforeEach(async () => {
21+
// Seed a row. createState does NOT run cleanValues, so it accepts the
22+
// legacy-shape rawFiles (not in the declared type) verbatim.
23+
const seed = {
24+
values: {
25+
rawFiles: [
26+
{
27+
buffer: "base64-binary-stand-in-" + "A".repeat(2048),
28+
filename: "legacy.pdf",
29+
metadata: { pages: 3 },
30+
mimeType: "application/pdf",
31+
parsedText: "a".repeat(10_000),
32+
},
33+
],
34+
} as unknown as StateValues,
35+
};
36+
const row = await createState(seed);
37+
stateId = row.id!;
38+
39+
// Verify the seed landed with the heavy fields intact — otherwise the
40+
// post-update assertions would pass vacuously if the JSONB column (or a
41+
// future schema change) silently coerced them away at insert time.
42+
const seeded = await getState(stateId);
43+
const seededRf = (seeded!.values as { rawFiles?: Array<Record<string, unknown>> }).rawFiles;
44+
expect(seededRf?.[0]?.buffer).toBeDefined();
45+
expect(seededRf?.[0]?.parsedText).toBeDefined();
46+
});
47+
48+
afterEach(async () => {
49+
if (!stateId) return;
50+
const supabase = getServiceClient();
51+
const { error } = await supabase.from("states").delete().eq("id", stateId);
52+
if (error) throw new Error(`states cleanup failed: ${error.message}`);
53+
stateId = "";
54+
});
55+
56+
test("updateState with rawFiles payload persists without buffer/parsedText", async () => {
57+
// Invariant: cleanValues must strip `buffer` and `parsedText` from every
58+
// rawFiles entry before persistence, so state rows don't balloon past
59+
// Postgres' JSONB row limits as files accumulate in a conversation.
60+
const rawFilesPayload = {
61+
rawFiles: [
62+
{
63+
buffer: "base64-binary-stand-in-" + "B".repeat(2048),
64+
filename: "legacy.pdf",
65+
metadata: { pages: 3 },
66+
mimeType: "application/pdf",
67+
parsedText: "b".repeat(10_000),
68+
},
69+
],
70+
} as unknown as Partial<StateValues>;
71+
72+
await updateState(stateId, rawFilesPayload);
73+
74+
const row = await getState(stateId);
75+
expect(row).toBeDefined();
76+
77+
const persisted = row!.values as Record<string, unknown>;
78+
const rf = persisted.rawFiles as Array<Record<string, unknown>> | undefined;
79+
expect(rf).toBeDefined();
80+
expect(rf!).toHaveLength(1);
81+
82+
const entry = rf![0]!;
83+
expect(entry.buffer).toBeUndefined();
84+
expect(entry.parsedText).toBeUndefined();
85+
expect(entry.filename).toBe("legacy.pdf");
86+
expect(entry.mimeType).toBe("application/pdf");
87+
expect(entry.metadata).toEqual({ pages: 3 });
88+
});
89+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterEach, beforeAll, beforeEach, expect, jest, test } from "bun:test";
2+
import { describeIfSupabase } from "../../../utils/__testHelpers__/integrationEnv";
3+
import { generateUUID } from "../../../utils/uuid";
4+
5+
describeIfSupabase("[integration] ensureUserAndConversation — concurrent 23505 race", () => {
6+
// Dynamic imports so the module graph isn't loaded when env is absent —
7+
// src/db/operations.ts eagerly calls getServiceClient() on module eval.
8+
let ensureUserAndConversation: typeof import("../setup").ensureUserAndConversation;
9+
let getServiceClient: typeof import("../../../db/client").getServiceClient;
10+
let logger: typeof import("../../../utils/logger")["default"];
11+
12+
beforeAll(async () => {
13+
({ ensureUserAndConversation } = await import("../setup"));
14+
({ getServiceClient } = await import("../../../db/client"));
15+
logger = (await import("../../../utils/logger")).default;
16+
});
17+
18+
const CONCURRENCY = 10;
19+
let conversationId: string;
20+
let userIds: string[];
21+
22+
beforeEach(() => {
23+
conversationId = generateUUID();
24+
userIds = Array.from({ length: CONCURRENCY }, () => generateUUID());
25+
});
26+
27+
afterEach(async () => {
28+
const supabase = getServiceClient();
29+
// Delete in FK order: conversations -> users. Surface PostgREST errors
30+
// so a silent cleanup failure doesn't leak rows into later test runs.
31+
if (conversationId) {
32+
const { error: convErr } = await supabase
33+
.from("conversations")
34+
.delete()
35+
.eq("id", conversationId);
36+
if (convErr) throw new Error(`conversations cleanup failed: ${convErr.message}`);
37+
}
38+
if (userIds.length > 0) {
39+
const { error: userErr } = await supabase.from("users").delete().in("id", userIds);
40+
if (userErr) throw new Error(`users cleanup failed: ${userErr.message}`);
41+
}
42+
jest.restoreAllMocks();
43+
});
44+
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.
54+
const errorSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined);
55+
56+
const results = await Promise.all(
57+
userIds.map((uid) => ensureUserAndConversation(uid, conversationId))
58+
);
59+
60+
const winners = results.filter((r) => r.success);
61+
const losers = results.filter((r) => !r.success);
62+
expect(winners).toHaveLength(1);
63+
expect(losers).toHaveLength(CONCURRENCY - 1);
64+
for (const loser of losers) {
65+
expect(loser.error).toBe("Access denied: conversation belongs to another user");
66+
}
67+
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.
71+
const genericFailureCall = errorSpy.mock.calls.find(
72+
([, msg]) => msg === "create_conversation_failed"
73+
);
74+
expect(genericFailureCall).toBeUndefined();
75+
76+
// The DB row should exist and belong to the sole winner.
77+
const supabase = getServiceClient();
78+
const { data } = await supabase
79+
.from("conversations")
80+
.select("id, user_id")
81+
.eq("id", conversationId)
82+
.single();
83+
expect(data).toBeDefined();
84+
const winningUserId = userIds[results.findIndex((r) => r.success)];
85+
expect(data!.user_id).toBe(winningUserId);
86+
});
87+
});
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+
});

src/services/chat/setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export async function ensureUserAndConversation(
9595
? (err as { code?: unknown }).code
9696
: undefined;
9797
if (errCode === "23505") {
98+
if (logger) {
99+
logger.warn({ conversationId, userId }, "conversation_create_race_23505");
100+
}
98101
// Re-check ownership for the race condition case
99102
try {
100103
const racedConversation = await getConversation(conversationId);

0 commit comments

Comments
 (0)