/** * Anonymous 5-message trial — integration tests. * * Uses `createChatRouter({ streamFn })` with a FAKE streamer, mounted behind * `optionalAuth` exactly as app.js wires it, on an ephemeral loopback port. * No real AI calls and no Supabase dependency: the anonymous path is pure * in-memory. * * Scenarios: * 1. Exactly 5 messages succeed, each emitting session + limit events. * 2. The 6th is blocked server-side with 403 SIGNIN_REQUIRED (no AI call). * 3. Continuity: echoing chatId continues the same trial; the AI receives * prior turns (context preserved). * 4. Refresh / unknown chatId → fresh trial, fresh budget. * 5. Forged chatId → fresh trial, never touches sessions, no leak. * 6. AI failure still consumes the message and ends the stream cleanly. * 7. Chapter validation still applies to anonymous requests. * 8. Unauthenticated GET /api/chat/* still requires auth (401). */ import test, { before, after } from "node:test"; import assert from "node:assert/strict"; import express from "express"; import { randomUUID } from "node:crypto"; import { createChatRouter } from "../src/routes/chat.js"; import { optionalAuth } from "../src/middleware/supabaseAuth.js"; import { MAX_FREE_MESSAGES } from "../src/services/anonTrial.js"; // ─── fake AI streamer ─── const streamArgs = []; async function fakeStream(messages, grade, subject, chapter, onChunk, onError, summary) { streamArgs.push({ messages, grade, subject, chapter, summary }); if (fakeStream.mode === "error") { onError("boom"); return ""; } onChunk("Hello "); onChunk("world"); return "Hello world"; } let server; let base; before(async () => { const app = express(); app.use(express.json()); app.use("/api/chat", optionalAuth, createChatRouter({ streamFn: fakeStream })); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); base = `http://127.0.0.1:${server.address().port}`; }); after(async () => { if (server) await new Promise((resolve) => server.close(resolve)); }); async function postChat(body) { const res = await fetch(`${base}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return res; } /** Parse SSE text into {type, payload} events. */ function parseSse(text) { return text .split("\n\n") .filter((block) => block.startsWith("data: ")) .map((block) => JSON.parse(block.slice(6))); } test("anonymous can send exactly 5 messages, each with session+limit events", async () => { fakeStream.mode = "ok"; let trialId = null; for (let i = 1; i <= MAX_FREE_MESSAGES; i++) { // First message starts a trial; follow-ups echo the trial id as chatId, // exactly like the frontend does with activeChat. const body = trialId ? { text: `q${i}`, chatId: trialId } : { text: `q${i}` }; const res = await postChat(body); assert.equal(res.status, 200, `message ${i} should be 200`); const events = parseSse(await res.text()); const sessionEvt = events.find((e) => e.type === "session"); const limitEvt = events.find((e) => e.type === "limit"); const doneEvt = events.find((e) => e.type === "done"); assert.ok(sessionEvt?.id, `message ${i} emits a session id`); assert.equal(limitEvt?.remaining, MAX_FREE_MESSAGES - i, `remaining after msg ${i}`); assert.ok(doneEvt, `message ${i} ends with done`); const chunks = events.filter((e) => e.type === "chunk").map((e) => e.content).join(""); assert.equal(chunks, "Hello world", "chunks streamed"); if (!trialId) trialId = sessionEvt.id; else assert.equal(sessionEvt.id, trialId, "same trial across the session"); } }); test("the 6th anonymous message is blocked with 403 SIGNIN_REQUIRED (no AI call)", async () => { fakeStream.mode = "ok"; // First establish a fresh trial and burn the whole budget. let trialId = null; for (let i = 0; i < MAX_FREE_MESSAGES; i++) { const body = trialId ? { text: `burn${i}`, chatId: trialId } : { text: `burn${i}` }; const res = await postChat(body); const events = parseSse(await res.text()); trialId = events.find((e) => e.type === "session").id; } // Snapshot AFTER the burn loop: the 6th must not reach the AI at all. const beforeCalls = streamArgs.length; const res = await postChat({ text: "sixth", chatId: trialId }); assert.equal(res.status, 403); const body = await res.json(); assert.equal(body.code, "SIGNIN_REQUIRED"); assert.ok(body.error.toLowerCase().includes("sign in")); assert.equal(streamArgs.length, beforeCalls, "no AI call was made for the blocked message"); }); test("continuity: chatId continues the same trial and AI sees prior turns", async () => { fakeStream.mode = "ok"; streamArgs.length = 0; const first = parseSse(await (await postChat({ text: "first question" })).text()); const trialId = first.find((e) => e.type === "session").id; const second = parseSse(await (await postChat({ text: "second question", chatId: trialId })).text()); assert.equal(second.find((e) => e.type === "session").id, trialId, "same trial id"); assert.equal(second.find((e) => e.type === "limit").remaining, MAX_FREE_MESSAGES - 2); // The fake streamer must have received both turns (context preserved). const lastCall = streamArgs[streamArgs.length - 1]; const texts = lastCall.messages.map((m) => m.text); assert.ok(texts.includes("first question"), "prior user turn in context"); assert.ok(texts.includes("second question"), "current user turn in context"); }); test("refresh / unknown chatId starts a fresh trial with a fresh budget", async () => { fakeStream.mode = "ok"; const first = parseSse(await (await postChat({ text: "before refresh" })).text()); const trialId = first.find((e) => e.type === "session").id; assert.equal(first.find((e) => e.type === "limit").remaining, MAX_FREE_MESSAGES - 1); // Simulated page refresh: the browser has no state, so no chatId is sent. const second = parseSse(await (await postChat({ text: "after refresh" })).text()); const newId = second.find((e) => e.type === "session").id; assert.notEqual(newId, trialId, "new trial id after refresh"); assert.equal(second.find((e) => e.type === "limit").remaining, MAX_FREE_MESSAGES - 1, "budget reset"); }); test("forged chatId starts a fresh trial and never touches session data", async () => { fakeStream.mode = "ok"; const forged = randomUUID(); // looks like a real session uuid const events = parseSse(await (await postChat({ text: "hi", chatId: forged })).text()); const sessionEvt = events.find((e) => e.type === "session"); assert.ok(sessionEvt.id, "emits a trial id"); assert.notEqual(sessionEvt.id, forged, "does not adopt the forged id"); assert.equal(events.find((e) => e.type === "limit").remaining, MAX_FREE_MESSAGES - 1); }); test("AI failure consumes the message and ends the stream cleanly with an error event", async () => { fakeStream.mode = "error"; const res = await postChat({ text: "will fail" }); assert.equal(res.status, 200, "SSE response still 200 (error surfaced in-stream)"); const events = parseSse(await res.text()); assert.ok(events.some((e) => e.type === "error"), "error event present"); assert.ok(!events.some((e) => e.type === "done"), "no done after error"); // The message was consumed: budget decremented. // (Re-establish context by sending one ok message on the same trial.) fakeStream.mode = "ok"; const after = parseSse(await (await postChat({ text: "check", chatId: events.find((e) => e.type === "session").id })).text()); assert.equal(after.find((e) => e.type === "limit").remaining, MAX_FREE_MESSAGES - 2); }); test("chapter injection is rejected for anonymous requests too", async () => { const res = await postChat({ text: "hello", chapter: "" }); assert.equal(res.status, 400); const body = await res.json(); assert.ok(body.error, "error message present"); }); test("unauthenticated GET /api/chat/* still requires auth", async () => { const sessions = await fetch(`${base}/api/chat/sessions`); assert.equal(sessions.status, 401, "GET /api/chat/sessions → 401"); const one = await fetch(`${base}/api/chat/${randomUUID()}`); assert.equal(one.status, 401, "GET /api/chat/:id → 401"); });