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 { exampleCompletionSignal } from '../src/modules/ai/sessionCompletion.js'; import { buildAiCoachSessionContext } from '../src/modules/ai/sessionContext.js'; import { getSessionByOrder, PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js'; import { seedCourseSessions } from '../src/modules/sessions/sessions.service.js'; import { assertNoSecrets, 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 = `domain-${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 }; } describe('learning domain API', () => { 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('GET /ai/config is public and does not leak secrets', async () => { const { status, body, raw } = await requestJson(app, '/ai/config'); assert.equal(status, 200); assert.equal(typeof body.data?.configured, 'boolean'); assert.equal(typeof body.data?.model, 'string'); assert.equal(typeof body.data?.provider, 'string'); assert.doesNotMatch(raw, /sk-/); assert.doesNotMatch(raw, /OPENAI_API_KEY/); assertNoSecrets(raw); }); it('gates journey, assessments, sequential sessions, and chat persistence', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token, user } = await registerUser('flow'); const beforePre = await requestJson(app, '/journey', { headers: authHeader(token) }); assert.equal(beforePre.status, 200); const beforePermissions = beforePre.body.data?.permissions as { canTakePreTest: boolean; canOpenTraining: boolean; canTakePostTest: boolean; isCompleted: boolean; }; assert.equal(beforePermissions.canTakePreTest, true); assert.equal(beforePermissions.canOpenTraining, false); assert.equal(beforePermissions.canTakePostTest, false); const lockedChat = await requestJson(app, '/chat/sessions', { method: 'POST', headers: authHeader(token), body: JSON.stringify({}), }); assert.equal(lockedChat.status, 403); const postTooSoon = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ phase: 'post', answers: pcqAnswers(4) }), }); assert.equal(postTooSoon.status, 403); 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); assert.equal(pre.body.data?.scores && (pre.body.data.scores as { total: number }).total, 93); const reverseAnswer = ( pre.body.data?.answers as { questionId: string; selectedValue: number; score: number }[] ).find((answer) => answer.questionId === 'pcq_13'); assert.equal(reverseAnswer?.selectedValue, 4); assert.equal(reverseAnswer?.score, 3); const retake = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(5) }), }); assert.equal(retake.status, 409); assert.equal(retake.body.error?.code, 'CONFLICT'); const afterPre = await requestJson(app, '/journey', { headers: authHeader(token) }); assert.equal((afterPre.body.data as { currentStep: string }).currentStep, 'training_in_progress'); const afterPermissions = afterPre.body.data?.permissions as { canTakePreTest: boolean; canOpenTraining: boolean; canTakePostTest: boolean; }; assert.equal(afterPermissions.canTakePreTest, false); assert.equal(afterPermissions.canOpenTraining, true); assert.equal(afterPermissions.canTakePostTest, false); const sessions = await requestJson(app, '/sessions', { headers: authHeader(token) }); assert.equal(sessions.status, 200); const list = sessions.body.data as unknown as { order: number; unlocked: boolean; completed: boolean }[]; assert.equal(list.length, PROGRAM_SESSION_COUNT); assert.equal(list[0]?.unlocked, true); assert.equal(list[1]?.unlocked, false); const sessionTwoComplete = await requestJson(app, '/sessions/2/complete', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ ...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(2)!)), }), }); assert.equal(sessionTwoComplete.status, 403); const chat = await requestJson(app, '/chat/sessions', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ sessionOrder: 1 }), }); assert.equal(chat.status, 201); const chatId = chat.body.data?.id as string; assert.ok(chatId); const firstMessage = await requestJson(app, `/chat/sessions/${chatId}/messages`, { method: 'POST', headers: authHeader(token), body: JSON.stringify({ content: 'سلام، آماده هستم.' }), }); assert.ok(firstMessage.status === 503 || firstMessage.status === 201); const secondMessage = await requestJson(app, `/chat/sessions/${chatId}/messages`, { method: 'POST', headers: authHeader(token), body: JSON.stringify({ content: 'ادامه بده.' }), }); assert.ok(secondMessage.status === 503 || secondMessage.status === 201); const stored = await requestJson(app, `/chat/sessions/${chatId}/messages`, { headers: authHeader(token), }); assert.equal(stored.status, 200); const storedMessages = (stored.body.data?.messages as { role: string; content: string }[]) ?? []; assert.ok(storedMessages.filter((message) => message.role === 'user').length >= 2); const complete = await requestJson(app, '/sessions/1/complete', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ ...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(1)!)), chatSessionId: chatId, }), }); assert.equal(complete.status, 200); const completedSessions = complete.body.data?.completedSessions as number[]; assert.deepEqual(completedSessions, [1]); const endedChat = await requestJson(app, `/chat/sessions/${chatId}/messages`, { headers: authHeader(token), }); assert.equal(endedChat.status, 200); assert.ok((endedChat.body.data?.session as { endedAt: string | null }).endedAt); const listed = await requestJson(app, '/chat/sessions?sessionOrder=1', { headers: authHeader(token), }); assert.equal(listed.status, 200); const listedSessions = listed.body.data as { id: string; sessionOrder: number; endedAt: string | null }[]; assert.ok(listedSessions.some((session) => session.id === chatId && session.endedAt)); const recomplete = await requestJson(app, '/sessions/1/complete', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ ...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(1)!)), chatSessionId: chatId, }), }); assert.equal(recomplete.status, 200); assert.deepEqual(recomplete.body.data?.completedSessions as number[], [1]); const afterSession = await requestJson(app, '/sessions', { headers: authHeader(token) }); const afterList = afterSession.body.data as unknown as { order: number; unlocked: boolean }[]; assert.equal(afterList[1]?.unlocked, true); const stillNoPost = await requestJson(app, '/journey', { headers: authHeader(token) }); assert.equal( (stillNoPost.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest, false, ); }); });