All checks were successful
Build and Push Docker Image / build (push) Successful in 8m0s
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>
248 lines
8.4 KiB
TypeScript
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)
|
|
})
|
|
})
|