Skip to content

Commit 8a5faec

Browse files
committed
test(webapp): cover more session-stream scenarios
Adds wire-level legs drawn from the ai-chat smoke-test catalog: the `.in/append` client-channel round-trip, the server-side peek fast-close (`X-Session-Settled`) that pairs with the client caught-up close, a 413 on an oversized `.in/append` body still carrying CORS headers, and rejection of a subscribe with an invalid token. Seeds a real Session row so the append path resolves.
1 parent f2805d0 commit 8a5faec

2 files changed

Lines changed: 168 additions & 2 deletions

File tree

apps/webapp/test/helpers/sessionStream.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,84 @@ export type SubscribeOptions = {
9999
lastEventId?: string;
100100
timeoutInSeconds?: number;
101101
peekSettled?: boolean;
102+
io?: "out" | "in";
102103
};
103104

105+
function sessionChannelUrl(baseUrl: string, addressingKey: string, io: "out" | "in"): string {
106+
return `${baseUrl}/realtime/v1/sessions/${encodeURIComponent(addressingKey)}/${io}`;
107+
}
108+
109+
/**
110+
* Append a record to a session's `.in` channel through the webapp route the
111+
* browser uses (client -> agent). Returns the status, the reflected
112+
* Access-Control-Allow-Origin (when an Origin was sent), and the parsed body.
113+
*/
114+
export async function appendInput(opts: {
115+
baseUrl: string;
116+
addressingKey: string;
117+
token: string;
118+
body: string;
119+
partId?: string;
120+
origin?: string;
121+
}): Promise<{ status: number; acao: string | null; json: unknown }> {
122+
const url = `${sessionChannelUrl(opts.baseUrl, opts.addressingKey, "in")}/append`;
123+
const res = await fetch(url, {
124+
method: "POST",
125+
headers: {
126+
Authorization: `Bearer ${opts.token}`,
127+
"Content-Type": "application/json",
128+
...(opts.partId ? { "X-Part-Id": opts.partId } : {}),
129+
...(opts.origin ? { Origin: opts.origin } : {}),
130+
},
131+
body: opts.body,
132+
});
133+
let json: unknown;
134+
try {
135+
json = await res.json();
136+
} catch {}
137+
return { status: res.status, acao: res.headers.get("access-control-allow-origin"), json };
138+
}
139+
140+
/**
141+
* Raw SSE GET against a session channel, exposing the response status +
142+
* headers (which `SSEStreamSubscription` hides). Reads the body to close and
143+
* reports how long that took, for asserting the server-side peek fast-close.
144+
*/
145+
export async function openChannelRaw(
146+
opts: SubscribeOptions & { maxMs?: number }
147+
): Promise<{ status: number; sessionSettled: string | null; closedMs: number; body: string }> {
148+
const url = sessionChannelUrl(opts.baseUrl, opts.addressingKey, opts.io ?? "out");
149+
const started = performance.now();
150+
const res = await fetch(url, {
151+
headers: {
152+
Authorization: `Bearer ${opts.token}`,
153+
Accept: "text/event-stream",
154+
...(opts.lastEventId ? { "Last-Event-ID": opts.lastEventId } : {}),
155+
...(opts.timeoutInSeconds ? { "Timeout-Seconds": String(opts.timeoutInSeconds) } : {}),
156+
...(opts.peekSettled ? { "X-Peek-Settled": "1" } : {}),
157+
},
158+
});
159+
const sessionSettled = res.headers.get("x-session-settled");
160+
let body = "";
161+
if (res.body) {
162+
const reader = res.body.getReader();
163+
const decoder = new TextDecoder();
164+
const deadline = started + (opts.maxMs ?? 15_000);
165+
try {
166+
while (performance.now() < deadline) {
167+
const { done, value } = await reader.read();
168+
if (done) break;
169+
body += decoder.decode(value, { stream: true });
170+
}
171+
} finally {
172+
await reader.cancel().catch(() => {});
173+
}
174+
}
175+
return { status: res.status, sessionSettled, closedMs: performance.now() - started, body };
176+
}
177+
104178
export function subscribeSessionOut(opts: SubscribeOptions): SSEStreamSubscription {
105-
const url = `${opts.baseUrl}/realtime/v1/sessions/${encodeURIComponent(opts.addressingKey)}/out`;
179+
const url = sessionChannelUrl(opts.baseUrl, opts.addressingKey, opts.io ?? "out");
106180
return new SSEStreamSubscription(url, {
107181
headers: {
108182
Authorization: `Bearer ${opts.token}`,

apps/webapp/test/session-stream.e2e.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import type { SessionStreamTestServer } from "@internal/testcontainers/webapp";
1616
import { startSessionStreamTestServer } from "@internal/testcontainers/webapp";
1717
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
1818
import {
19+
appendInput,
1920
collectSessionOut,
2021
collectUntilCaughtUp,
2122
isTurnComplete,
2223
mintSessionToken,
24+
openChannelRaw,
2325
SessionStreamProducer,
2426
sessionStreamName,
2527
} from "./helpers/sessionStream";
@@ -37,8 +39,21 @@ afterAll(async () => {
3739
}, 120_000);
3840

3941
async function setupSession() {
40-
const { organization, environment, apiKey } = await seedTestEnvironment(server.prisma);
42+
const { organization, project, environment, apiKey } = await seedTestEnvironment(server.prisma);
4143
const addressingKey = `sess-${randomBytes(6).toString("hex")}`;
44+
await server.prisma.session.create({
45+
data: {
46+
friendlyId: `session_${randomBytes(8).toString("hex")}`,
47+
externalId: addressingKey,
48+
type: "chat.agent",
49+
projectId: project.id,
50+
runtimeEnvironmentId: environment.id,
51+
environmentType: environment.type,
52+
organizationId: organization.id,
53+
taskIdentifier: "chat-agent",
54+
triggerConfig: {},
55+
},
56+
});
4257
const token = await mintSessionToken({ apiKey, envId: environment.id, addressingKey });
4358
const streamName = sessionStreamName({
4459
orgId: organization.id,
@@ -213,4 +228,81 @@ describe("session stream e2e", () => {
213228
expect(dataChunks).toEqual([0, 1, 2]);
214229
expect(parts.some(isTurnComplete)).toBe(true);
215230
});
231+
232+
it("E8 in/append 413 for an oversized body still carries CORS headers", async () => {
233+
const { addressingKey, token, baseUrl } = await setupSession();
234+
235+
const oversized = "x".repeat(2 * 1024 * 1024);
236+
const { status, acao } = await appendInput({
237+
baseUrl,
238+
addressingKey,
239+
token,
240+
origin: "http://example.com",
241+
body: JSON.stringify({ kind: "message", payload: { big: oversized } }),
242+
});
243+
244+
expect(status).toBe(413);
245+
expect(acao).not.toBeNull();
246+
});
247+
248+
it("E9 server peek fast-closes at a turn-complete tail with X-Session-Settled", async () => {
249+
const { addressingKey, token, producer, baseUrl } = await setupSession();
250+
251+
await producer.appendData({ n: 0 }, "p0");
252+
const tc = await producer.appendTurnComplete();
253+
254+
const { status, sessionSettled, closedMs } = await openChannelRaw({
255+
baseUrl,
256+
addressingKey,
257+
token,
258+
lastEventId: String(tc),
259+
peekSettled: true,
260+
timeoutInSeconds: 30,
261+
maxMs: 10_000,
262+
});
263+
264+
expect(status).toBe(200);
265+
expect(sessionSettled).toBe("true");
266+
expect(closedMs).toBeLessThan(5_000);
267+
});
268+
269+
it("E11 in/append delivers the record on the .in channel", async () => {
270+
const { addressingKey, token, baseUrl } = await setupSession();
271+
272+
const payload = JSON.stringify({ kind: "message", text: "hello from client" });
273+
const appended = await appendInput({
274+
baseUrl,
275+
addressingKey,
276+
token,
277+
partId: "in-0",
278+
body: payload,
279+
});
280+
expect(appended.status).toBe(200);
281+
282+
const { parts } = await collectSessionOut({
283+
baseUrl,
284+
addressingKey,
285+
token,
286+
io: "in",
287+
until: (p) => p.some((x) => x.chunk != null),
288+
maxMs: 15_000,
289+
});
290+
291+
const got = parts.find((p) => p.chunk != null);
292+
expect(got).toBeTruthy();
293+
expect(String(got?.chunk)).toContain("hello from client");
294+
});
295+
296+
it("E12 subscribe with an invalid token is rejected", async () => {
297+
const { addressingKey, baseUrl } = await setupSession();
298+
299+
const { status } = await openChannelRaw({
300+
baseUrl,
301+
addressingKey,
302+
token: "tr_pub_invalid_not_a_real_token",
303+
maxMs: 5_000,
304+
});
305+
306+
expect([401, 403]).toContain(status);
307+
});
216308
});

0 commit comments

Comments
 (0)