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); // 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) }, }); // 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 by backdating previous completions as we go 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); } 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); // completeSession helper only asserts 200 — fetch progress for cooldown fields 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); // 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 still locked by cooldown — must NOT block assignment 1 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, false); assert.ok(s3.unlocksAt); // 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', { headers: authHeader(token), }); const s3After = (sessionsAfter.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); assert.equal( items.filter((i) => i.unlocked).length, 1, 'unlocking session 3 must not unlock later assignments', ); // 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) }, }); 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, false, 'session 7 may still be on cooldown'); assert.equal( items.find((i) => i.definition.sessionOrder === 7)?.unlocked, false, 'assignment for session 7 stays locked until session 7 is completed', ); }); });