Files
pwa/backend/tests/assignments.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

571 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { ChatRole } from '@prisma/client';
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 } from '../src/modules/sessions/catalog.js';
import { seedCourseSessions } from '../src/modules/sessions/seedCatalog.js';
import { seedAssignmentDefinitions } from '../src/modules/assignments/assignments.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 = `assignments-${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 };
}
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, `chat create ${order}: ${JSON.stringify(chat.body)}`);
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, `complete ${order}: ${JSON.stringify(complete.body)}`);
return { chatId };
}
describe('assignments API', () => {
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('activates cumulative assignments, reports, drafts, daily entries, and isolation', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const owner = await registerUser('owner');
const other = await registerUser('other');
await completePreTest(owner.token);
await completePreTest(other.token);
// Session 1 has no assignment
await openChatAndComplete(owner.token, 1);
let list = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(owner.token),
});
assert.equal(list.status, 200);
assert.equal((list.body.data?.assignments as unknown[]).length, 0);
// Complete session 2 → joy assignment activates
await openChatAndComplete(owner.token, 2);
list = await requestJson(app, '/assignments/active', {
headers: authHeader(owner.token),
});
assert.equal(list.status, 200);
const activeAfter2 = list.body.data?.assignments as Array<{
id: string;
definition: { key: string; sessionOrder: number };
status: string;
}>;
assert.equal(activeAfter2.length, 1);
assert.equal(activeAfter2[0]?.definition.key, 'joy-activities');
const joyId = activeAfter2[0]!.id;
// Completing again does not duplicate
await requestJson(app, '/sessions/2/complete', {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(2)!)),
}),
});
list = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(owner.token),
});
assert.equal((list.body.data?.assignments as unknown[]).length, 1);
// 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',
headers: authHeader(owner.token),
body: JSON.stringify({ sessionOrder: 3 }),
});
assert.equal(chat3.status, 201);
await openChatAndComplete(owner.token, 3);
list = await requestJson(app, '/assignments/active', {
headers: authHeader(owner.token),
});
const activeAfter3 = list.body.data?.assignments as Array<{
id: string;
definition: { key: string };
}>;
assert.equal(activeAfter3.length, 2);
assert.ok(activeAfter3.some((a) => a.definition.key === 'joy-activities'));
assert.ok(activeAfter3.some((a) => a.definition.key === 'meaning-purpose'));
const meaningId = activeAfter3.find((a) => a.definition.key === 'meaning-purpose')!.id;
// Joy still active after session 3
const joyStill = await requestJson(app, `/assignments/${joyId}`, {
headers: authHeader(owner.token),
});
assert.equal(joyStill.status, 200);
assert.notEqual(joyStill.body.data?.status, 'COMPLETED');
// Multiple reports on different days
const report1 = await requestJson(app, `/assignments/${joyId}/reports`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
reportText: 'امروز یک پیاده‌روی آرام انجام دادم و حالم بهتر شد.',
reportStatus: 'in_progress',
}),
});
assert.equal(report1.status, 200);
const report2 = await requestJson(app, `/assignments/${joyId}/reports`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
reportText: 'روز بعد هم یک فیلم شاد دیدم و خندیدم.',
reportStatus: 'completed',
}),
});
assert.equal(report2.status, 200);
const joyDetail = await requestJson(app, `/assignments/${joyId}`, {
headers: authHeader(owner.token),
});
assert.equal((joyDetail.body.data?.reports as unknown[]).length, 2);
// Daily entries + duplicate day conflict
const entry1 = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-10',
dayStatus: 'done',
responseData: { activity: 'پیاده‌روی', feeling: 'سبک' },
note: 'صبح',
}),
});
assert.equal(entry1.status, 200);
const entryDup = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-10',
dayStatus: 'done',
responseData: { activity: 'تکراری', feeling: '—' },
}),
});
assert.equal(entryDup.status, 409);
const entry2 = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-11',
dayStatus: 'done',
responseData: { activity: 'موسیقی', feeling: 'شاد' },
}),
});
assert.equal(entry2.status, 200);
const entry2Id = (entry2.body.data as { id: string }).id;
const entryPatch = await requestJson(app, `/assignments/${joyId}/entries/${entry2Id}`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
dayStatus: 'done',
responseData: { activity: 'موسیقی آرام', feeling: 'آرام' },
note: 'ویرایش',
}),
});
assert.equal(entryPatch.status, 200);
// Incomplete daily «done» entry must be rejected
const entryIncomplete = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-12',
dayStatus: 'done',
responseData: { activity: '', feeling: '' },
}),
});
assert.equal(entryIncomplete.status, 422);
// Skipped day allowed without activity payload
const entrySkipped = await requestJson(app, `/assignments/${joyId}/entries`, {
method: 'POST',
headers: authHeader(owner.token),
body: JSON.stringify({
entryDate: '2026-09-12',
dayStatus: 'skipped',
responseData: {},
note: 'خسته بودم',
}),
});
assert.equal(entrySkipped.status, 200);
// ONE_TIME draft save & continue
const draft = await requestJson(app, `/assignments/${meaningId}/draft`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
draftData: { goal: 'هدف کوچک', step: 'یک قدم', reflection: 'یادداشت' },
completeIfValid: false,
}),
});
assert.equal(draft.status, 200);
assert.equal((draft.body.data?.draftData as { goal: string }).goal, 'هدف کوچک');
assert.equal(draft.body.data?.status, 'IN_PROGRESS');
// Incomplete completeIfValid must fail
const incompleteSubmit = await requestJson(app, `/assignments/${meaningId}/draft`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
draftData: { goal: '', step: '', reflection: '' },
completeIfValid: true,
}),
});
assert.equal(incompleteSubmit.status, 422);
// Valid submit completes ONE_TIME
const completed = await requestJson(app, `/assignments/${meaningId}/draft`, {
method: 'PATCH',
headers: authHeader(owner.token),
body: JSON.stringify({
draftData: { goal: 'هدف به‌روز', step: 'قدم دوم', reflection: '' },
completeIfValid: true,
}),
});
assert.equal(completed.status, 200);
assert.equal(completed.body.data?.status, 'COMPLETED');
// Isolation: other user cannot read/write owner's assignment
const otherRead = await requestJson(app, `/assignments/${joyId}`, {
headers: authHeader(other.token),
});
assert.equal(otherRead.status, 404);
const otherReport = await requestJson(app, `/assignments/${joyId}/reports`, {
method: 'POST',
headers: authHeader(other.token),
body: JSON.stringify({
reportText: 'تلاش برای دسترسی غیرمجاز به تکلیف دیگری',
reportStatus: 'completed',
}),
});
assert.equal(otherReport.status, 404);
const otherList = await requestJson(app, '/assignments?filter=all', {
headers: authHeader(other.token),
});
assert.equal((otherList.body.data?.assignments as unknown[]).length, 0);
// Pending report filter — requiresReport is false on seeds → empty
const pending = await requestJson(app, '/assignments/pending-report', {
headers: authHeader(owner.token),
});
assert.equal(pending.status, 200);
assert.equal((pending.body.data?.assignments as unknown[]).length, 0);
// Session assignments endpoint
const sessionAssignments = await requestJson(app, '/sessions/2/assignments', {
headers: authHeader(owner.token),
});
assert.equal(sessionAssignments.status, 200);
assert.ok(Array.isArray(sessionAssignments.body.data?.assignments));
assertNoSecrets(JSON.stringify(list.body));
});
it('keeps ongoing assignments usable across later sessions (through day 9 / session 10)', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, user } = await registerUser('long');
await completePreTest(token);
await openChatAndComplete(token, 1);
await openChatAndComplete(token, 2);
// Unlock sessions 3–10 sequentially without cooldown delays
for (let order = 3; order <= 10; order += 1) {
await openChatAndComplete(token, order);
}
const list = await requestJson(app, '/assignments/active', {
headers: authHeader(token),
});
assert.equal(list.status, 200);
const assignments = list.body.data?.assignments as Array<{
id: string;
definition: { key: string; sessionOrder: number };
}>;
// Sessions 2–10 each activate one definition → 9 active (none completed)
assert.equal(assignments.length, 9);
assert.ok(assignments.some((a) => a.definition.key === 'joy-activities'));
assert.ok(assignments.some((a) => a.definition.key === 'kindness-acts'));
const joy = assignments.find((a) => a.definition.key === 'joy-activities')!;
const entry = await requestJson(app, `/assignments/${joy.id}/entries`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
entryDate: '2026-09-13',
dayStatus: 'done',
responseData: { activity: 'فعالیت روز نهم', feeling: 'خوب' },
}),
});
assert.equal(entry.status, 200);
const finalHw = assignments.find((a) => a.definition.key === 'kindness-acts')!;
// Early complete without minValidDays / enough days must fail
const earlyComplete = await requestJson(app, `/assignments/${finalHw.id}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({}),
});
assert.equal(earlyComplete.status, 422);
const kindEntry = await requestJson(app, `/assignments/${finalHw.id}/entries`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
entryDate: '2026-09-14',
dayStatus: 'done',
responseData: {
acts: [
{
forWhom: 'همسایه',
description: 'کمک در حمل وسایل',
selfFeeling: 'سبک',
},
],
},
}),
});
assert.equal(kindEntry.status, 200);
// Still cannot complete — minValidDays is null (research undecided)
const stillBlocked = await requestJson(app, `/assignments/${finalHw.id}/complete`, {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({}),
});
assert.equal(stillBlocked.status, 422);
assert.notEqual(
(
await requestJson(app, `/assignments/${finalHw.id}`, {
headers: authHeader(token),
})
).body.data?.status,
'COMPLETED',
);
});
it('unlocks assignment immediately on session complete — independent of next-session cooldown', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token, user } = await registerUser('unlock-gate');
await completePreTest(token);
await openChatAndComplete(token, 1);
let catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
assert.equal(catalog.status, 200);
let items = catalog.body.data?.catalog as Array<{
unlocked: boolean;
lockReason: string | null;
definition: { key: string; sessionOrder: number; displayOrder: number };
userAssignment: { id: string } | null;
}>;
const first = items.find((i) => i.definition.displayOrder === 1)!;
assert.equal(first.definition.sessionOrder, 2);
assert.equal(first.unlocked, false);
assert.match(String(first.lockReason), /جلسه 2/);
await openChatAndComplete(token, 2);
// With waiting period removed, no cooldown is set
const progress = await requestJson(app, '/sessions/progress', {
headers: authHeader(token),
});
assert.equal(progress.status, 200);
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', {
method: 'POST',
headers: authHeader(token),
body: JSON.stringify({
...exampleCompletionSignal(buildAiCoachSessionContext(getSessionByOrder(2)!)),
}),
});
assert.equal(redo.status, 200);
const unlockedFromComplete = redo.body.data?.unlockedAssignments as Array<{
unlocked: boolean;
definition: { key: string; sessionOrder: number };
}>;
assert.ok(Array.isArray(unlockedFromComplete));
assert.equal(unlockedFromComplete.length, 1);
assert.equal(unlockedFromComplete[0]?.definition.key, 'joy-activities');
assert.equal(unlockedFromComplete[0]?.definition.sessionOrder, 2);
assert.equal(unlockedFromComplete[0]?.unlocked, true);
catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
items = catalog.body.data?.catalog as typeof items;
const joy = items.find((i) => i.definition.key === 'joy-activities')!;
assert.equal(joy.unlocked, true);
assert.equal(joy.lockReason, null);
assert.ok(joy.userAssignment?.id);
// Session 3 is unlocked immediately (no 24h wait)
const sessions = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const sessionList = sessions.body.data as Array<{
order: number;
unlocked: boolean;
unlocksAt?: string | null;
}>;
const s3 = sessionList.find((s) => s.order === 3)!;
assert.equal(s3.unlocked, true);
assert.equal(s3.unlocksAt, null);
// 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 = (sessionsAfter3.body.data as typeof sessionList).find((s) => s.order === 3)!;
assert.equal(s3After.unlocked, true);
catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
items = catalog.body.data?.catalog as typeof items;
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,
2,
'unlocking session 3 must not unlock later assignments (session 4+)',
);
// Mid assignment: تکلیف پنجم (displayOrder 5 / sessionOrder 6) after completing session 6
for (let order = 4; order <= 6; order += 1) {
await openChatAndComplete(token, order);
}
catalog = await requestJson(app, '/assignments/catalog', {
headers: authHeader(token),
});
items = catalog.body.data?.catalog as typeof items;
const fifth = items.find((i) => i.definition.displayOrder === 5)!;
assert.equal(fifth.definition.sessionOrder, 6);
assert.equal(fifth.definition.key, 'humor-practice');
assert.equal(fifth.unlocked, true);
assert.ok(fifth.userAssignment?.id);
const sessionsMid = await requestJson(app, '/sessions', {
headers: authHeader(token),
});
const s7 = (sessionsMid.body.data as typeof sessionList).find((s) => s.order === 7)!;
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,
'assignment for session 7 stays locked until session 7 is completed',
);
});
});