Files
pwa/backend/tests/chat-stream.test.ts
T

231 lines
7.1 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { prisma } from '../src/lib/prisma.js';
import { PCQ_QUESTION_IDS } from '../src/modules/assessments/scoring/index.js';
import { seedCourseSessions } from '../src/modules/sessions/sessions.service.js';
import {
authHeader,
createTestApp,
isDatabaseAvailable,
requestJson,
} from './helpers.js';
const app = createTestApp();
const createdUserIds: bigint[] = [];
function pcqAnswers(value: number) {
return PCQ_QUESTION_IDS.map((questionId) => ({
questionId,
selectedValue: String(value),
}));
}
async function registerUser(suffix: string) {
const email = `stream-${suffix}-${Date.now()}@example.com`;
const { status, body } = await requestJson(app, '/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password: 'password12' }),
});
assert.equal(status, 201);
const token = body.data?.sessionToken as string;
const externalId = (body.data?.user as { id: string }).id;
const user = await prisma.user.findUnique({ where: { externalId } });
assert.ok(user);
createdUserIds.push(user.id);
return { token, user };
}
async function completePreTest(token: string) {
const pre = await requestJson(app, '/assessments/attempts', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }),
});
assert.equal(pre.status, 201);
}
describe('chat SSE progressive streaming', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
if (dbAvailable) {
await seedCourseSessions();
}
});
after(async () => {
if (dbAvailable && createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
}
});
it('preserves non-streaming fallback when Accept is not text/event-stream', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await registerUser('non-stream');
await completePreTest(token);
const created = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: 1 }),
});
assert.equal(created.status, 201);
const chatId = created.body.data?.id as string;
const res = await requestJson(app, `/chat/sessions/${chatId}/messages`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ content: 'سلام' }),
});
assert.equal(res.status, 201);
assert.ok(res.body.data?.userMessage);
assert.ok(res.body.data?.assistantMessage);
});
it('delivers progressive chunks via SSE without leaking [COACH_SESSION_COMPLETE] control markers', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await registerUser('sse-delivery');
await completePreTest(token);
const created = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: 1 }),
});
assert.equal(created.status, 201);
const chatId = created.body.data?.id as string;
const response = await app.request(`/chat/sessions/${chatId}/messages`, {
method: 'POST',
headers: {
...authHeader(token),
Accept: 'text/event-stream',
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: 'سلام' }),
});
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
const reader = response.body?.getReader();
assert.ok(reader, 'Response body must be readable stream');
const decoder = new TextDecoder();
let buffer = '';
const events: Array<{ event: string; data: any }> = [];
let receivedFirstChunk = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split('\n\n');
buffer = blocks.pop() || '';
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.split('\n');
let eventName = 'message';
let dataStr = '';
for (const line of lines) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
else if (line.startsWith('data:')) dataStr = line.slice(5).trim();
}
if (dataStr) {
try {
const parsed = JSON.parse(dataStr);
events.push({ event: eventName, data: parsed });
if (eventName === 'chunk') {
receivedFirstChunk = true;
}
} catch {
events.push({ event: eventName, data: dataStr });
}
}
}
}
assert.ok(receivedFirstChunk, 'Must have received at least one chunk before stream completion');
const chunkEvents = events.filter((e) => e.event === 'chunk');
assert.ok(chunkEvents.length > 0, 'Must have delivered chunk events');
// Verify control marker never leaks in visible chunks
for (const chunk of chunkEvents) {
assert.doesNotMatch(
chunk.data.text || '',
/\[COACH_SESSION_COMPLETE\]/,
'Visible chunk must NEVER contain [COACH_SESSION_COMPLETE]',
);
}
const doneEvent = events.find((e) => e.event === 'done');
assert.ok(doneEvent, 'Must emit done event');
assert.ok(doneEvent.data.assistantMessage, 'Done event must contain assistantMessage');
assert.ok(doneEvent.data.assistantMessage.content, 'Assistant content must be non-empty');
// Verify DB consistency
const dbMessage = await prisma.chatMessage.findUnique({
where: { id: BigInt(doneEvent.data.assistantMessage.id) },
});
assert.ok(dbMessage, 'Message must be saved in database');
assert.equal(
dbMessage.content,
doneEvent.data.assistantMessage.content,
'Database content must match done event content',
);
assert.doesNotMatch(
dbMessage.content,
/\[COACH_SESSION_COMPLETE\]/,
'Database content must not contain raw control markers',
);
});
it('supports stream start on /chat/sessions/:id/start', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await registerUser('sse-start');
await completePreTest(token);
const created = await requestJson(app, '/chat/sessions', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({ sessionOrder: 1 }),
});
assert.equal(created.status, 201);
const chatId = created.body.data?.id as string;
const response = await app.request(`/chat/sessions/${chatId}/start`, {
method: 'POST',
headers: {
...authHeader(token),
Accept: 'text/event-stream',
},
});
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') || '', /text\/event-stream/);
const reader = response.body?.getReader();
if (reader) {
while (true) {
const { done } = await reader.read();
if (done) break;
}
}
});
});