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 { 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-detail-test-${order}-${Date.now()}`, order, title, description: 'admin assignment detail test', estimatedDurationMinutes: 10, blocks: [], }, }); } async function createDefinition(input: { key: string; sessionOrder: number; title: string; expectedDurationDays?: number | null; }) { await ensureCourseSession(input.sessionOrder, `جلسه detail ${input.sessionOrder}`); const def = await prisma.assignmentDefinition.create({ data: { key: input.key, sessionOrder: input.sessionOrder, title: input.title, description: 'desc', instructions: 'instr', assignmentType: AssignmentType.DAILY, expectedDurationDays: input.expectedDurationDays !== undefined ? input.expectedDurationDays : 4, displayOrder: input.sessionOrder, }, }); createdDefinitionIds.push(def.id); return def; } describe('admin assignment detail', () => { 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/1`, ); 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(`detail-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/1`, { headers: authHeader(token), }); assert.equal(status, 403); assert.equal(body.error?.code, 'FORBIDDEN'); assertNoSecrets(raw); }); it('returns 422 for invalid externalId or assignmentId', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`detail-invalid-${Date.now()}@example.com`); const badUser = await requestJson(app, '/admin/users/not-a-uuid/assignments/1', { headers: authHeader(token), }); assert.equal(badUser.status, 422); const badAssignment = await requestJson( app, `/admin/users/${randomUUID()}/assignments/not-numeric`, { headers: authHeader(token) }, ); assert.equal(badAssignment.status, 422); }); it('returns 404 for missing user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`detail-missing-${Date.now()}@example.com`); const { status, body } = await requestJson( app, `/admin/users/${randomUUID()}/assignments/1`, { headers: authHeader(token) }, ); assert.equal(status, 404); assert.equal(body.error?.code, 'NOT_FOUND'); }); it('returns detail for owned assignment, hides foreign assignment, and records audit', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const { token, userId: adminId } = await promoteAndLogin( `detail-admin-${stamp}@example.com`, ); const targetReg = await register(`detail-target-${stamp}@example.com`); const otherReg = await register(`detail-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 target = await prisma.user.findUniqueOrThrow({ where: { externalId: targetId } }); const other = await prisma.user.findUniqueOrThrow({ where: { externalId: otherId } }); const def = await createDefinition({ key: `detail-def-${stamp}`, sessionOrder: 9, title: 'جزئیات تمرین', expectedDurationDays: 4, }); const otherDef = await createDefinition({ key: `detail-other-def-${stamp}`, sessionOrder: 10, title: 'تکلیف دیگری', }); const activatedAt = new Date('2026-04-01T10:00:00.000Z'); const startedAt = new Date('2026-04-02T10:00:00.000Z'); const ua = await prisma.userAssignment.create({ data: { userId: target.id, definitionId: def.id, status: UserAssignmentStatus.IN_PROGRESS, activatedAt, startedAt, lastActivityAt: new Date('2026-04-03T12:00:00.000Z'), draftData: { secretField: 'must-not-leak' }, completionNote: 'private note', }, }); await prisma.assignmentDailyEntry.create({ data: { userAssignmentId: ua.id, userId: target.id, entryDate: new Date('2026-04-02'), dayStatus: AssignmentDayStatus.done, responseData: { answer: 'private' }, note: 'private entry note', }, }); await prisma.assignmentReport.create({ data: { userAssignmentId: ua.id, userId: target.id, reportText: 'secret report text', reportStatus: 'in_progress', }, }); const foreign = await prisma.userAssignment.create({ data: { userId: other.id, definitionId: otherDef.id, status: UserAssignmentStatus.ACTIVE, }, }); await prisma.learningJourney.upsert({ where: { userId: target.id }, create: { userId: target.id, preTestCompletedAt: new Date(), trainingStartedAt: new Date(), }, update: { preTestCompletedAt: new Date(), trainingStartedAt: new Date(), }, }); const before = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } }); const { status, body, raw } = await requestJson( app, `/admin/users/${targetId}/assignments/${ua.id.toString()}`, { headers: authHeader(token) }, ); assert.equal(status, 200); assertNoSecrets(raw); assert.equal( !/must-not-leak|private note|secret report|passwordHash|draftData|responseData/i.test(raw), true, ); const data = body.data as { user: { id: string }; assignment: { id: string; definitionTitle: string; status: string; assignedAt: string; startedAt: string | null; doneDayCount: number; expectedDurationDays: number | null; progressPercent: number | null; }; session: { order: number; title: string }; learningProgress: { currentStep: string | null; totalLessons: number; completedLessons: number; }; activity: { firstActivityAt: string | null; lastActivityAt: string | null; completedActivityCount: number; } | null; }; assert.equal(data.user.id, targetId); assert.equal(data.assignment.id, ua.id.toString()); assert.equal(data.assignment.definitionTitle, 'جزئیات تمرین'); assert.equal(data.assignment.status, UserAssignmentStatus.IN_PROGRESS); assert.equal(data.assignment.assignedAt, activatedAt.toISOString()); assert.equal(data.assignment.startedAt, startedAt.toISOString()); assert.equal(data.assignment.doneDayCount, 1); assert.equal(data.assignment.expectedDurationDays, 4); assert.equal(data.assignment.progressPercent, 25); assert.equal(data.session.order, 9); assert.ok(data.session.title); assert.equal(data.learningProgress.currentStep, 'training_in_progress'); assert.ok(data.activity); assert.equal(data.activity!.completedActivityCount, 1); assert.ok(data.activity!.firstActivityAt); const foreignRes = await requestJson( app, `/admin/users/${targetId}/assignments/${foreign.id.toString()}`, { headers: authHeader(token) }, ); assert.equal(foreignRes.status, 404); assert.equal(foreignRes.body.error?.code, 'NOT_FOUND'); assert.equal(!/تکلیف دیگری|Other/i.test(foreignRes.raw), true); const missing = await requestJson( app, `/admin/users/${targetId}/assignments/999999999999`, { headers: authHeader(token) }, ); assert.equal(missing.status, 404); const after = await prisma.userAssignment.findUniqueOrThrow({ where: { id: ua.id } }); assert.equal(after.status, before.status); assert.equal(after.updatedAt.toISOString(), before.updatedAt.toISOString()); assert.deepEqual(after.draftData, before.draftData); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: adminId, action: AUDIT_ACTIONS.ADMIN_VIEW_ASSIGNMENT, targetId: ua.id.toString(), }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const meta = audit!.metadata as Record; assert.equal(meta.view, 'assignment_detail'); assert.equal(meta.externalId, targetId); assert.equal(meta.assignmentId, ua.id.toString()); assert.equal(!JSON.stringify(meta).includes('must-not-leak'), true); }); });