Files
pwa/backend/tests/admin-assignment-detail.test.ts
T
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

364 lines
12 KiB
TypeScript

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