373 lines
12 KiB
TypeScript
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);
|
||
|
|
});
|
||
|
|
});
|