/** * Integration tests for admin proxy-evaluation flow. * * Exercises the three new admin procedures in src/server/routers/evaluation.ts * (adminStart, adminAutosave, adminSubmitOnBehalf) and getJurorAssignmentsForRound. * * These tests require a reachable Postgres matching DATABASE_URL_TEST * (or DATABASE_URL). Run with: * npx vitest run tests/unit/admin-proxy-evaluation.test.ts */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { TRPCError } from '@trpc/server' import { evaluationRouter } from '@/server/routers/evaluation' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestCompetition, createTestRound, createTestProject, createTestAssignment, createTestEvaluationForm, createTestCOI, cleanupTestData, } from '../helpers' describe('admin proxy evaluation', () => { let programId: string let adminId: string let jurorId: string let otherJurorId: string let projectId: string let projectWithCoiId: string let roundId: string let assignmentId: string let coiAssignmentId: string let formId: string const createdUserIds: string[] = [] beforeAll(async () => { const program = await createTestProgram() programId = program.id const admin = await createTestUser('PROGRAM_ADMIN', { email: `admin-${Date.now()}@test.local` }) adminId = admin.id createdUserIds.push(adminId) const juror = await createTestUser('JURY_MEMBER', { email: `juror-${Date.now()}@test.local` }) jurorId = juror.id createdUserIds.push(jurorId) const otherJuror = await createTestUser('JURY_MEMBER', { email: `other-${Date.now()}@test.local` }) otherJurorId = otherJuror.id createdUserIds.push(otherJurorId) const competition = await createTestCompetition(programId) // Round with CLOSED voting window to prove bypass works const round = await createTestRound(competition.id, { status: 'ROUND_ACTIVE', windowOpenAt: new Date(Date.now() - 7 * 86_400_000), // 7 days ago windowCloseAt: new Date(Date.now() - 1 * 86_400_000), // closed 1 day ago configJson: { scoringMode: 'criteria', requireFeedback: true, feedbackMinLength: 10, }, }) roundId = round.id const project = await createTestProject(programId, { title: 'Proxy Test Project', competitionCategory: 'STARTUP', }) projectId = project.id const projectWithCoi = await createTestProject(programId, { title: 'COI Test Project', competitionCategory: 'STARTUP', }) projectWithCoiId = projectWithCoi.id const assignment = await createTestAssignment(jurorId, projectId, roundId) assignmentId = assignment.id const coiAssignment = await createTestAssignment(jurorId, projectWithCoiId, roundId) coiAssignmentId = coiAssignment.id await createTestCOI(coiAssignmentId, jurorId, projectWithCoiId, true) const form = await createTestEvaluationForm(roundId, [ { id: 'c1', label: 'Innovation', scale: '1-10', weight: 1 }, { id: 'c2', label: 'Impact', scale: '1-10', weight: 1 }, ]) formId = form.id }) afterAll(async () => { await cleanupTestData(programId, createdUserIds) }) it('rejects non-admin callers on adminSubmitOnBehalf', async () => { const juror = await prisma.user.findUniqueOrThrow({ where: { id: jurorId } }) const caller = createCaller(evaluationRouter, juror) await expect( caller.adminSubmitOnBehalf({ id: 'any-id', criterionScoresJson: { c1: 8, c2: 7 }, feedbackText: 'some feedback for this test', }), ).rejects.toThrow(TRPCError) }) it('rejects non-admin callers on adminStart', async () => { const juror = await prisma.user.findUniqueOrThrow({ where: { id: jurorId } }) const caller = createCaller(evaluationRouter, juror) await expect(caller.adminStart({ assignmentId })).rejects.toThrow(TRPCError) }) it('lists juror assignments for admin including COI flag', async () => { const admin = await prisma.user.findUniqueOrThrow({ where: { id: adminId } }) const caller = createCaller(evaluationRouter, admin) const result = await caller.getJurorAssignmentsForRound({ roundId, userId: jurorId }) expect(result.juror.id).toBe(jurorId) expect(result.round.id).toBe(roundId) expect(result.assignments).toHaveLength(2) const coiAssignment = result.assignments.find( (a: (typeof result.assignments)[number]) => a.project.id === projectWithCoiId, ) expect(coiAssignment?.conflictOfInterest?.hasConflict).toBe(true) }) it('admin can create + autosave + submit an evaluation outside the voting window', async () => { const admin = await prisma.user.findUniqueOrThrow({ where: { id: adminId } }) const caller = createCaller(evaluationRouter, admin) // Start const evaluation = await caller.adminStart({ assignmentId }) expect(evaluation.status).toBe('DRAFT') expect(evaluation.assignmentId).toBe(assignmentId) expect(evaluation.formId).toBe(formId) // Autosave const draft = await caller.adminAutosave({ id: evaluation.id, criterionScoresJson: { c1: 6 }, feedbackText: 'partial feedback draft', }) expect(draft.status).toBe('DRAFT') expect((draft.criterionScoresJson as Record).c1).toBe(6) // Submit — window is closed, should bypass successfully const submitted = await caller.adminSubmitOnBehalf({ id: evaluation.id, criterionScoresJson: { c1: 8, c2: 9 }, globalScore: 9, feedbackText: 'Comprehensive feedback from the admin proxy', }) expect(submitted.status).toBe('SUBMITTED') expect(submitted.submittedAt).not.toBeNull() // Assignment marked complete const updatedAssignment = await prisma.assignment.findUniqueOrThrow({ where: { id: assignmentId }, }) expect(updatedAssignment.isCompleted).toBe(true) // Audit entry captures both IDs + bypassedWindow flag const audit = await prisma.auditLog.findFirst({ where: { action: 'ADMIN_PROXY_EVAL_SUBMITTED', entityId: evaluation.id, }, orderBy: { timestamp: 'desc' }, }) expect(audit).not.toBeNull() expect(audit?.userId).toBe(adminId) const details = audit?.detailsJson as Record expect(details.adminUserId).toBe(adminId) expect(details.onBehalfOfUserId).toBe(jurorId) expect(details.assignmentId).toBe(assignmentId) expect(details.bypassedWindow).toBe(true) }) it('admin cannot submit for a juror who declared COI', async () => { const admin = await prisma.user.findUniqueOrThrow({ where: { id: adminId } }) const caller = createCaller(evaluationRouter, admin) // Start is permitted (admin may still want to inspect), but submit must block const evaluation = await caller.adminStart({ assignmentId: coiAssignmentId }) await expect( caller.adminSubmitOnBehalf({ id: evaluation.id, criterionScoresJson: { c1: 5, c2: 5 }, feedbackText: 'should not be accepted because of conflict', }), ).rejects.toThrowError(/conflict of interest/i) }) it('enforces feedback minimum length on admin submit', async () => { const admin = await prisma.user.findUniqueOrThrow({ where: { id: adminId } }) const caller = createCaller(evaluationRouter, admin) // Fresh assignment for a clean run const anotherProject = await createTestProject(programId, { title: 'Validation Test', competitionCategory: 'STARTUP', }) const anotherAssignment = await createTestAssignment(otherJurorId, anotherProject.id, roundId) const evaluation = await caller.adminStart({ assignmentId: anotherAssignment.id }) await expect( caller.adminSubmitOnBehalf({ id: evaluation.id, criterionScoresJson: { c1: 7, c2: 8 }, feedbackText: 'too short', // under 10 char minimum }), ).rejects.toThrowError(/at least/i) }) it('adminAutosave refuses to overwrite a SUBMITTED evaluation', async () => { const admin = await prisma.user.findUniqueOrThrow({ where: { id: adminId } }) const caller = createCaller(evaluationRouter, admin) // The first test already submitted the main assignment's evaluation const submittedEval = await prisma.evaluation.findUniqueOrThrow({ where: { assignmentId }, }) await expect( caller.adminAutosave({ id: submittedEval.id, criterionScoresJson: { c1: 1 }, }), ).rejects.toThrowError(/submitted/i) }) })