/* The tutor endpoint's wire behaviour, against a mock backend. Testable with no API key, which matters: the framing and the abort path are where this breaks, and neither needs a real model to exercise. A raw newline inside a `data:` field would silently truncate every Korean reply — that is the bug this file exists to catch. */ import { describe, it, expect } from "vitest"; import { tutorRoute } from "../../server/src/tutor.ts"; import type { TutorBackend, TutorEvent } from "../../server/src/backends/types.ts"; /** Emits the chunks it is given, then done. */ function mockBackend(chunks: string[], opts: { hang?: boolean } = {}): TutorBackend { return { name: "mock", async *stream(req): AsyncIterable { for (const text of chunks) { if (req.signal.aborted) return; yield { type: "delta", text }; } if (opts.hang) { // Stay open until aborted, so the abort path can be observed. await new Promise((resolve) => { if (req.signal.aborted) return resolve(); req.signal.addEventListener("abort", () => resolve(), { once: true }); }); return; } yield { type: "done", stopReason: "end_turn" }; }, }; } const post = (app: ReturnType, body: unknown) => app.request("/", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); /** Parse an SSE body into its events, the way the client does. */ function parseSSE(text: string): { event: string; data: string }[] { return text .split("\n\n") .filter((f) => f.trim()) .map((frame) => { const lines = frame.split("\n"); return { event: lines.find((l) => l.startsWith("event:"))?.slice(6).trim() ?? "", data: lines .filter((l) => l.startsWith("data:")) .map((l) => l.slice(5).trim()) .join(""), }; }); } describe("tutor endpoint", () => { it("rejects a request with no system prompt or message", async () => { const app = tutorRoute(mockBackend(["hi"])); expect((await post(app, { message: "안녕" })).status).toBe(400); expect((await post(app, { system: "S" })).status).toBe(400); }); it("streams deltas as SSE and closes with done", async () => { const app = tutorRoute(mockBackend(["안녕", "하세요"])); const res = await post(app, { system: "S", history: [], message: "안녕" }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/event-stream"); // Compression or a caching proxy would break the stream; both are // refused explicitly. expect(res.headers.get("cache-control")).toContain("no-transform"); const events = parseSSE(await res.text()); const deltas = events.filter((e) => e.event === "delta").map((e) => JSON.parse(e.data).text); expect(deltas).toEqual(["안녕", "하세요"]); expect(events.at(-1)?.event).toBe("done"); }); /* The framing bug this guards against: a tutor reply is full of newlines — every ::words row, every task line — and a raw newline inside a `data:` field ends the event early, truncating the turn. */ it("survives newlines in the payload, which every real reply has", async () => { const reply = "좋아.\n\n::task translate\n밥 먹어\n::\n::words\n밥 | rice\n::"; const app = tutorRoute(mockBackend([reply])); const res = await post(app, { system: "S", history: [], message: "네" }); const events = parseSSE(await res.text()); const delta = events.find((e) => e.event === "delta"); expect(JSON.parse(delta!.data).text).toBe(reply); }); it("passes the transcript through and caps its length", async () => { let seen: { history: unknown[]; system: string } | null = null; const spy: TutorBackend = { name: "spy", async *stream(req) { seen = { history: req.history, system: req.system }; yield { type: "done", stopReason: "end_turn" }; }, }; const app = tutorRoute(spy); const history: { role: "user" | "assistant"; content: string }[] = Array.from( { length: 60 }, (_, i) => ({ role: i % 2 ? "assistant" : "user", content: `turn ${i}` }), ); const res = await post(app, { system: "GATE", history, message: "다음" }); await res.text(); expect(seen!.system).toBe("GATE"); expect(seen!.history.length).toBe(40); // MAX_HISTORY expect((seen!.history.at(-1) as { content: string }).content).toBe("turn 59"); }); it("reports a backend error as an error event rather than a dead stream", async () => { const failing: TutorBackend = { name: "failing", // eslint-disable-next-line require-yield async *stream() { throw new Error("upstream exploded"); }, }; const res = await post(tutorRoute(failing), { system: "S", history: [], message: "x" }); const events = parseSSE(await res.text()); expect(events.at(-1)?.event).toBe("error"); expect(JSON.parse(events.at(-1)!.data).message).toContain("upstream exploded"); }); /* The abort path is what makes "Stop" stop billing: the client going away has to reach the backend's signal, not just close the socket. */ it("aborts the backend when the client goes away", async () => { let seen: AbortSignal | null = null; const holding: TutorBackend = { name: "holding", async *stream(req) { seen = req.signal; yield { type: "delta", text: "부" }; await new Promise((resolve) => req.signal.addEventListener("abort", () => resolve(), { once: true }), ); }, }; const res = await post(tutorRoute(holding), { system: "S", history: [], message: "x" }); const reader = res.body!.getReader(); await reader.read(); // the turn is live and the backend holds the signal expect(seen).not.toBeNull(); expect(seen!.aborted).toBe(false); await reader.cancel(); // the client goes away for (let i = 0; i < 50 && !seen!.aborted; i++) { await new Promise((r) => setTimeout(r, 20)); } expect(seen!.aborted, "the backend must see the client disconnect").toBe(true); }); });