231 lines
7.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
});
|
|
});
|