Files
MOPC-Portal/tests/unit/admin-proxy-evaluation.test.ts
Matt f37a9b49b5
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m0s
test: add integration coverage for admin proxy evaluation
Seven scenarios covering the new admin procedures:
- non-admin users are rejected on adminStart/adminSubmitOnBehalf
- admin can list a juror's assignments with COI flag surfaced
- admin can complete the full draft→autosave→submit cycle with the
  voting window already closed, confirming bypass works
- COI-declared assignments still block admin submission
- feedback minimum length is enforced on admin submit
- adminAutosave refuses to overwrite a SUBMITTED evaluation
- audit log captures admin id, juror id, and bypassedWindow flag

Also swaps a lucide icon alias (FileEdit → Pencil) in the juror
assignments page to avoid the deprecated alias form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:00:03 +02:00

248 lines
8.4 KiB
TypeScript

/**
* 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<string, number>).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<string, unknown>
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)
})
})