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/seedCatalog.js'; import { seedAssignmentDefinitions } from '../src/modules/assignments/assignments.service.js'; import { getNextLockedSessionUnlockTime, getPreviousSessionUnlockTime, isSessionUnlockedForProgress, sessionUsesUnlockInterval, } from '../src/modules/sessions/unlock.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 = `session-unlock-${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 }; } import { ChatRole } from '@prisma/client'; async function completePreTest(token: string) { const res = await requestJson(app, '/assessments/attempts', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ phase: 'pre', answers: pcqAnswers(4) }), }); assert.equal(res.status, 201); } async function openChatAndComplete(token: string, order: number) { const chat = await requestJson(app, '/chat/sessions', { method: 'POST', headers: authHeader(token), body: JSON.stringify({ sessionOrder: order }), }); assert.equal(chat.status, 201); const chatId = (chat.body.data as { id: string }).id; 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)!; const complete = await requestJson(app, `/sessions/${order}/complete`, { method: 'POST', headers: authHeader(token), body: JSON.stringify({ ...exampleCompletionSignal(buildAiCoachSessionContext(session)), chatSessionId: chatId, }), }); assert.equal(complete.status, 200); return { chatId }; } describe('session unlock logic and waiting period removal', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); if (dbAvailable) { await seedCourseSessions(); await seedAssignmentDefinitions(); } }); after(async () => { if (dbAvailable && createdUserIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); } }); it('unit: unlock interval functions confirm 0 waiting period', () => { // 1. sessionUsesUnlockInterval returns false for all session orders for (let order = 1; order <= PROGRAM_SESSION_COUNT; order++) { assert.equal( sessionUsesUnlockInterval(order), false, `session ${order} should not use unlock interval`, ); } const completionTimes = new Map(); completionTimes.set(1, new Date()); completionTimes.set(2, new Date()); // 2. getPreviousSessionUnlockTime returns null (no waiting delay) assert.equal(getPreviousSessionUnlockTime(3, completionTimes), null); // 3. isSessionUnlockedForProgress unlocks session 3 immediately when 1 and 2 are complete assert.equal( isSessionUnlockedForProgress(3, [1, 2], true, false, completionTimes), true, 'session 3 should unlock immediately after sessions 1 and 2 are complete', ); // 4. Prerequisites still block: session 4 is NOT unlocked when only 1 and 2 are complete assert.equal( isSessionUnlockedForProgress(4, [1, 2], true, false, completionTimes), false, 'session 4 must remain locked when session 3 is not complete', ); // 5. canOpenTraining prerequisite still blocks all sessions assert.equal( isSessionUnlockedForProgress(1, [], false, false, completionTimes), false, 'session 1 must be locked if canOpenTraining is false', ); // 6. getNextLockedSessionUnlockTime returns null when next eligible session has no cooldown assert.equal( getNextLockedSessionUnlockTime([1, 2], completionTimes), null, 'next eligible session should have no locked cooldown delay', ); }); it('integration: completes sessions sequentially without delays; preserves assignments and prerequisites', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const participantA = await registerUser('part-a'); const participantB = await registerUser('part-b'); // Participant A takes pre-test await completePreTest(participantA.token); // Participant B has NOT taken pre-test — training is locked const bSessions = await requestJson(app, '/sessions', { headers: authHeader(participantB.token), }); const bList = bSessions.body.data as Array<{ order: number; unlocked: boolean }>; assert.ok(bList.every((s) => !s.unlocked), 'all sessions locked for participant B without pre-test'); // Participant A: session 1 is unlocked initially, session 2-11 are locked let aSessions = await requestJson(app, '/sessions', { headers: authHeader(participantA.token), }); let aList = aSessions.body.data as Array<{ order: number; unlocked: boolean; completed: boolean; unlocksAt: string | null }>; assert.equal(aList[0]?.unlocked, true); assert.equal(aList[1]?.unlocked, false); assert.equal(aList[2]?.unlocked, false); // Complete session 1 await openChatAndComplete(participantA.token, 1); // Session 2 is now unlocked immediately aSessions = await requestJson(app, '/sessions', { headers: authHeader(participantA.token), }); aList = aSessions.body.data as typeof aList; assert.equal(aList[0]?.completed, true); assert.equal(aList[1]?.unlocked, true); assert.equal(aList[2]?.unlocked, false); // Complete session 2 await openChatAndComplete(participantA.token, 2); // Progress endpoint shows NO cooldown waiting const aProgress = await requestJson(app, '/sessions/progress', { headers: authHeader(participantA.token), }); assert.equal(aProgress.status, 200); assert.equal(aProgress.body.data?.waitingForSessionOrder, null); assert.equal(aProgress.body.data?.nextSessionUnlocksAt, null); // Session 3 is unlocked IMMEDIATELY (no 24h waiting period!) aSessions = await requestJson(app, '/sessions', { headers: authHeader(participantA.token), }); aList = aSessions.body.data as typeof aList; const s3 = aList.find((s) => s.order === 3)!; assert.equal(s3.unlocked, true, 'session 3 must be unlocked immediately'); assert.equal(s3.unlocksAt, null, 'session 3 unlocksAt must be null'); // Completing session 2 does NOT unlock session 4 or the rest of the program const s4 = aList.find((s) => s.order === 4)!; assert.equal(s4.unlocked, false, 'session 4 must remain locked'); const s11 = aList.find((s) => s.order === 11)!; assert.equal(s11.unlocked, false, 'session 11 must remain locked'); // Associated assignment for session 2 (joy-activities) is unlocked immediately const aCatalog = await requestJson(app, '/assignments/catalog', { headers: authHeader(participantA.token), }); const catalogItems = aCatalog.body.data?.catalog as Array<{ unlocked: boolean; definition: { key: string; sessionOrder: number }; }>; const joyAssignment = catalogItems.find((i) => i.definition.key === 'joy-activities')!; assert.equal(joyAssignment.unlocked, true, 'joy assignment unlocked after session 2 complete'); // Assignment for session 3 (meaning-purpose) is STILL locked because session 3 is not yet completed const meaningAssignment = catalogItems.find((i) => i.definition.key === 'meaning-purpose')!; assert.equal(meaningAssignment.unlocked, false, 'meaning assignment remains locked until session 3 is completed'); // Participant B cannot access participant A's assignments const bAssignments = await requestJson(app, '/assignments?filter=all', { headers: authHeader(participantB.token), }); assert.equal(bAssignments.status, 200); assert.equal((bAssignments.body.data?.assignments as unknown[]).length, 0, 'participant B sees no assignments'); // Participant A immediately accesses and completes session 3 without any delay await openChatAndComplete(participantA.token, 3); // Associated assignment for session 3 is now unlocked immediately const aCatalogAfter3 = await requestJson(app, '/assignments/catalog', { headers: authHeader(participantA.token), }); const itemsAfter3 = aCatalogAfter3.body.data?.catalog as typeof catalogItems; const meaningAfter3 = itemsAfter3.find((i) => i.definition.key === 'meaning-purpose')!; assert.equal(meaningAfter3.unlocked, true, 'meaning assignment unlocks immediately after session 3 completion'); // Session 4 is now unlocked immediately aSessions = await requestJson(app, '/sessions', { headers: authHeader(participantA.token), }); aList = aSessions.body.data as typeof aList; const s4After3 = aList.find((s) => s.order === 4)!; assert.equal(s4After3.unlocked, true, 'session 4 unlocks immediately after session 3 completion'); // Completed sessions 1, 2, 3 remain completed assert.equal(aList.find((s) => s.order === 1)!.completed, true); assert.equal(aList.find((s) => s.order === 2)!.completed, true); assert.equal(aList.find((s) => s.order === 3)!.completed, true); }); });