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