import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { randomUUID } from 'node:crypto'; import { ChatRole, 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[] = []; 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) { const existing = await prisma.courseSession.findUnique({ where: { order } }); if (existing) return existing; return prisma.courseSession.create({ data: { sessionKey: `admin-chat-test-${order}`, order, title: `Test Session ${order}`, description: 'test', estimatedDurationMinutes: 10, blocks: [], }, }); } async function createChatWithMessages( userId: bigint, sessionOrder: number, messages: Array<{ role: ChatRole; content: string }>, ) { await ensureCourseSession(sessionOrder); const session = await prisma.chatSession.create({ data: { userId, sessionOrder, messages: { create: messages.map((message) => ({ role: message.role, content: message.content, })), }, }, }); return session; } describe('admin chat sessions', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); }); after(async () => { if (dbAvailable && createdUserIds.length > 0) { await prisma.auditLog.deleteMany({ where: { OR: [ { actorUserId: { in: createdUserIds } }, { targetUserId: { in: createdUserIds } }, ], }, }); await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); } }); it('returns 401 without session for chat list', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { status, raw } = await requestJson( app, `/admin/users/${randomUUID()}/chat-sessions`, ); 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(`chat-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}/chat-sessions`, { headers: authHeader(token), }); assert.equal(status, 403); assert.equal(body.error?.code, 'FORBIDDEN'); assertNoSecrets(raw); }); it('returns 422 for invalid externalId and sessionId', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { token } = await promoteAndLogin(`chat-invalid-${Date.now()}@example.com`); const badUser = await requestJson(app, '/admin/users/not-a-uuid/chat-sessions', { headers: authHeader(token), }); assert.equal(badUser.status, 422); assert.equal(badUser.body.error?.code, 'VALIDATION_ERROR'); const badSession = await requestJson( app, `/admin/users/${randomUUID()}/chat-sessions/not-numeric`, { headers: authHeader(token) }, ); assert.equal(badSession.status, 422); assert.equal(badSession.body.error?.code, 'VALIDATION_ERROR'); assertNoSecrets(badUser.raw); assertNoSecrets(badSession.raw); }); it('returns 404 for missing user and empty list for user without chats', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const { token } = await promoteAndLogin(`chat-empty-admin-${stamp}@example.com`); const missing = await requestJson(app, `/admin/users/${randomUUID()}/chat-sessions`, { headers: authHeader(token), }); assert.equal(missing.status, 404); const target = await register(`chat-empty-${stamp}@example.com`); const targetId = (target.body.data?.user as { id: string }).id; const empty = await requestJson(app, `/admin/users/${targetId}/chat-sessions`, { headers: authHeader(token), }); assert.equal(empty.status, 200); const data = empty.body.data as { items: unknown[]; pagination: { total: number }; }; assert.equal(data.items.length, 0); assert.equal(data.pagination.total, 0); assertNoSecrets(empty.raw); }); it('lists only the target user sessions with pagination, counts, and audit', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const targetReg = await register(`chat-list-${stamp}@example.com`); const otherReg = await register(`chat-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 } }); await createChatWithMessages(target.id, 1, [ { role: ChatRole.user, content: 'hello one' }, { role: ChatRole.assistant, content: 'hi one' }, ]); await createChatWithMessages(target.id, 2, [ { role: ChatRole.user, content: 'hello two' }, ]); await createChatWithMessages(other.id, 1, [ { role: ChatRole.user, content: 'secret other user' }, ]); const { token, userId: adminId } = await promoteAndLogin( `chat-list-admin-${stamp}@example.com`, ); const listed = await requestJson( app, `/admin/users/${targetId}/chat-sessions?page=1&pageSize=1&sortBy=sessionOrder&sortDir=asc`, { headers: authHeader(token) }, ); assert.equal(listed.status, 200); assertNoSecrets(listed.raw); const page1 = listed.body.data as { items: Array<{ id: string; messageCount: number; sessionOrder: number; title: string }>; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }; assert.equal(page1.pagination.page, 1); assert.equal(page1.pagination.pageSize, 1); assert.equal(page1.pagination.total, 2); assert.equal(page1.pagination.totalPages, 2); assert.equal(page1.items.length, 1); assert.equal(page1.items[0]?.sessionOrder, 1); assert.equal(page1.items[0]?.messageCount, 2); assert.match(page1.items[0]?.title ?? '', /جلسه 1/); const page2 = await requestJson( app, `/admin/users/${targetId}/chat-sessions?page=2&pageSize=1&sortBy=sessionOrder&sortDir=asc`, { headers: authHeader(token) }, ); const page2Data = page2.body.data as { items: Array<{ sessionOrder: number; messageCount: number }>; }; assert.equal(page2Data.items[0]?.sessionOrder, 2); assert.equal(page2Data.items[0]?.messageCount, 1); const raw = listed.raw; assert.equal(/secret other user/i.test(raw), false); assert.equal(/passwordHash|tokenHash/i.test(raw), false); const badSort = await requestJson( app, `/admin/users/${targetId}/chat-sessions?sortBy=passwordHash`, { headers: authHeader(token) }, ); assert.equal(badSort.status, 422); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: adminId, targetUserId: target.id, action: AUDIT_ACTIONS.ADMIN_VIEW_CHATS, }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const meta = JSON.stringify(audit.metadata ?? {}); assert.match(meta, /chat_sessions/); assert.equal(/hello one|secret other/i.test(meta), false); }); it('returns session detail with ordered messages and ownership checks', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const stamp = Date.now(); const targetReg = await register(`chat-detail-${stamp}@example.com`); const otherReg = await register(`chat-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 owned = await createChatWithMessages(target.id, 3, [ { role: ChatRole.user, content: 'first user msg' }, { role: ChatRole.assistant, content: 'first assistant msg' }, { role: ChatRole.user, content: 'second user msg' }, ]); const foreign = await createChatWithMessages(other.id, 3, [ { role: ChatRole.user, content: 'foreign message' }, ]); const { token, userId: adminId } = await promoteAndLogin( `chat-detail-admin-${stamp}@example.com`, ); const detail = await requestJson( app, `/admin/users/${targetId}/chat-sessions/${owned.id.toString()}`, { headers: authHeader(token) }, ); assert.equal(detail.status, 200); assertNoSecrets(detail.raw); const data = detail.body.data as { session: { id: string; messageCount: number }; messages: Array<{ role: string; content: string; sequence: number }>; }; assert.equal(data.session.id, owned.id.toString()); assert.equal(data.session.messageCount, 3); assert.equal(data.messages.length, 3); assert.equal(data.messages[0]?.content, 'first user msg'); assert.equal(data.messages[0]?.role, 'user'); assert.equal(data.messages[0]?.sequence, 1); assert.equal(data.messages[1]?.role, 'assistant'); assert.equal(data.messages[2]?.sequence, 3); assert.equal(/foreign message/i.test(detail.raw), false); assert.equal('metadata' in (data.messages[0] ?? {}), false); const cross = await requestJson( app, `/admin/users/${targetId}/chat-sessions/${foreign.id.toString()}`, { headers: authHeader(token) }, ); assert.equal(cross.status, 404); const missing = await requestJson( app, `/admin/users/${targetId}/chat-sessions/999999999`, { headers: authHeader(token) }, ); assert.equal(missing.status, 404); const audit = await prisma.auditLog.findFirst({ where: { actorUserId: adminId, targetUserId: target.id, action: AUDIT_ACTIONS.ADMIN_VIEW_CHAT_MESSAGES, }, orderBy: { createdAt: 'desc' }, }); assert.ok(audit); const meta = JSON.stringify(audit.metadata ?? {}); assert.match(meta, /chat_session/); assert.equal(/first user msg|foreign message/i.test(meta), false); }); });