Files
pwa/backend/tests/admin-assignments-filter.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

373 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { randomUUID } from 'node:crypto';
import { AssignmentType, UserAssignmentStatus, UserRole } from '@prisma/client';
import { prisma } from '../src/lib/prisma.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-filter-test-${order}-${Date.now()}`,
order,
title,
description: 'admin filter test',
estimatedDurationMinutes: 10,
blocks: [],
},
});
}
async function createDefinition(input: {
key: string;
sessionOrder: number;
title: string;
assignmentType: AssignmentType;
sessionTitle: string;
}) {
await ensureCourseSession(input.sessionOrder, input.sessionTitle);
const def = await prisma.assignmentDefinition.create({
data: {
key: input.key,
sessionOrder: input.sessionOrder,
title: input.title,
description: 'desc',
instructions: 'instr',
assignmentType: input.assignmentType,
expectedDurationDays: 3,
displayOrder: input.sessionOrder,
},
});
createdDefinitionIds.push(def.id);
return def;
}
describe('admin assignments filter', () => {
let dbAvailable = false;
before(async () => {
dbAvailable = await isDatabaseAvailable();
});
after(async () => {
if (!dbAvailable) return;
if (createdDefinitionIds.length > 0) {
await prisma.userAssignment.deleteMany({
where: { definitionId: { in: createdDefinitionIds } },
});
}
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('rejects invalid filter query values with 422', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const { token } = await promoteAndLogin(`filter-invalid-${Date.now()}@example.com`);
const registered = await register(`filter-invalid-target-${Date.now()}@example.com`);
const id = (registered.body.data?.user as { id: string }).id;
const badStatus = await requestJson(
app,
`/admin/users/${id}/assignments?status=PENDING`,
{ headers: authHeader(token) },
);
assert.equal(badStatus.status, 422);
const badType = await requestJson(
app,
`/admin/users/${id}/assignments?assignmentType=WEEKLY`,
{ headers: authHeader(token) },
);
assert.equal(badType.status, 422);
const badSort = await requestJson(
app,
`/admin/users/${id}/assignments?sortBy=passwordHash`,
{ headers: authHeader(token) },
);
assert.equal(badSort.status, 422);
const badDir = await requestJson(
app,
`/admin/users/${id}/assignments?sortDir=sideways`,
{ headers: authHeader(token) },
);
assert.equal(badDir.status, 422);
const longSearch = 'x'.repeat(101);
const badSearch = await requestJson(
app,
`/admin/users/${id}/assignments?search=${longSearch}`,
{ headers: authHeader(token) },
);
assert.equal(badSearch.status, 422);
});
it('filters and searches only the target user assignments', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const stamp = Date.now();
const { token } = await promoteAndLogin(`filter-admin-${stamp}@example.com`);
const targetReg = await register(`filter-target-${stamp}@example.com`);
const otherReg = await register(`filter-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 defJoy = await createDefinition({
key: `filter-joy-${stamp}`,
sessionOrder: 2,
title: 'تمرین شادی ویژه',
assignmentType: AssignmentType.DAILY,
sessionTitle: `جلسه شکرگزاری فیلتر-${stamp}`,
});
const defMeaning = await createDefinition({
key: `filter-meaning-${stamp}`,
sessionOrder: 4,
title: 'تمرین معنا',
assignmentType: AssignmentType.ONE_TIME,
sessionTitle: `جلسه معنایی-${stamp}`,
});
const defOther = await createDefinition({
key: `filter-other-${stamp}`,
sessionOrder: 3,
title: 'تمرین شادی ویژه',
assignmentType: AssignmentType.DAILY,
sessionTitle: `جلسه شکرگزاری فیلتر-${stamp}`,
});
const uaCompleted = await prisma.userAssignment.create({
data: {
userId: target.id,
definitionId: defJoy.id,
status: UserAssignmentStatus.COMPLETED,
activatedAt: new Date('2026-05-01T10:00:00.000Z'),
completedAt: new Date('2026-05-03T10:00:00.000Z'),
},
});
const uaActive = await prisma.userAssignment.create({
data: {
userId: target.id,
definitionId: defMeaning.id,
status: UserAssignmentStatus.ACTIVE,
activatedAt: new Date('2026-05-02T10:00:00.000Z'),
},
});
await prisma.userAssignment.create({
data: {
userId: other.id,
definitionId: defOther.id,
status: UserAssignmentStatus.COMPLETED,
},
});
const beforeCount = await prisma.userAssignment.count({ where: { userId: target.id } });
const noFilter = await requestJson(app, `/admin/users/${targetId}/assignments`, {
headers: authHeader(token),
});
assert.equal(noFilter.status, 200);
const noFilterItems = (noFilter.body.data as { items: Array<{ assignmentId: string }> })
.items;
assert.equal(noFilterItems.length, 2);
const byStatus = await requestJson(
app,
`/admin/users/${targetId}/assignments?status=COMPLETED`,
{ headers: authHeader(token) },
);
assert.equal(byStatus.status, 200);
const statusItems = (
byStatus.body.data as {
items: Array<{ assignmentId: string; status: string }>;
summary: { total: number; completed: number };
}
).items;
assert.equal(statusItems.length, 1);
assert.equal(statusItems[0]!.assignmentId, uaCompleted.id.toString());
assert.equal(statusItems[0]!.status, UserAssignmentStatus.COMPLETED);
assert.equal(
(byStatus.body.data as { summary: { total: number } }).summary.total,
1,
);
const byType = await requestJson(
app,
`/admin/users/${targetId}/assignments?assignmentType=ONE_TIME`,
{ headers: authHeader(token) },
);
assert.equal(byType.status, 200);
const typeItems = (byType.body.data as { items: Array<{ assignmentId: string }> }).items;
assert.equal(typeItems.length, 1);
assert.equal(typeItems[0]!.assignmentId, uaActive.id.toString());
const combined = await requestJson(
app,
`/admin/users/${targetId}/assignments?status=COMPLETED&assignmentType=DAILY`,
{ headers: authHeader(token) },
);
assert.equal(combined.status, 200);
assert.equal((combined.body.data as { items: unknown[] }).items.length, 1);
const searchTitle = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent('شادی')}`,
{ headers: authHeader(token) },
);
assert.equal(searchTitle.status, 200);
assert.equal((searchTitle.body.data as { items: unknown[] }).items.length, 1);
const searchKey = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent(`filter-meaning-${stamp}`)}`,
{ headers: authHeader(token) },
);
assert.equal(searchKey.status, 200);
assert.equal((searchKey.body.data as { items: unknown[] }).items.length, 1);
const searchSession = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent(`شکرگزاری فیلتر-${stamp}`)}`,
{ headers: authHeader(token) },
);
assert.equal(searchSession.status, 200);
assert.equal((searchSession.body.data as { items: unknown[] }).items.length, 1);
const searchMiss = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=zzzz-not-found`,
{ headers: authHeader(token) },
);
assert.equal(searchMiss.status, 200);
assert.equal((searchMiss.body.data as { items: unknown[] }).items.length, 0);
const whitespaceSearch = await requestJson(
app,
`/admin/users/${targetId}/assignments?search=${encodeURIComponent(' ')}`,
{ headers: authHeader(token) },
);
assert.equal(whitespaceSearch.status, 200);
assert.equal((whitespaceSearch.body.data as { items: unknown[] }).items.length, 2);
const sortAsc = await requestJson(
app,
`/admin/users/${targetId}/assignments?sortBy=sessionOrder&sortDir=asc`,
{ headers: authHeader(token) },
);
const sortDesc = await requestJson(
app,
`/admin/users/${targetId}/assignments?sortBy=sessionOrder&sortDir=desc`,
{ headers: authHeader(token) },
);
const ascOrders = (
sortAsc.body.data as { items: Array<{ sessionOrder: number }> }
).items.map((item) => item.sessionOrder);
const descOrders = (
sortDesc.body.data as { items: Array<{ sessionOrder: number }> }
).items.map((item) => item.sessionOrder);
assert.deepEqual(ascOrders, [2, 4]);
assert.deepEqual(descOrders, [4, 2]);
const afterCount = await prisma.userAssignment.count({ where: { userId: target.id } });
assert.equal(afterCount, beforeCount);
assertNoSecrets(byStatus.raw);
assert.equal(!byStatus.raw.includes(otherId), true);
});
it('returns 401/403/404 for filter endpoint', async (t) => {
if (!dbAvailable) {
t.skip('MySQL is not running');
return;
}
const unauth = await requestJson(app, `/admin/users/${randomUUID()}/assignments?status=ACTIVE`);
assert.equal(unauth.status, 401);
const registered = await register(`filter-forbidden-${Date.now()}@example.com`);
const token = registered.body.data?.sessionToken as string;
const id = (registered.body.data?.user as { id: string }).id;
const forbidden = await requestJson(app, `/admin/users/${id}/assignments?status=ACTIVE`, {
headers: authHeader(token),
});
assert.equal(forbidden.status, 403);
const { token: adminToken } = await promoteAndLogin(
`filter-404-${Date.now()}@example.com`,
);
const missing = await requestJson(
app,
`/admin/users/${randomUUID()}/assignments?status=ACTIVE`,
{ headers: authHeader(adminToken) },
);
assert.equal(missing.status, 404);
});
});