padhle - post migration
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
Reference in New Issue
Block a user