Files
pwa/backend/tests/session-unlock.test.ts
T
ar.badiei a3a9f2664b 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.
2026-10-14 19:42:54 +03:30

264 lines
10 KiB
TypeScript

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<number, Date>();
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);
});
});