|
| 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 | +}); |
0 commit comments