import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { prisma } from '../src/lib/prisma.js'; import { ChatRole } from '@prisma/client'; 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 { 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), })); } function headers(token?: string, ip = 'e2e-qa'): HeadersInit { const next: Record = { 'Content-Type': 'application/json', 'x-real-ip': ip, }; if (token) next.Authorization = `Bearer ${token}`; return next; } async function registerUser(suffix: string, ip: string) { const email = `e2e-${suffix}-${Date.now()}@example.com`; const { status, body } = await requestJson(app, '/auth/register', { method: 'POST', headers: headers(undefined, ip), body: JSON.stringify({ email, password: 'password12' }), }); assert.equal(status, 201, `register failed: ${JSON.stringify(body)}`); 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 { email, token, user, externalId }; } async function completeSession(token: string, ip: string, order: number, userId: bigint) { const chat = await requestJson(app, '/chat/sessions', { method: 'POST', headers: headers(token, ip), body: JSON.stringify({ sessionOrder: order }), }); assert.equal(chat.status, 201, `chat create ${order} failed: ${JSON.stringify(chat.body)}`); const chatId = chat.body.data?.id as string; await prisma.chatMessage.createMany({ data: [ { chatSessionId: BigInt(chatId), role: ChatRole.user, content: `سلام جلسه ${order}` }, { chatSessionId: BigInt(chatId), role: ChatRole.user, content: `ادامه جلسه ${order}` }, ], }); const session = getSessionByOrder(order); assert.ok(session); const complete = await requestJson(app, `/sessions/${order}/complete`, { method: 'POST', headers: headers(token, ip), body: JSON.stringify({ ...exampleCompletionSignal(buildAiCoachSessionContext(session)), chatSessionId: chatId, }), }); assert.equal( complete.status, 200, `complete session ${order} failed: ${JSON.stringify(complete.body)}`, ); await prisma.sessionProgress.update({ where: { userId_sessionOrder: { userId, sessionOrder: order } }, data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }, }); return { chatId, progress: complete.body.data, userId }; } describe('full E2E learning journey QA', () => { 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('covers auth, profile, assessments, sessions 1-11, post-test, isolation, and 401', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const ownerIp = `e2e-owner-${Date.now()}`; const otherIp = `e2e-other-${Date.now()}`; const owner = await registerUser('owner', ownerIp); const me = await requestJson(app, '/auth/me', { headers: headers(owner.token, ownerIp) }); assert.equal(me.status, 200); assert.equal((me.body.data as { id: string }).id, owner.externalId); const profile = await requestJson(app, '/users/me/profile', { method: 'PATCH', headers: headers(owner.token, ownerIp), body: JSON.stringify({ name: 'آزمون', age: 28 }), }); assert.equal(profile.status, 200, JSON.stringify(profile.body)); assert.equal((profile.body.data as { profile: { name: string; age: number } }).profile.name, 'آزمون'); assert.equal((profile.body.data as { profile: { name: string; age: number } }).profile.age, 28); const profileAgain = await requestJson(app, '/users/me', { headers: headers(owner.token, ownerIp), }); assert.equal(profileAgain.status, 200); assert.equal((profileAgain.body.data as { profileComplete: boolean }).profileComplete, true); const badLogin = await requestJson(app, '/auth/login', { method: 'POST', headers: headers(undefined, ownerIp), body: JSON.stringify({ email: owner.email, password: 'wrong-password' }), }); assert.equal(badLogin.status, 401); const login = await requestJson(app, '/auth/login', { method: 'POST', headers: headers(undefined, ownerIp), body: JSON.stringify({ email: owner.email, password: 'password12' }), }); assert.equal(login.status, 200); const token = login.body.data?.sessionToken as string; assert.ok(token); const beforePre = await requestJson(app, '/journey', { headers: headers(token, ownerIp) }); assert.equal(beforePre.status, 200); assert.equal((beforePre.body.data?.permissions as { canTakePreTest: boolean }).canTakePreTest, true); assert.equal((beforePre.body.data?.permissions as { canOpenTraining: boolean }).canOpenTraining, false); const pre = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }), }); assert.equal(pre.status, 201); assert.equal(typeof (pre.body.data?.scores as { total: number }).total, 'number'); const duplicatePre = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(5) }), }); assert.equal(duplicatePre.status, 409); const afterPre = await requestJson(app, '/journey', { headers: headers(token, ownerIp) }); assert.equal((afterPre.body.data?.permissions as { canOpenTraining: boolean }).canOpenTraining, true); assert.equal((afterPre.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest, false); assert.ok(afterPre.body.data?.preTestCompletedAt); const sessions = await requestJson(app, '/sessions', { headers: headers(token, ownerIp) }); const list = sessions.body.data as unknown as { order: number; unlocked: boolean; completed: boolean }[]; assert.equal(list[0]?.unlocked, true); assert.equal(list[1]?.unlocked, false); assert.equal(list[4]?.unlocked, false); const lockedFive = await requestJson(app, '/chat/sessions', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ sessionOrder: 5 }), }); assert.equal(lockedFive.status, 403); const tooSoonComplete = await requestJson(app, '/sessions/1/complete', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify(exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(1)!))), }); assert.equal(tooSoonComplete.status, 422); const first = await completeSession(token, ownerIp, 1, owner.user.id); const liveMessage = await requestJson(app, `/chat/sessions/${first.chatId}/messages`, { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ content: 'یک پیام زنده برای پایداری گفتگو' }), }); assert.ok(liveMessage.status === 201 || liveMessage.status === 503, `live chat ${liveMessage.status}`); const afterOne = await requestJson(app, '/sessions', { headers: headers(token, ownerIp) }); const afterOneList = afterOne.body.data as unknown as { order: number; unlocked: boolean; completed: boolean }[]; assert.equal(afterOneList[0]?.completed, true); assert.equal(afterOneList[1]?.unlocked, true); assert.equal(afterOneList[4]?.unlocked, false); const history = await requestJson(app, `/chat/sessions/${first.chatId}/messages`, { headers: headers(token, ownerIp), }); assert.equal(history.status, 200); const messages = (history.body.data?.messages as { role: string; content: string }[]) ?? []; assert.ok(messages.filter((m) => m.role === 'user').length >= 2); assert.equal(messages.some((m) => m.role === 'system'), false); await completeSession(token, ownerIp, 2, owner.user.id); for (let order = 3; order <= PROGRAM_SESSION_COUNT - 2; order += 1) { await completeSession(token, ownerIp, order, owner.user.id); } await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT - 1, owner.user.id); const beforeFinal = await requestJson(app, '/journey', { headers: headers(token, ownerIp) }); assert.equal( (beforeFinal.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest, true, 'post-test should unlock once the learner reaches session 11', ); await completeSession(token, ownerIp, PROGRAM_SESSION_COUNT, owner.user.id); const afterTraining = await requestJson(app, '/journey', { headers: headers(token, ownerIp) }); assert.ok(afterTraining.body.data?.trainingCompletedAt); assert.equal((afterTraining.body.data as { currentStep: string }).currentStep, 'training_completed'); assert.equal( (afterTraining.body.data?.permissions as { canTakePostTest: boolean }).canTakePostTest, true, ); const postTooLateLocked = await requestJson(app, '/chat/sessions', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ sessionOrder: 1 }), }); assert.ok(postTooLateLocked.status === 201 || postTooLateLocked.status === 403); const post = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ phase: 'post', answers: pcqAnswers(5) }), }); assert.equal(post.status, 201, JSON.stringify(post.body)); const duplicatePost = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: headers(token, ownerIp), body: JSON.stringify({ phase: 'post', answers: pcqAnswers(6) }), }); assert.equal(duplicatePost.status, 409); const finished = await requestJson(app, '/journey', { headers: headers(token, ownerIp) }); assert.equal((finished.body.data as { currentStep: string }).currentStep, 'completed'); assert.equal((finished.body.data?.permissions as { isCompleted: boolean }).isCompleted, true); assert.ok(finished.body.data?.postTestCompletedAt); const other = await registerUser('other', otherIp); const otherJourney = await requestJson(app, '/journey', { headers: headers(other.token, otherIp), }); assert.equal(otherJourney.status, 200); assert.equal((otherJourney.body.data?.permissions as { canTakePreTest: boolean }).canTakePreTest, true); assert.equal(otherJourney.body.data?.preTestCompletedAt, null); const otherChat = await requestJson(app, `/chat/sessions/${first.chatId}/messages`, { headers: headers(other.token, otherIp), }); assert.equal(otherChat.status, 404); const otherProfile = await requestJson(app, '/users/me', { headers: headers(other.token, otherIp), }); assert.equal((otherProfile.body.data as { id: string }).id, other.externalId); assert.notEqual((otherProfile.body.data as { id: string }).id, owner.externalId); const loggedOut = await requestJson(app, '/auth/logout', { method: 'POST', headers: headers(token, ownerIp), }); assert.equal(loggedOut.status, 200); const expired = await requestJson(app, '/auth/me', { headers: headers(token, ownerIp) }); assert.equal(expired.status, 401); const ownerRow = await prisma.user.findUnique({ where: { id: owner.user.id }, include: { profile: true, journey: true, assessmentAttempts: true, sessionProgress: true, chatSessions: { include: { messages: true } }, }, }); assert.ok(ownerRow?.profile); assert.ok(ownerRow.journey?.preTestCompletedAt); assert.ok(ownerRow.journey?.trainingCompletedAt); assert.ok(ownerRow.journey?.postTestCompletedAt); assert.equal(ownerRow.assessmentAttempts.length, 2); assert.equal( ownerRow.sessionProgress.filter((row) => row.status === 'completed').length, PROGRAM_SESSION_COUNT, ); assert.ok(ownerRow.chatSessions.length >= 1); assert.ok(ownerRow.chatSessions.some((session) => session.messages.length >= 2)); }); });