From a3a9f2664bac85c6b502473480fbeb1fb53bf3c4 Mon Sep 17 00:00:00 2001 From: "ar.badiei" Date: Wed, 14 Oct 2026 19:42:54 +0330 Subject: [PATCH] Remove inter-session waiting period for immediate sequential access - Set SESSION_UNLOCK_INTERVAL_MS to 0 in backend and frontend. - Allow immediate access to next eligible session upon completing previous sessions. - Preserve all session prerequisites, assignment unlock logic, and participant progress. - Update tests to verify immediate session unlock and assignment availability. --- backend/src/shared/constants/app.ts | 4 +- backend/tests/assignments.test.ts | 60 +++--- backend/tests/session-unlock.test.ts | 263 +++++++++++++++++++++++++++ src/lib/sessionUnlock.ts | 4 +- src/services/programService.ts | 3 +- 5 files changed, 294 insertions(+), 40 deletions(-) create mode 100644 backend/tests/session-unlock.test.ts diff --git a/backend/src/shared/constants/app.ts b/backend/src/shared/constants/app.ts index 0c6360a..c1a47fa 100644 --- a/backend/src/shared/constants/app.ts +++ b/backend/src/shared/constants/app.ts @@ -1,8 +1,8 @@ export const APP_NAME = 'Novya API'; export const APP_DESCRIPTION = 'Novya learning platform backend API'; -/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */ -export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */ +export const SESSION_UNLOCK_INTERVAL_MS = 0; export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE_SIZE = 20; diff --git a/backend/tests/assignments.test.ts b/backend/tests/assignments.test.ts index 10a7c6d..adf8795 100644 --- a/backend/tests/assignments.test.ts +++ b/backend/tests/assignments.test.ts @@ -143,14 +143,7 @@ describe('assignments API', () => { }); assert.equal((list.body.data?.assignments as unknown[]).length, 1); - // Backdate session 2 completion so session 3 unlocks (24h gate for order >= 3) - await prisma.sessionProgress.update({ - where: { - userId_sessionOrder: { userId: owner.user.id, sessionOrder: 2 }, - }, - data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }, - }); - + // Session 3 is unlocked immediately (no 24h waiting period) // Incomplete homework must NOT block starting session 3 const chat3 = await requestJson(app, '/chat/sessions', { method: 'POST', @@ -363,12 +356,8 @@ describe('assignments API', () => { await openChatAndComplete(token, 1); await openChatAndComplete(token, 2); - // Unlock sessions 3–10 by backdating previous completions as we go + // Unlock sessions 3–10 sequentially without cooldown delays for (let order = 3; order <= 10; order += 1) { - await prisma.sessionProgress.updateMany({ - where: { userId: user.id, sessionOrder: order - 1 }, - data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }, - }); await openChatAndComplete(token, order); } @@ -472,13 +461,13 @@ describe('assignments API', () => { await openChatAndComplete(token, 2); - // completeSession helper only asserts 200 — fetch progress for cooldown fields + // With waiting period removed, no cooldown is set const progress = await requestJson(app, '/sessions/progress', { headers: authHeader(token), }); assert.equal(progress.status, 200); - assert.ok(progress.body.data?.waitingForSessionOrder === 3); - assert.ok(progress.body.data?.nextSessionUnlocksAt); + assert.equal(progress.body.data?.waitingForSessionOrder, null); + assert.equal(progress.body.data?.nextSessionUnlocksAt, null); // Re-complete to read unlockedAssignments payload from complete endpoint const redo = await requestJson(app, '/sessions/2/complete', { @@ -508,7 +497,7 @@ describe('assignments API', () => { assert.equal(joy.lockReason, null); assert.ok(joy.userAssignment?.id); - // Session 3 still locked by cooldown — must NOT block assignment 1 + // Session 3 is unlocked immediately (no 24h wait) const sessions = await requestJson(app, '/sessions', { headers: authHeader(token), }); @@ -518,18 +507,19 @@ describe('assignments API', () => { unlocksAt?: string | null; }>; const s3 = sessionList.find((s) => s.order === 3)!; - assert.equal(s3.unlocked, false); - assert.ok(s3.unlocksAt); + assert.equal(s3.unlocked, true); + assert.equal(s3.unlocksAt, null); - // Opening cooldown (simulate later unlock of session 3) must not change assignment-1 state - await prisma.sessionProgress.update({ - where: { userId_sessionOrder: { userId: user.id, sessionOrder: 2 } }, - data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }, - }); - const sessionsAfter = await requestJson(app, '/sessions', { + // Prerequisites still hold: Session 4 is NOT unlocked yet + const s4 = sessionList.find((s) => s.order === 4)!; + assert.equal(s4.unlocked, false); + + // Complete session 3 immediately without any time delay + await openChatAndComplete(token, 3); + const sessionsAfter3 = await requestJson(app, '/sessions', { headers: authHeader(token), }); - const s3After = (sessionsAfter.body.data as typeof sessionList).find((s) => s.order === 3)!; + const s3After = (sessionsAfter3.body.data as typeof sessionList).find((s) => s.order === 3)!; assert.equal(s3After.unlocked, true); catalog = await requestJson(app, '/assignments/catalog', { @@ -539,18 +529,18 @@ describe('assignments API', () => { const joyAfter = items.find((i) => i.definition.key === 'joy-activities')!; assert.equal(joyAfter.unlocked, true); assert.equal(joyAfter.userAssignment?.id, joy.userAssignment?.id); + + // Assignment 2 (meaning) is now unlocked immediately on session 3 complete + const meaningAfter3 = items.find((i) => i.definition.key === 'meaning-purpose')!; + assert.equal(meaningAfter3.unlocked, true); assert.equal( items.filter((i) => i.unlocked).length, - 1, - 'unlocking session 3 must not unlock later assignments', + 2, + 'unlocking session 3 must not unlock later assignments (session 4+)', ); // Mid assignment: تکلیف پنجم (displayOrder 5 / sessionOrder 6) after completing session 6 - for (let order = 3; order <= 6; order += 1) { - await prisma.sessionProgress.updateMany({ - where: { userId: user.id, sessionOrder: order - 1 }, - data: { completedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) }, - }); + for (let order = 4; order <= 6; order += 1) { await openChatAndComplete(token, order); } @@ -568,7 +558,9 @@ describe('assignments API', () => { headers: authHeader(token), }); const s7 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 7)!; - assert.equal(s7.unlocked, false, 'session 7 may still be on cooldown'); + assert.equal(s7.unlocked, true, 'session 7 unlocks immediately after session 6 completion'); + const s8 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 8)!; + assert.equal(s8.unlocked, false, 'session 8 remains locked until session 7 is completed'); assert.equal( items.find((i) => i.definition.sessionOrder === 7)?.unlocked, false, diff --git a/backend/tests/session-unlock.test.ts b/backend/tests/session-unlock.test.ts new file mode 100644 index 0000000..f49e567 --- /dev/null +++ b/backend/tests/session-unlock.test.ts @@ -0,0 +1,263 @@ +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); + }); +}); diff --git a/src/lib/sessionUnlock.ts b/src/lib/sessionUnlock.ts index 87b2b21..3385dc4 100644 --- a/src/lib/sessionUnlock.ts +++ b/src/lib/sessionUnlock.ts @@ -1,7 +1,7 @@ import { PROGRAM_SESSION_COUNT } from '@/data/program/sessions'; -/** Minimum wait after completing session N before session N+1 unlocks (sessions 3–10). */ -export const SESSION_UNLOCK_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** Minimum wait after completing session N before session N+1 unlocks. Set to 0 so next session unlocks immediately. */ +export const SESSION_UNLOCK_INTERVAL_MS = 0; export type SessionCompletionTimes = Record; diff --git a/src/services/programService.ts b/src/services/programService.ts index e1e869a..2467636 100644 --- a/src/services/programService.ts +++ b/src/services/programService.ts @@ -334,8 +334,7 @@ export async function submitHomeworkReport( /** * Check if a specific session is unlocked for a user. - * Session N unlocks when sessions 1…N-1 are complete and at least 1 hour has passed - * since session N-1 was completed. + * Session N unlocks immediately when previous sessions 1…N-1 are complete. */ export function isSessionUnlocked(userId: string, sessionOrder: number): boolean { const cachedSessions = getSessions(userId);