Files
pwa/backend/tests/assignments.test.ts
ar.badieiandCursor c6243caa35 Add read-only Noya admin panel with assignments and UX polish.
Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 08:00:24 +03:30

579 lines
21 KiB
TypeScript
Raw Permalink 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);
// 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',
);
});
});