187 lines
8.2 KiB
JavaScript
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");
|
|
});
|