Files
pwa/backend/tests/assignments.test.ts
T

579 lines
21 KiB
TypeScript
Raw Normal View History

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',
);
});
});