feat(assessment & chat): polish PCQ-24 mobile result view, typography hierarchy, and anchored chat composer
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,14 @@ const globalForTestDb = globalThis as unknown as {
|
||||
};
|
||||
|
||||
function migrateDeploy(): void {
|
||||
execSync('npx prisma generate --schema=./prisma/schema.prisma', {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
try {
|
||||
execSync('npx prisma generate --schema=./prisma/schema.prisma', {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
} catch {
|
||||
// Ignore file-lock error if another process (e.g. dev server) holds query engine
|
||||
}
|
||||
execSync('npx prisma migrate deploy --schema=./prisma/schema.prisma', {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
|
||||
Reference in New Issue
Block a user