Skip to content

Commit ea239f2

Browse files
committed
test(webapp): cover the basin configuration that hid two bugs in review
Three gaps, all of which let a wrong basin through unnoticed. The resolver suite now asserts an invariant across the whole configuration matrix rather than a handful of cases: returning v2 requires a basin, so no future combination can hand back a version the run cannot serve. The session swap case pins which basin the trigger path reads. It seeds a session with a basin of its own and asserts the organization's reaches the resolver, because the run row carries the organization's and the run-scoped stream routes resolve against that alone. A new e2e runs the harness with no global basin and per-org basins enabled, the configuration where basin resolution actually decides something. Every existing test ran with a global basin set, which makes any basin value work and hides the whole class.
1 parent c36fecb commit ea239f2

4 files changed

Lines changed: 219 additions & 1 deletion

File tree

apps/webapp/test/determineRealtimeStreamsVersion.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,32 @@ describe("resolveRealtimeStreamsVersion", () => {
8484
expect(resolveRealtimeStreamsVersion("v3", GLOBAL_BASIN)).toBe("v1");
8585
});
8686
});
87+
88+
describe("resolveRealtimeStreamsVersion invariant", () => {
89+
const BASINS = [undefined, "a-basin"];
90+
const TOKENS = [undefined, "a-token"];
91+
const SKIPS = [false, true];
92+
const DEFAULTS: Array<"v1" | "v2"> = ["v1", "v2"];
93+
const REQUESTED = [undefined, "v1", "v2", "v3"];
94+
95+
it("only returns v2 when a basin is present, for every configuration", () => {
96+
const counterexamples: string[] = [];
97+
98+
for (const basin of BASINS) {
99+
for (const accessToken of TOKENS) {
100+
for (const skipAccessTokens of SKIPS) {
101+
for (const defaultVersion of DEFAULTS) {
102+
for (const requested of REQUESTED) {
103+
const config = { defaultVersion, basin, accessToken, skipAccessTokens };
104+
if (resolveRealtimeStreamsVersion(requested, config) === "v2" && !basin) {
105+
counterexamples.push(JSON.stringify({ requested, ...config }));
106+
}
107+
}
108+
}
109+
}
110+
}
111+
}
112+
113+
expect(counterexamples).toEqual([]);
114+
});
115+
});

apps/webapp/test/realtimeServices.replicaLag.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ const replicaHolder = vi.hoisted(() => ({ client: undefined as any }));
3939
const storeHolder = vi.hoisted(() => ({ store: undefined as any }));
4040
// Records every TriggerTaskService.call so read 3 can assert NO double-trigger and read 4 can assert
4141
// which previousRunId the resolveRunFriendlyId fallback forwarded.
42+
const versionCalls = vi.hoisted(() => [] as Array<{ requested?: string; basin?: string | null }>);
43+
44+
vi.mock("~/services/realtime/v1StreamsGlobal.server", () => ({
45+
determineRealtimeStreamsVersion: (requested?: string, basin?: string | null) => {
46+
versionCalls.push({ requested, basin });
47+
return "v2";
48+
},
49+
}));
50+
4251
const triggerState = vi.hoisted(() => ({
4352
calls: [] as Array<{ taskIdentifier: string; body: any; options: any }>,
4453
result: { run: { id: "", friendlyId: "" } } as { run: { id: string; friendlyId: string } },
@@ -389,6 +398,7 @@ describe("realtime-svc — replica-lag guards", () => {
389398
triggerConfig: { basePayload: {} },
390399
currentRunId: callingRunId,
391400
currentRunVersion: 0,
401+
streamBasinName: "session-pinned-basin",
392402
},
393403
});
394404

@@ -397,6 +407,7 @@ describe("realtime-svc — replica-lag guards", () => {
397407
replicaHolder.client = replica.client;
398408
storeHolder.store = writerStore;
399409
triggerState.calls.length = 0;
410+
versionCalls.length = 0;
400411
const newRunId = cuidRunId(`sn${seq}`);
401412
const newFriendlyId = `run_${suffix}_new`;
402413
triggerState.result = { run: { id: newRunId, friendlyId: newFriendlyId } };
@@ -419,6 +430,8 @@ describe("realtime-svc — replica-lag guards", () => {
419430
expect(triggerState.calls).toHaveLength(1);
420431
expect(triggerState.calls[0]!.body.payload.previousRunId).toBe(callingRunId);
421432
expect(triggerState.calls[0]!.options.realtimeStreamsVersion).toBeDefined();
433+
434+
expect(versionCalls.at(-1)).toEqual({ requested: "v2", basin: null });
422435
expect(replica.wasHit("taskRun")).toBe(true);
423436

424437
// Proof the null was lag-induced: the primary holds the resolvable friendlyId (≠ the cuid).
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Full-stack e2e for the per-org-basin configuration: S2 credentials present,
3+
* no global basin, so whether a run can use v2 depends entirely on whether its
4+
* organization has been provisioned one.
5+
*
6+
* The sibling `sessionRunStreamsBackend` e2e runs with a global basin set,
7+
* which makes every basin value work and hides this whole class of bug. Here a
8+
* run stamped v2 without a resolvable basin is not a degraded experience, it
9+
* throws on every stream operation for the life of the run, so both directions
10+
* are asserted: a provisioned organization reaches S2, and an unprovisioned one
11+
* degrades to v1 and keeps working on Redis.
12+
*
13+
* The unprovisioned case asserts the run carries a null basin and still serves
14+
* its streams. Which basin the trigger path reads is pinned separately, by the
15+
* swap case in `realtimeServices.replicaLag.test.ts`, which asserts the
16+
* organization's basin reaches the resolver even when the session row has one
17+
* of its own.
18+
*
19+
* Requires a pre-built webapp: pnpm run build --filter webapp
20+
*/
21+
import { randomBytes } from "crypto";
22+
import Redis from "ioredis";
23+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
24+
import type { SessionStreamTestServer } from "@internal/testcontainers/webapp";
25+
import { startSessionStreamTestServer } from "@internal/testcontainers/webapp";
26+
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
27+
28+
vi.setConfig({ testTimeout: 120_000, hookTimeout: 180_000 });
29+
30+
let server: SessionStreamTestServer;
31+
32+
beforeAll(async () => {
33+
server = await startSessionStreamTestServer({
34+
extraEnv: {
35+
REALTIME_STREAMS_S2_BASIN: "",
36+
REALTIME_STREAMS_PER_ORG_BASINS_ENABLED: "true",
37+
},
38+
});
39+
}, 180_000);
40+
41+
afterAll(async () => {
42+
await server?.stop();
43+
}, 120_000);
44+
45+
const STREAM_ID = "frames";
46+
47+
/** Per-org basins drop the `org/{id}` segment; see `streamPrefixFor`. */
48+
function perOrgStreamName(p: { envSlug: string; envId: string; runId: string }): string {
49+
return `env/${p.envSlug}/${p.envId}/runs/${p.runId}/${STREAM_ID}`;
50+
}
51+
52+
function redisStreamKey(runId: string): string {
53+
return `tr:realtime:streams:stream:${runId}:${STREAM_ID}`;
54+
}
55+
56+
async function s2HasRecords(basin: string, streamName: string): Promise<boolean> {
57+
const qs = new URLSearchParams({ seq_num: "0", clamp: "true", wait: "0" });
58+
const res = await fetch(
59+
`${server.s2.endpoint}/v1/streams/${encodeURIComponent(streamName)}/records?${qs}`,
60+
{
61+
headers: {
62+
Authorization: "Bearer ignored",
63+
Accept: "text/event-stream",
64+
"S2-Format": "raw",
65+
"S2-Basin": basin,
66+
},
67+
}
68+
);
69+
if (!res.ok) return false;
70+
return (await res.text()).includes(STREAM_ID);
71+
}
72+
73+
async function createSessionRun(apiKey: string, taskIdentifier: string): Promise<string> {
74+
const res = await fetch(`${server.webapp.baseUrl}/api/v1/sessions`, {
75+
method: "POST",
76+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
77+
body: JSON.stringify({
78+
type: "chat.agent",
79+
externalId: `e2e-${randomBytes(6).toString("hex")}`,
80+
taskIdentifier,
81+
triggerConfig: { basePayload: {} },
82+
}),
83+
});
84+
expect(res.ok).toBe(true);
85+
return ((await res.json()) as { runId: string }).runId;
86+
}
87+
88+
async function appendFrame(apiKey: string, runId: string): Promise<number> {
89+
const res = await fetch(
90+
`${server.webapp.baseUrl}/realtime/v1/streams/${runId}/self/${STREAM_ID}/append`,
91+
{
92+
method: "POST",
93+
headers: {
94+
Authorization: `Bearer ${apiKey}`,
95+
"Content-Type": "text/plain",
96+
"X-Part-Id": STREAM_ID,
97+
},
98+
body: JSON.stringify({ frame: "a".repeat(1024) }),
99+
}
100+
);
101+
return res.status;
102+
}
103+
104+
describe("session runs with per-org basins and no global basin", () => {
105+
it("reaches S2 for a provisioned organization", async () => {
106+
const { organization, environment, apiKey } = await seedTestEnvironment(server.prisma);
107+
const basin = server.s2.basin;
108+
109+
await server.prisma.organization.update({
110+
where: { id: organization.id },
111+
data: { streamBasinName: basin },
112+
});
113+
114+
const runId = await createSessionRun(apiKey, "e2e-per-org-provisioned");
115+
116+
const run = await server.prisma.taskRun.findFirstOrThrow({
117+
where: { friendlyId: runId },
118+
select: { realtimeStreamsVersion: true, streamBasinName: true },
119+
});
120+
121+
expect(await appendFrame(apiKey, runId)).toBe(200);
122+
123+
const redis = new Redis({ host: server.redis.host, port: server.redis.port });
124+
let observed;
125+
try {
126+
observed = {
127+
version: run.realtimeStreamsVersion,
128+
runBasin: run.streamBasinName,
129+
inS2: await s2HasRecords(
130+
basin,
131+
perOrgStreamName({ envSlug: environment.slug, envId: environment.id, runId })
132+
),
133+
keyInRedis: (await redis.exists(redisStreamKey(runId))) === 1,
134+
};
135+
} finally {
136+
redis.disconnect();
137+
}
138+
139+
expect(observed).toEqual({ version: "v2", runBasin: basin, inS2: true, keyInRedis: false });
140+
});
141+
142+
it("degrades to v1 for an unprovisioned organization and still serves its streams", async () => {
143+
const { organization, apiKey } = await seedTestEnvironment(server.prisma);
144+
145+
await server.prisma.organization.update({
146+
where: { id: organization.id },
147+
data: { streamBasinName: null },
148+
});
149+
150+
const runId = await createSessionRun(apiKey, "e2e-per-org-unprovisioned");
151+
152+
const run = await server.prisma.taskRun.findFirstOrThrow({
153+
where: { friendlyId: runId },
154+
select: { realtimeStreamsVersion: true, streamBasinName: true },
155+
});
156+
157+
expect(await appendFrame(apiKey, runId)).toBe(200);
158+
159+
const redis = new Redis({ host: server.redis.host, port: server.redis.port });
160+
let observed;
161+
try {
162+
observed = {
163+
version: run.realtimeStreamsVersion,
164+
runBasin: run.streamBasinName,
165+
keyInRedis: (await redis.exists(redisStreamKey(runId))) === 1,
166+
};
167+
} finally {
168+
redis.disconnect();
169+
}
170+
171+
expect(observed).toEqual({ version: "v1", runBasin: null, keyInRedis: true });
172+
});
173+
});

internal-packages/testcontainers/src/webapp.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,9 @@ export interface SessionStreamTestServer extends TestServer {
286286
* process reaching every container over its mapped port, so the S2 endpoint is
287287
* the mapped localhost URL (the docker-network alias is unusable from the host).
288288
*/
289-
export async function startSessionStreamTestServer(): Promise<SessionStreamTestServer> {
289+
export async function startSessionStreamTestServer(
290+
options: StartWebappOptions = {}
291+
): Promise<SessionStreamTestServer> {
290292
const network = await new Network().start();
291293

292294
let pgContainer: Awaited<ReturnType<typeof createPostgresContainer>>["container"] | undefined;
@@ -328,6 +330,7 @@ export async function startSessionStreamTestServer(): Promise<SessionStreamTestS
328330
OBJECT_STORE_ACCESS_KEY_ID: minioConfig.accessKeyId,
329331
OBJECT_STORE_SECRET_ACCESS_KEY: minioConfig.secretAccessKey,
330332
OBJECT_STORE_REGION: minioConfig.region,
333+
...(options.extraEnv ?? {}),
331334
},
332335
}
333336
);

0 commit comments

Comments
 (0)