import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { randomUUID } from 'node:crypto'; import { AssignmentDayStatus, AssignmentType, UserAssignmentStatus, UserRole, } from '@prisma/client'; import { prisma } from '../src/lib/prisma.js'; import { AUDIT_ACTIONS } from '../src/modules/admin/auditActions.js'; import { PROGRAM_SESSION_COUNT } from '../src/modules/sessions/catalog.js'; import { assertNoSecrets, authHeader, createTestApp, isDatabaseAvailable, requestJson, } from './helpers.js'; const app = createTestApp(); const createdUserIds: bigint[] = []; const createdDefinitionIds: bigint[] = []; async function trackUser(externalId: string | undefined) { if (!externalId) return; const user = await prisma.user.findUnique({ where: { externalId } }); if (user) createdUserIds.push(user.id); } async function register(email: string, password = 'password12') { const result = await requestJson(app, '/auth/register', { method: 'POST', body: JSON.stringify({ email, password }), }); await trackUser((result.body.data?.user as { id?: string } | undefined)?.id); return result; } async function promoteAndLogin(email: string): Promise<{ token: string; userId: bigint }> { const registered = await register(email); assert.equal(registered.status, 201); const externalId = (registered.body.data?.user as { id: string }).id; const user = await prisma.user.findUniqueOrThrow({ where: { externalId } }); await prisma.user.update({ where: { id: user.id }, data: { role: UserRole.admin } }); const login = await requestJson(app, '/auth/login', { method: 'POST', body: JSON.stringify({ email, password: 'password12' }), }); assert.equal(login.status, 200); return { token: login.body.data?.sessionToken as string, userId: user.id, }; } async function ensureCourseSession(order: number, title: string) { const existing = await prisma.courseSession.findUnique({ where: { order } }); if (existing) { return prisma.courseSession.update({ where: { order }, data: { title }, }); } return prisma.courseSession.create({ data: { sessionKey: `admin-assign-test-${order}-${Date.now()}`, order, title, description: 'admin assignment test', estimatedDurationMinutes: 10, blocks: [], }, }); } async function createDefinition(input: { key: string; sessionOrder: number; title: string; assignmentType?: AssignmentType; expectedDurationDays?: number | null; }) { await ensureCourseSession(input.sessionOrder, `جلسه تست ${input.sessionOrder}`); const def = await prisma.assignmentDefinition.create({ data: { key: input.key, sessionOrder: input.sessionOrder, title: input.title, description: 'desc', instructions: 'instr', assignmentType: input.assignmentType ?? AssignmentType.DAILY, expectedDurationDays: input.expectedDurationDays !== undefined ? input.expectedDurationDays : 4, displayOrder: input.sessionOrder, }, }); createdDefinitionIds.push(def.id); return def; } describe('admin assignments', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); }); after(async () => { if (!dbAvailable) return; if (createdUserIds.length > 0) { await prisma.auditLog.deleteMany({ where: { OR: [ { actorUserId: { in: createdUserIds } }, { targetUserId: { in: createdUserIds } }, ], }, }); await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); } if (createdDefinitionIds.length > 0) { await prisma.assignmentDefinition.deleteMany({ where: { id: { in: createdDefinitionIds } }, }); } }); it('returns 401 without session', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { status, raw } = await requestJson( app, `/admin/users/${randomUUID()}/assignments`, ); assert.equal(status, 401); assertNoSecrets(raw); }); it('returns 403 for a normal user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const registered = await register(`assign-user-${Date.now()}@example.com`); const token = registered.body.data?.sessionToken as string; const id = (registered.body.data?.user as { id: string }).id; const { status, body, raw } = await requestJson(app, `/admin/users/${id}/assignments`, { headers: authHeader(token), }); assert.equal(status, 403); assert.equal(body.error?.code, 'FORBIDDEN'); assertNoSecrets(raw); }); it('returns 422 for invalid externalId', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`assign-invalid-${Date.now()}@example.com`); const { status, body } = await requestJson(app, '/admin/users/not-a-uuid/assignments', { headers: authHeader(token), }); assert.equal(status, 422); assert.equal(body.error?.code, 'VALIDATION_ERROR'); }); it('returns 404 for missing user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`assign-missing-${Date.now()}@example.com`); const { status, body } = await requestJson( app, `/admin/users/${randomUUID()}/assignments`, { headers: authHeader(token) }, ); assert.equal(status, 404); assert.equal(body.error?.code, 'NOT_FOUND'); }); it('returns empty items for user without assignments and records audit', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token, userId: adminId } = await promoteAndLogin( `assign-empty-admin-${Date.now()}@example.com`, ); const registered = await register(`assign-empty-target-${Date.now()}@example.com`); const externalId = (registered.body.data?.user as { id: string }).id; const beforeCount = await prisma.userAssignment.count({ where: { user: { externalId } }, }); const { status, body, raw } = await requestJson( app, `/admin/users/${externalId}/assignments`, { headers: authHeader(token) }, ); assert.equal(status, 200); assert.equal(body.success, true); const data = body.data as { user: { id: string }; items: unknown[]; summary: { total: number; completed: number; pending: number; progressPercent: number | null }; learningProgress: { currentStep: string | null; trainingCompleted: boolean; courseCompleted: boolean; totalLessons: number; completedLessons: number; progressPercent: number | null; }; }; assert.equal(data.user.id, externalId); assert.equal(data.items.length, 0); assert.equal(data.summary.total, 0); assert.equal(data.summary.progressPercent, null); assert.equal(data.learningProgress.totalLessons, PROGRAM_SESSION_COUNT); assert.equal(data.learningProgress.completedLessons, 0); assert.equal(data.learningProgress.progressPercent, 0); assert.equal(data.learningProgress.trainingCompleted, false); assert.equal(data.learningProgress.courseCompleted, false); // Journey may be absent until first access; null is valid. assert.ok( data.learningProgress.currentStep === null || typeof data.learningProgress.currentStep === 'string', ); assertNoSecrets(raw); assert.equal(!/draftData|completionNote|reportText|passwordHash/i.test(raw), true); const afterCount = await prisma.userAssignment.count({ where: { user: { externalId } }, }); assert.equal(afterCount, beforeCount); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: adminId, action: AUDIT_ACTIONS.ADMIN_VIEW_ASSIGNMENTS, targetId: externalId, }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const meta = audit!.metadata as Record | null; assert.equal(meta?.view, 'assignments'); assert.equal(meta?.externalId, externalId); assert.equal(!JSON.stringify(meta).includes('password'), true); }); it('lists only the target user assignments with correct fields and summary', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const { token } = await promoteAndLogin(`assign-list-admin-${stamp}@example.com`); const targetReg = await register(`assign-list-target-${stamp}@example.com`); const otherReg = await register(`assign-list-other-${stamp}@example.com`); const targetId = (targetReg.body.data?.user as { id: string }).id; const otherId = (otherReg.body.data?.user as { id: string }).id; const targetUser = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } }); const otherUser = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } }); const defA = await createDefinition({ key: `admin-a-${stamp}`, sessionOrder: 3, title: 'تمرین شادی', expectedDurationDays: 4, }); const defB = await createDefinition({ key: `admin-b-${stamp}`, sessionOrder: 5, title: 'تمرین معنا', assignmentType: AssignmentType.ONE_TIME, expectedDurationDays: null, }); const defOther = await createDefinition({ key: `admin-other-${stamp}`, sessionOrder: 4, title: 'تکلیف دیگری', }); const activatedAt = new Date('2026-01-10T10:00:00.000Z'); const startedAt = new Date('2026-01-11T10:00:00.000Z'); const completedAt = new Date('2026-01-12T10:00:00.000Z'); const uaCompleted = await prisma.userAssignment.create({ data: { userId: targetUser.id, definitionId: defA.id, status: UserAssignmentStatus.COMPLETED, activatedAt, startedAt, completedAt, }, }); await prisma.assignmentDailyEntry.createMany({ data: [ { userAssignmentId: uaCompleted.id, userId: targetUser.id, entryDate: new Date('2026-01-11'), dayStatus: AssignmentDayStatus.done, responseData: {}, }, { userAssignmentId: uaCompleted.id, userId: targetUser.id, entryDate: new Date('2026-01-12'), dayStatus: AssignmentDayStatus.done, responseData: {}, }, { userAssignmentId: uaCompleted.id, userId: targetUser.id, entryDate: new Date('2026-01-13'), dayStatus: AssignmentDayStatus.skipped, responseData: {}, }, ], }); await prisma.userAssignment.create({ data: { userId: targetUser.id, definitionId: defB.id, status: UserAssignmentStatus.ACTIVE, activatedAt: new Date('2026-01-15T10:00:00.000Z'), }, }); await prisma.userAssignment.create({ data: { userId: otherUser.id, definitionId: defOther.id, status: UserAssignmentStatus.IN_PROGRESS, }, }); await ensureCourseSession(1, 'جلسه تست 1'); await prisma.sessionProgress.create({ data: { userId: targetUser.id, sessionOrder: 1, status: 'completed', completedAt: new Date(), }, }); await prisma.learningJourney.upsert({ where: { userId: targetUser.id }, create: { userId: targetUser.id, preTestCompletedAt: new Date(), trainingStartedAt: new Date(), }, update: { preTestCompletedAt: new Date(), trainingStartedAt: new Date(), trainingCompletedAt: null, postTestCompletedAt: null, }, }); const { status, body, raw } = await requestJson( app, `/admin/users/${targetId}/assignments?sortBy=sessionOrder&sortDir=asc`, { headers: authHeader(token) }, ); assert.equal(status, 200); assertNoSecrets(raw); assert.equal(!/draftData|completionNote|reportText|passwordHash|tokenHash/i.test(raw), true); const data = body.data as { items: Array<{ assignmentId: string; definitionTitle: string; sessionOrder: number; sessionTitle: string | null; status: string; assignedAt: string; startedAt: string | null; completedAt: string | null; doneDayCount: number; expectedDurationDays: number | null; progressPercent: number | null; programId: null; }>; summary: { total: number; completed: number; inProgress: number; pending: number; notStarted: number; progressPercent: number | null; }; learningProgress: { currentStep: string | null; trainingStarted: boolean; trainingCompleted: boolean; courseCompleted: boolean; completedLessons: number; totalLessons: number; progressPercent: number | null; }; }; assert.equal(data.items.length, 2); assert.equal( data.items.every((item) => item.assignmentId !== undefined), true, ); assert.equal(data.items[0]!.sessionOrder, 3); assert.equal(data.items[0]!.definitionTitle, 'تمرین شادی'); assert.equal(data.items[0]!.status, UserAssignmentStatus.COMPLETED); assert.equal(data.items[0]!.assignedAt, activatedAt.toISOString()); assert.equal(data.items[0]!.startedAt, startedAt.toISOString()); assert.equal(data.items[0]!.completedAt, completedAt.toISOString()); assert.equal(data.items[0]!.doneDayCount, 2); assert.equal(data.items[0]!.expectedDurationDays, 4); assert.equal(data.items[0]!.progressPercent, 50); assert.equal(data.items[0]!.programId, null); assert.ok(data.items[0]!.sessionTitle); assert.equal(data.items[1]!.sessionOrder, 5); assert.equal(data.items[1]!.status, UserAssignmentStatus.ACTIVE); assert.equal(data.items[1]!.progressPercent, null); assert.equal(data.items[1]!.expectedDurationDays, null); assert.equal(data.summary.total, 2); assert.equal(data.summary.completed, 1); assert.equal(data.summary.inProgress, 1); assert.equal(data.summary.pending, 0); assert.equal(data.summary.notStarted, 0); assert.equal(data.summary.progressPercent, 50); assert.equal(data.learningProgress.trainingStarted, true); assert.equal(data.learningProgress.trainingCompleted, false); assert.equal(data.learningProgress.courseCompleted, false); assert.equal(data.learningProgress.completedLessons, 1); assert.equal(data.learningProgress.totalLessons, PROGRAM_SESSION_COUNT); assert.equal( data.learningProgress.progressPercent, Math.round((1 / PROGRAM_SESSION_COUNT) * 100), ); assert.equal(data.learningProgress.currentStep, 'training_in_progress'); }); it('does not mutate assignment or journey rows on GET', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const { token } = await promoteAndLogin(`assign-ro-admin-${stamp}@example.com`); const registered = await register(`assign-ro-target-${stamp}@example.com`); const externalId = (registered.body.data?.user as { id: string }).id; const user = await prisma.user.findUniqueOrThrow({ where: { externalId } }); const def = await createDefinition({ key: `admin-ro-${stamp}`, sessionOrder: 2, title: 'فقط مشاهده', }); const ua = await prisma.userAssignment.create({ data: { userId: user.id, definitionId: def.id, status: UserAssignmentStatus.IN_PROGRESS, startedAt: new Date('2026-02-01T08:00:00.000Z'), }, }); const beforeUa = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } }); const beforeJourney = await prisma.learningJourney.upsert({ where: { userId: user.id }, create: { userId: user.id }, update: {}, }); const { status } = await requestJson(app, `/admin/users/${externalId}/assignments`, { headers: authHeader(token), }); assert.equal(status, 200); const afterUa = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } }); const afterJourney = await prisma.learningJourney.findUniqueOrThrow({ where: { userId: user.id }, }); assert.equal(afterUa.status, beforeUa.status); assert.equal(afterUa.updatedAt.toISOString(), beforeUa.updatedAt.toISOString()); assert.equal(afterUa.completedAt, beforeUa.completedAt); assert.equal( afterJourney.trainingCompletedAt?.toISOString() ?? null, beforeJourney.trainingCompletedAt?.toISOString() ?? null, ); assert.equal( afterJourney.postTestCompletedAt?.toISOString() ?? null, beforeJourney.postTestCompletedAt?.toISOString() ?? null, ); }); });