Files
2026-09-15 04:08:55 -04:00

187 lines
8.2 KiB
JavaScript

/**
* 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: "<script>alert(1)</script>" });
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");
});