diff --git a/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx index af34017..da0b9f0 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/jurors/[userId]/evaluate/page.tsx @@ -13,7 +13,7 @@ import { ArrowRight, CheckCircle2, Clock, - FileEdit, + Pencil, ShieldAlert, UserCheck, } from 'lucide-react' @@ -184,7 +184,7 @@ function AssignmentRow({ roundId, userId, assignment, mode }: AssignmentRowProps evaluation?.status === 'SUBMITTED' || evaluation?.status === 'LOCKED' ? CheckCircle2 : evaluation?.status === 'DRAFT' - ? FileEdit + ? Pencil : Clock const href = `/admin/rounds/${roundId}/jurors/${userId}/evaluate/${project.id}` as Route diff --git a/tests/unit/admin-proxy-evaluation.test.ts b/tests/unit/admin-proxy-evaluation.test.ts new file mode 100644 index 0000000..c05b2b5 --- /dev/null +++ b/tests/unit/admin-proxy-evaluation.test.ts @@ -0,0 +1,247 @@ +/** + * 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) + }) +})