Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
163
tests/helpers.ts
163
tests/helpers.ts
@@ -10,10 +10,10 @@ import { randomUUID } from 'crypto'
|
||||
import { prisma } from './setup'
|
||||
import type {
|
||||
UserRole,
|
||||
StageType,
|
||||
StageStatus,
|
||||
TrackKind,
|
||||
ProjectStageStateValue,
|
||||
RoundType,
|
||||
RoundStatus,
|
||||
CompetitionStatus,
|
||||
ProjectRoundStateValue,
|
||||
AssignmentMethod,
|
||||
} from '@prisma/client'
|
||||
|
||||
@@ -65,76 +65,52 @@ export async function createTestProgram(
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Pipeline Factory ──────────────────────────────────────────────────────
|
||||
// ─── Competition Factory ───────────────────────────────────────────────────
|
||||
|
||||
export async function createTestPipeline(
|
||||
export async function createTestCompetition(
|
||||
programId: string,
|
||||
overrides: Partial<{ name: string; slug: string; status: string }> = {},
|
||||
overrides: Partial<{
|
||||
name: string
|
||||
slug: string
|
||||
status: CompetitionStatus
|
||||
}> = {},
|
||||
) {
|
||||
const id = uid('pipe')
|
||||
return prisma.pipeline.create({
|
||||
const id = uid('comp')
|
||||
return prisma.competition.create({
|
||||
data: {
|
||||
id,
|
||||
programId,
|
||||
name: overrides.name ?? `Pipeline ${id}`,
|
||||
name: overrides.name ?? `Competition ${id}`,
|
||||
slug: overrides.slug ?? id,
|
||||
status: overrides.status ?? 'DRAFT',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Track Factory ─────────────────────────────────────────────────────────
|
||||
// ─── Round Factory ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTestTrack(
|
||||
pipelineId: string,
|
||||
export async function createTestRound(
|
||||
competitionId: string,
|
||||
overrides: Partial<{
|
||||
name: string
|
||||
slug: string
|
||||
kind: TrackKind
|
||||
sortOrder: number
|
||||
routingMode: string
|
||||
decisionMode: string
|
||||
}> = {},
|
||||
) {
|
||||
const id = uid('track')
|
||||
return prisma.track.create({
|
||||
data: {
|
||||
id,
|
||||
pipelineId,
|
||||
name: overrides.name ?? `Track ${id}`,
|
||||
slug: overrides.slug ?? id,
|
||||
kind: overrides.kind ?? 'MAIN',
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
routingMode: (overrides.routingMode as any) ?? null,
|
||||
decisionMode: (overrides.decisionMode as any) ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Stage Factory ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTestStage(
|
||||
trackId: string,
|
||||
overrides: Partial<{
|
||||
name: string
|
||||
slug: string
|
||||
stageType: StageType
|
||||
status: StageStatus
|
||||
roundType: RoundType
|
||||
status: RoundStatus
|
||||
sortOrder: number
|
||||
configJson: Record<string, unknown>
|
||||
windowOpenAt: Date
|
||||
windowCloseAt: Date
|
||||
}> = {},
|
||||
) {
|
||||
const id = uid('stage')
|
||||
return prisma.stage.create({
|
||||
const id = uid('round')
|
||||
return prisma.round.create({
|
||||
data: {
|
||||
id,
|
||||
trackId,
|
||||
name: overrides.name ?? `Stage ${id}`,
|
||||
competitionId,
|
||||
name: overrides.name ?? `Round ${id}`,
|
||||
slug: overrides.slug ?? id,
|
||||
stageType: overrides.stageType ?? 'EVALUATION',
|
||||
status: overrides.status ?? 'STAGE_ACTIVE',
|
||||
roundType: overrides.roundType ?? 'EVALUATION',
|
||||
status: overrides.status ?? 'ROUND_ACTIVE',
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
configJson: (overrides.configJson as any) ?? undefined,
|
||||
windowOpenAt: overrides.windowOpenAt ?? null,
|
||||
@@ -143,23 +119,6 @@ export async function createTestStage(
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Stage Transition Factory ──────────────────────────────────────────────
|
||||
|
||||
export async function createTestTransition(
|
||||
fromStageId: string,
|
||||
toStageId: string,
|
||||
overrides: Partial<{ isDefault: boolean; guardJson: Record<string, unknown> }> = {},
|
||||
) {
|
||||
return prisma.stageTransition.create({
|
||||
data: {
|
||||
fromStageId,
|
||||
toStageId,
|
||||
isDefault: overrides.isDefault ?? true,
|
||||
guardJson: (overrides.guardJson as any) ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Project Factory ───────────────────────────────────────────────────────
|
||||
|
||||
export async function createTestProject(
|
||||
@@ -188,22 +147,20 @@ export async function createTestProject(
|
||||
})
|
||||
}
|
||||
|
||||
// ─── ProjectStageState Factory ─────────────────────────────────────────────
|
||||
// ─── ProjectRoundState Factory ─────────────────────────────────────────────
|
||||
|
||||
export async function createTestPSS(
|
||||
export async function createTestProjectRoundState(
|
||||
projectId: string,
|
||||
trackId: string,
|
||||
stageId: string,
|
||||
roundId: string,
|
||||
overrides: Partial<{
|
||||
state: ProjectStageStateValue
|
||||
state: ProjectRoundStateValue
|
||||
exitedAt: Date | null
|
||||
}> = {},
|
||||
) {
|
||||
return prisma.projectStageState.create({
|
||||
return prisma.projectRoundState.create({
|
||||
data: {
|
||||
projectId,
|
||||
trackId,
|
||||
stageId,
|
||||
roundId,
|
||||
state: overrides.state ?? 'PENDING',
|
||||
exitedAt: overrides.exitedAt ?? null,
|
||||
},
|
||||
@@ -215,7 +172,7 @@ export async function createTestPSS(
|
||||
export async function createTestAssignment(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
stageId: string,
|
||||
roundId: string,
|
||||
overrides: Partial<{
|
||||
method: AssignmentMethod
|
||||
isCompleted: boolean
|
||||
@@ -225,7 +182,7 @@ export async function createTestAssignment(
|
||||
data: {
|
||||
userId,
|
||||
projectId,
|
||||
stageId,
|
||||
roundId,
|
||||
method: overrides.method ?? 'MANUAL',
|
||||
isCompleted: overrides.isCompleted ?? false,
|
||||
},
|
||||
@@ -235,7 +192,7 @@ export async function createTestAssignment(
|
||||
// ─── Evaluation Form Factory ───────────────────────────────────────────────
|
||||
|
||||
export async function createTestEvaluationForm(
|
||||
stageId: string,
|
||||
roundId: string,
|
||||
criteria: Array<{
|
||||
id: string
|
||||
label: string
|
||||
@@ -245,7 +202,7 @@ export async function createTestEvaluationForm(
|
||||
) {
|
||||
return prisma.evaluationForm.create({
|
||||
data: {
|
||||
stageId,
|
||||
roundId,
|
||||
criteriaJson: criteria.length > 0
|
||||
? criteria
|
||||
: [
|
||||
@@ -261,7 +218,7 @@ export async function createTestEvaluationForm(
|
||||
// ─── Filtering Rule Factory ────────────────────────────────────────────────
|
||||
|
||||
export async function createTestFilteringRule(
|
||||
stageId: string,
|
||||
roundId: string,
|
||||
overrides: Partial<{
|
||||
name: string
|
||||
ruleType: string
|
||||
@@ -271,7 +228,7 @@ export async function createTestFilteringRule(
|
||||
) {
|
||||
return prisma.filteringRule.create({
|
||||
data: {
|
||||
stageId,
|
||||
roundId,
|
||||
name: overrides.name ?? 'Test Filter Rule',
|
||||
ruleType: (overrides.ruleType as any) ?? 'DOCUMENT_CHECK',
|
||||
configJson: (overrides.configJson ?? { requiredFileTypes: ['EXEC_SUMMARY'], action: 'REJECT' }) as any,
|
||||
@@ -304,7 +261,7 @@ export async function createTestCOI(
|
||||
// ─── Cohort + CohortProject Factory ────────────────────────────────────────
|
||||
|
||||
export async function createTestCohort(
|
||||
stageId: string,
|
||||
roundId: string,
|
||||
overrides: Partial<{
|
||||
name: string
|
||||
isOpen: boolean
|
||||
@@ -315,7 +272,7 @@ export async function createTestCohort(
|
||||
return prisma.cohort.create({
|
||||
data: {
|
||||
id,
|
||||
stageId,
|
||||
roundId,
|
||||
name: overrides.name ?? `Cohort ${id}`,
|
||||
isOpen: overrides.isOpen ?? false,
|
||||
votingMode: overrides.votingMode ?? 'simple',
|
||||
@@ -350,31 +307,31 @@ export async function cleanupTestData(programId: string, userIds: string[] = [])
|
||||
await prisma.overrideAction.deleteMany({ where: { actorId: { in: userIds } } })
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||
}
|
||||
await prisma.cohortProject.deleteMany({ where: { cohort: { stage: { track: { pipeline: { programId } } } } } })
|
||||
await prisma.cohort.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.liveProgressCursor.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.filteringResult.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.filteringRule.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.filteringJob.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.assignmentJob.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { stage: { track: { pipeline: { programId } } } } } })
|
||||
await prisma.evaluation.deleteMany({ where: { assignment: { stage: { track: { pipeline: { programId } } } } } })
|
||||
await prisma.assignment.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.evaluationForm.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.fileRequirement.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.gracePeriod.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.reminderLog.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.evaluationSummary.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.evaluationDiscussion.deleteMany({ where: { stage: { track: { pipeline: { programId } } } } })
|
||||
await prisma.projectStageState.deleteMany({ where: { track: { pipeline: { programId } } } })
|
||||
await prisma.stageTransition.deleteMany({ where: { fromStage: { track: { pipeline: { programId } } } } })
|
||||
// Competition/Round cascade cleanup
|
||||
await prisma.cohortProject.deleteMany({ where: { cohort: { round: { competition: { programId } } } } })
|
||||
await prisma.cohort.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.liveProgressCursor.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.filteringResult.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.filteringRule.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.filteringJob.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.assignmentJob.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
|
||||
await prisma.evaluation.deleteMany({ where: { assignment: { round: { competition: { programId } } } } })
|
||||
await prisma.assignment.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.evaluationForm.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.fileRequirement.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.gracePeriod.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.reminderLog.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.evaluationSummary.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.evaluationDiscussion.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.advancementRule.deleteMany({ where: { round: { competition: { programId } } } })
|
||||
await prisma.awardEligibility.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||
await prisma.awardVote.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||
await prisma.awardJuror.deleteMany({ where: { award: { program: { id: programId } } } })
|
||||
await prisma.specialAward.deleteMany({ where: { programId } })
|
||||
await prisma.stage.deleteMany({ where: { track: { pipeline: { programId } } } })
|
||||
await prisma.track.deleteMany({ where: { pipeline: { programId } } })
|
||||
await prisma.pipeline.deleteMany({ where: { programId } })
|
||||
await prisma.round.deleteMany({ where: { competition: { programId } } })
|
||||
await prisma.competition.deleteMany({ where: { programId } })
|
||||
await prisma.projectStatusHistory.deleteMany({ where: { project: { programId } } })
|
||||
await prisma.projectFile.deleteMany({ where: { project: { programId } } })
|
||||
await prisma.projectTag.deleteMany({ where: { project: { programId } } })
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* I-005: Assignment API — Preview vs Execute Parity
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { previewStageAssignment, executeStageAssignment } from '@/server/services/stage-assignment'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Assignment Preview Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('I-005: Assignment Preview vs Execute Parity', () => {
|
||||
it('preview and execute produce matching assignment pairs', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Assignment Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create 3 jurors
|
||||
const juror1 = await createTestUser('JURY_MEMBER', { name: 'Juror Preview 1' })
|
||||
const juror2 = await createTestUser('JURY_MEMBER', { name: 'Juror Preview 2' })
|
||||
const juror3 = await createTestUser('JURY_MEMBER', { name: 'Juror Preview 3' })
|
||||
userIds.push(juror1.id, juror2.id, juror3.id)
|
||||
|
||||
// Create 2 projects
|
||||
const proj1 = await createTestProject(programId, { title: 'Preview P1' })
|
||||
const proj2 = await createTestProject(programId, { title: 'Preview P2' })
|
||||
await createTestPSS(proj1.id, track.id, stage.id, { state: 'PENDING' })
|
||||
await createTestPSS(proj2.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
const config = { requiredReviews: 2 }
|
||||
|
||||
// Step 1: Preview
|
||||
const preview = await previewStageAssignment(stage.id, config, prisma)
|
||||
|
||||
expect(preview.assignments.length).toBeGreaterThan(0)
|
||||
expect(preview.stats.totalProjects).toBe(2)
|
||||
|
||||
// Step 2: Execute with the same pairs from preview
|
||||
const assignmentInputs = preview.assignments.map(a => ({
|
||||
userId: a.userId,
|
||||
projectId: a.projectId,
|
||||
}))
|
||||
|
||||
const execResult = await executeStageAssignment(
|
||||
stage.id, assignmentInputs, admin.id, prisma
|
||||
)
|
||||
|
||||
expect(execResult.created).toBe(assignmentInputs.length)
|
||||
expect(execResult.errors).toHaveLength(0)
|
||||
|
||||
// Step 3: Verify all assignments exist in database
|
||||
const dbAssignments = await prisma.assignment.findMany({
|
||||
where: { stageId: stage.id },
|
||||
})
|
||||
|
||||
expect(dbAssignments.length).toBe(assignmentInputs.length)
|
||||
|
||||
// Verify each preview pair has a matching DB record
|
||||
for (const input of assignmentInputs) {
|
||||
const match = dbAssignments.find(
|
||||
a => a.userId === input.userId && a.projectId === input.projectId
|
||||
)
|
||||
expect(match).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* I-007: Cohort Voting — Closed Window Submit
|
||||
*
|
||||
* Tests that audience votes are rejected when the cohort voting window is closed.
|
||||
* The castVote procedure checks for an open cohort before accepting votes.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma, createTestContext } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestCohort,
|
||||
createTestCohortProject,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { closeCohortWindow, openCohortWindow } from '@/server/services/live-control'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Cohort Voting Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('I-007: Cohort Voting — Closed Window Submit', () => {
|
||||
it('cohort starts closed, opens, then closes correctly', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Live Final Voting',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const project = await createTestProject(programId, { title: 'Vote Project' })
|
||||
const cohort = await createTestCohort(stage.id, {
|
||||
name: 'Voting Cohort',
|
||||
isOpen: false,
|
||||
})
|
||||
await createTestCohortProject(cohort.id, project.id, 0)
|
||||
|
||||
// Verify cohort starts closed
|
||||
const initialCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(initialCohort!.isOpen).toBe(false)
|
||||
|
||||
// Open the cohort window
|
||||
const openResult = await openCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(openResult.success).toBe(true)
|
||||
|
||||
const openedCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(openedCohort!.isOpen).toBe(true)
|
||||
expect(openedCohort!.windowOpenAt).not.toBeNull()
|
||||
|
||||
// Close the cohort window
|
||||
const closeResult = await closeCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(closeResult.success).toBe(true)
|
||||
|
||||
const closedCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(closedCohort!.isOpen).toBe(false)
|
||||
expect(closedCohort!.windowCloseAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects opening an already-open cohort', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Double Open Test',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const cohort = await createTestCohort(stage.id, {
|
||||
name: 'Already Open Cohort',
|
||||
isOpen: true, // Already open
|
||||
})
|
||||
|
||||
const result = await openCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors!.some(e => e.includes('already open'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects closing an already-closed cohort', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Double Close Test',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const cohort = await createTestCohort(stage.id, {
|
||||
name: 'Already Closed Cohort',
|
||||
isOpen: false,
|
||||
})
|
||||
|
||||
const result = await closeCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors!.some(e => e.includes('already closed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('creates audit trail for cohort window operations', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Audit Trail Test',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const cohort = await createTestCohort(stage.id, {
|
||||
name: 'Audit Cohort',
|
||||
isOpen: false,
|
||||
})
|
||||
|
||||
// Open then close — verify both succeed
|
||||
const openRes = await openCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(openRes.success).toBe(true)
|
||||
const closeRes = await closeCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(closeRes.success).toBe(true)
|
||||
|
||||
// Verify audit trail
|
||||
const auditLogs = await prisma.decisionAuditLog.findMany({
|
||||
where: {
|
||||
entityType: 'Cohort',
|
||||
entityId: cohort.id,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
expect(auditLogs.length).toBe(2)
|
||||
expect(auditLogs[0].eventType).toBe('live.cohort_opened')
|
||||
expect(auditLogs[1].eventType).toBe('live.cohort_closed')
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* I-008: Decision Audit — Override Applied with Immutable Timeline
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma, createTestContext } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { decisionRouter } from '@/server/routers/decision'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Decision Audit Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('I-008: Decision Audit — Override with Immutable Timeline', () => {
|
||||
it('creates OverrideAction and DecisionAuditLog preserving original state', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, { status: 'STAGE_ACTIVE' })
|
||||
const project = await createTestProject(programId, { title: 'Audit Project' })
|
||||
const pss = await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = decisionRouter.createCaller(ctx)
|
||||
|
||||
// Apply override: PENDING → PASSED
|
||||
await caller.override({
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: pss.id,
|
||||
newValue: { state: 'PASSED' },
|
||||
reasonCode: 'POLICY_EXCEPTION',
|
||||
reasonText: 'Special committee decision',
|
||||
})
|
||||
|
||||
// 1. Verify OverrideAction preserves the original state
|
||||
const overrideAction = await prisma.overrideAction.findFirst({
|
||||
where: { entityType: 'ProjectStageState', entityId: pss.id },
|
||||
})
|
||||
expect(overrideAction).not.toBeNull()
|
||||
const prevValue = overrideAction!.previousValue as Record<string, unknown>
|
||||
expect(prevValue.state).toBe('PENDING')
|
||||
expect(overrideAction!.reasonCode).toBe('POLICY_EXCEPTION')
|
||||
expect(overrideAction!.reasonText).toBe('Special committee decision')
|
||||
expect(overrideAction!.actorId).toBe(admin.id)
|
||||
|
||||
// 2. Verify DecisionAuditLog was created
|
||||
const auditLog = await prisma.decisionAuditLog.findFirst({
|
||||
where: { entityType: 'ProjectStageState', entityId: pss.id, eventType: 'override.applied' },
|
||||
})
|
||||
expect(auditLog).not.toBeNull()
|
||||
expect(auditLog!.actorId).toBe(admin.id)
|
||||
const details = auditLog!.detailsJson as Record<string, unknown>
|
||||
expect(details.reasonCode).toBe('POLICY_EXCEPTION')
|
||||
|
||||
// 3. Verify the actual state was updated
|
||||
const updatedPSS = await prisma.projectStageState.findUnique({ where: { id: pss.id } })
|
||||
expect(updatedPSS!.state).toBe('PASSED')
|
||||
|
||||
// 4. Verify immutable timeline via the auditTimeline procedure
|
||||
const timeline = await caller.auditTimeline({
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: pss.id,
|
||||
})
|
||||
expect(timeline.timeline.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const overrideEntry = timeline.timeline.find(t => t.type === 'override')
|
||||
expect(overrideEntry).toBeDefined()
|
||||
expect((overrideEntry!.details as any).reasonCode).toBe('POLICY_EXCEPTION')
|
||||
|
||||
// 5. Apply a second override: PASSED → REJECTED
|
||||
await caller.override({
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: pss.id,
|
||||
newValue: { state: 'REJECTED' },
|
||||
reasonCode: 'DATA_CORRECTION',
|
||||
reasonText: 'Correcting previous override',
|
||||
})
|
||||
|
||||
// 6. Verify both overrides exist in the timeline
|
||||
const fullTimeline = await caller.auditTimeline({
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: pss.id,
|
||||
})
|
||||
|
||||
const overrides = fullTimeline.timeline.filter(t => t.type === 'override')
|
||||
expect(overrides.length).toBe(2)
|
||||
|
||||
// 7. Verify second override preserved PASSED as previous state
|
||||
const secondOverride = await prisma.overrideAction.findFirst({
|
||||
where: { entityType: 'ProjectStageState', entityId: pss.id, reasonCode: 'DATA_CORRECTION' },
|
||||
})
|
||||
expect(secondOverride).not.toBeNull()
|
||||
const secondPrevValue = secondOverride!.previousValue as Record<string, unknown>
|
||||
expect(secondPrevValue.state).toBe('PASSED')
|
||||
})
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* I-006: Live Runtime — Jump / Reorder / Open-Close Windows
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestCohort,
|
||||
createTestCohortProject,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import {
|
||||
startSession,
|
||||
jumpToProject,
|
||||
reorderQueue,
|
||||
pauseResume,
|
||||
openCohortWindow,
|
||||
closeCohortWindow,
|
||||
} from '@/server/services/live-control'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Live Runtime Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('I-006: Live Runtime Operations', () => {
|
||||
it('full live session lifecycle: start → jump → reorder → pause → resume → open/close window', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Live Final Session',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create 4 projects
|
||||
const projects = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = await createTestProject(programId, { title: `Live Runtime P${i}` })
|
||||
projects.push(p)
|
||||
}
|
||||
|
||||
// Create cohort with projects
|
||||
const cohort = await createTestCohort(stage.id, { name: 'Runtime Cohort', isOpen: false })
|
||||
const cohortProjects = []
|
||||
for (let i = 0; i < projects.length; i++) {
|
||||
const cp = await createTestCohortProject(cohort.id, projects[i].id, i)
|
||||
cohortProjects.push(cp)
|
||||
}
|
||||
|
||||
// 1. Start session
|
||||
const sessionResult = await startSession(stage.id, admin.id, prisma)
|
||||
expect(sessionResult.success).toBe(true)
|
||||
|
||||
// Verify cursor starts at first project
|
||||
let cursor = await prisma.liveProgressCursor.findUnique({ where: { stageId: stage.id } })
|
||||
expect(cursor!.activeProjectId).toBe(projects[0].id)
|
||||
expect(cursor!.activeOrderIndex).toBe(0)
|
||||
|
||||
// 2. Jump to project at index 2
|
||||
const jumpResult = await jumpToProject(stage.id, 2, admin.id, prisma)
|
||||
expect(jumpResult.success).toBe(true)
|
||||
expect(jumpResult.projectId).toBe(projects[2].id)
|
||||
|
||||
cursor = await prisma.liveProgressCursor.findUnique({ where: { stageId: stage.id } })
|
||||
expect(cursor!.activeProjectId).toBe(projects[2].id)
|
||||
expect(cursor!.activeOrderIndex).toBe(2)
|
||||
|
||||
// 3. Reorder queue (reverse order)
|
||||
const reorderedIds = [...cohortProjects].reverse().map(cp => cp.id)
|
||||
const reorderResult = await reorderQueue(stage.id, reorderedIds, admin.id, prisma)
|
||||
expect(reorderResult.success).toBe(true)
|
||||
|
||||
// Verify reorder updated sortOrder
|
||||
const reorderedCPs = await prisma.cohortProject.findMany({
|
||||
where: { cohortId: cohort.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
expect(reorderedCPs[0].projectId).toBe(projects[3].id) // Was last, now first
|
||||
expect(reorderedCPs[3].projectId).toBe(projects[0].id) // Was first, now last
|
||||
|
||||
// 4. Pause
|
||||
const pauseResult = await pauseResume(stage.id, true, admin.id, prisma)
|
||||
expect(pauseResult.success).toBe(true)
|
||||
|
||||
cursor = await prisma.liveProgressCursor.findUnique({ where: { stageId: stage.id } })
|
||||
expect(cursor!.isPaused).toBe(true)
|
||||
|
||||
// 5. Resume
|
||||
const resumeResult = await pauseResume(stage.id, false, admin.id, prisma)
|
||||
expect(resumeResult.success).toBe(true)
|
||||
|
||||
cursor = await prisma.liveProgressCursor.findUnique({ where: { stageId: stage.id } })
|
||||
expect(cursor!.isPaused).toBe(false)
|
||||
|
||||
// 6. Open voting window
|
||||
const openResult = await openCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(openResult.success).toBe(true)
|
||||
|
||||
const openedCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(openedCohort!.isOpen).toBe(true)
|
||||
expect(openedCohort!.windowOpenAt).not.toBeNull()
|
||||
|
||||
// 7. Close voting window
|
||||
const closeResult = await closeCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(closeResult.success).toBe(true)
|
||||
|
||||
const closedCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(closedCohort!.isOpen).toBe(false)
|
||||
expect(closedCohort!.windowCloseAt).not.toBeNull()
|
||||
|
||||
// Verify audit trail
|
||||
const auditLogs = await prisma.decisionAuditLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ eventType: { startsWith: 'live.' } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
expect(auditLogs.length).toBeGreaterThanOrEqual(5) // session_started, cursor_updated, queue_reordered, paused, resumed, opened, closed
|
||||
})
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* I-001: Pipeline CRUD — Create / Update / Publish
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma, createTestContext } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { pipelineRouter } from '@/server/routers/pipeline'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Pipeline CRUD Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('I-001: Pipeline CRUD', () => {
|
||||
it('creates a pipeline with tracks and stages via createStructure', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = pipelineRouter.createCaller(ctx)
|
||||
|
||||
const result = await caller.createStructure({
|
||||
programId,
|
||||
name: 'Test Pipeline',
|
||||
slug: `test-pipe-${Date.now()}`,
|
||||
tracks: [
|
||||
{
|
||||
name: 'Main Track',
|
||||
slug: 'main',
|
||||
kind: 'MAIN',
|
||||
sortOrder: 0,
|
||||
stages: [
|
||||
{ name: 'Filtering', slug: 'filtering', stageType: 'FILTER', sortOrder: 0 },
|
||||
{ name: 'Evaluation', slug: 'evaluation', stageType: 'EVALUATION', sortOrder: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.pipeline.id).toBeDefined()
|
||||
|
||||
// Verify pipeline was created
|
||||
const pipeline = await prisma.pipeline.findUnique({
|
||||
where: { id: result.pipeline.id },
|
||||
include: {
|
||||
tracks: {
|
||||
include: { stages: { orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(pipeline).not.toBeNull()
|
||||
expect(pipeline!.name).toBe('Test Pipeline')
|
||||
expect(pipeline!.tracks).toHaveLength(1)
|
||||
expect(pipeline!.tracks[0].name).toBe('Main Track')
|
||||
expect(pipeline!.tracks[0].stages).toHaveLength(2)
|
||||
expect(pipeline!.tracks[0].stages[0].name).toBe('Filtering')
|
||||
expect(pipeline!.tracks[0].stages[1].name).toBe('Evaluation')
|
||||
|
||||
// Verify auto-created StageTransition between stages
|
||||
const transitions = await prisma.stageTransition.findMany({
|
||||
where: {
|
||||
fromStage: { trackId: pipeline!.tracks[0].id },
|
||||
},
|
||||
})
|
||||
expect(transitions.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('updates a pipeline name and settings', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = pipelineRouter.createCaller(ctx)
|
||||
|
||||
const created = await caller.createStructure({
|
||||
programId,
|
||||
name: 'Original Name',
|
||||
slug: `upd-pipe-${Date.now()}`,
|
||||
tracks: [
|
||||
{
|
||||
name: 'Track 1',
|
||||
slug: 'track-1',
|
||||
kind: 'MAIN',
|
||||
sortOrder: 0,
|
||||
stages: [{ name: 'S1', slug: 's1', stageType: 'EVALUATION', sortOrder: 0 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const updated = await caller.update({
|
||||
id: created.pipeline.id,
|
||||
name: 'Updated Name',
|
||||
settingsJson: { theme: 'blue' },
|
||||
})
|
||||
|
||||
expect(updated.name).toBe('Updated Name')
|
||||
|
||||
const fetched = await prisma.pipeline.findUnique({ where: { id: created.pipeline.id } })
|
||||
expect(fetched!.name).toBe('Updated Name')
|
||||
expect((fetched!.settingsJson as any)?.theme).toBe('blue')
|
||||
})
|
||||
|
||||
it('publishes a pipeline with valid structure', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = pipelineRouter.createCaller(ctx)
|
||||
|
||||
const created = await caller.createStructure({
|
||||
programId,
|
||||
name: 'Publish Test',
|
||||
slug: `pub-pipe-${Date.now()}`,
|
||||
tracks: [
|
||||
{
|
||||
name: 'Main',
|
||||
slug: 'main',
|
||||
kind: 'MAIN',
|
||||
sortOrder: 0,
|
||||
stages: [{ name: 'Eval', slug: 'eval', stageType: 'EVALUATION', sortOrder: 0 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const published = await caller.publish({ id: created.pipeline.id })
|
||||
|
||||
expect(published.status).toBe('ACTIVE')
|
||||
|
||||
const fetched = await prisma.pipeline.findUnique({ where: { id: created.pipeline.id } })
|
||||
expect(fetched!.status).toBe('ACTIVE')
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* I-002: Stage Config — Invalid Config Schema
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma, createTestContext } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { stageRouter } from '@/server/routers/stage'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Stage Config Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('I-002: Stage Config — Valid and Invalid Updates', () => {
|
||||
it('accepts valid stage config update', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Config Test Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = stageRouter.createCaller(ctx)
|
||||
|
||||
const updated = await caller.updateConfig({
|
||||
id: stage.id,
|
||||
configJson: {
|
||||
requiredReviews: 3,
|
||||
evaluationMode: 'CRITERIA_BASED',
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated).toBeDefined()
|
||||
|
||||
// Verify the configJson was persisted
|
||||
const fetched = await prisma.stage.findUnique({ where: { id: stage.id } })
|
||||
const config = fetched!.configJson as Record<string, unknown>
|
||||
expect(config.requiredReviews).toBe(3)
|
||||
expect(config.evaluationMode).toBe('CRITERIA_BASED')
|
||||
})
|
||||
|
||||
it('updates stage name via updateConfig', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Original Name',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = stageRouter.createCaller(ctx)
|
||||
|
||||
const updated = await caller.updateConfig({
|
||||
id: stage.id,
|
||||
name: 'New Name',
|
||||
})
|
||||
|
||||
expect(updated.name).toBe('New Name')
|
||||
|
||||
const fetched = await prisma.stage.findUnique({ where: { id: stage.id } })
|
||||
expect(fetched!.name).toBe('New Name')
|
||||
})
|
||||
|
||||
it('updates stage window dates via updateConfig', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Window Test Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = stageRouter.createCaller(ctx)
|
||||
|
||||
const openAt = new Date('2026-03-01T00:00:00Z')
|
||||
const closeAt = new Date('2026-04-01T00:00:00Z')
|
||||
|
||||
const updated = await caller.updateConfig({
|
||||
id: stage.id,
|
||||
windowOpenAt: openAt,
|
||||
windowCloseAt: closeAt,
|
||||
})
|
||||
|
||||
expect(updated).toBeDefined()
|
||||
|
||||
const fetched = await prisma.stage.findUnique({ where: { id: stage.id } })
|
||||
expect(fetched!.windowOpenAt!.getTime()).toBe(openAt.getTime())
|
||||
expect(fetched!.windowCloseAt!.getTime()).toBe(closeAt.getTime())
|
||||
})
|
||||
|
||||
it('rejects invalid window dates (close before open)', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Invalid Window Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = stageRouter.createCaller(ctx)
|
||||
|
||||
await expect(
|
||||
caller.updateConfig({
|
||||
id: stage.id,
|
||||
windowOpenAt: new Date('2026-04-01T00:00:00Z'),
|
||||
windowCloseAt: new Date('2026-03-01T00:00:00Z'), // Before open
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('opens and closes stage window via openWindow/closeWindow', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Open/Close Window Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = stageRouter.createCaller(ctx)
|
||||
|
||||
// Open the window
|
||||
const opened = await caller.openWindow({ id: stage.id })
|
||||
expect(opened.windowOpenAt).not.toBeNull()
|
||||
|
||||
// Close the window
|
||||
const closed = await caller.closeWindow({ id: stage.id })
|
||||
expect(closed.windowCloseAt).not.toBeNull()
|
||||
})
|
||||
})
|
||||
382
tests/unit/assignment-policy.test.ts
Normal file
382
tests/unit/assignment-policy.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Unit tests for the assignment policy resolution engine.
|
||||
*
|
||||
* These are pure-logic tests — no database or Prisma needed.
|
||||
* We construct MemberContext objects inline and verify the resolved policies.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { CapMode } from '@prisma/client'
|
||||
import type { MemberContext } from '@/server/services/competition-context'
|
||||
import {
|
||||
SYSTEM_DEFAULT_CAP,
|
||||
SYSTEM_DEFAULT_CAP_MODE,
|
||||
SYSTEM_DEFAULT_SOFT_BUFFER,
|
||||
resolveEffectiveCap,
|
||||
resolveEffectiveCapMode,
|
||||
resolveEffectiveSoftCapBuffer,
|
||||
resolveEffectiveCategoryBias,
|
||||
evaluateAssignmentPolicy,
|
||||
} from '@/server/services/assignment-policy'
|
||||
|
||||
// ============================================================================
|
||||
// Helpers — build minimal MemberContext stubs
|
||||
// ============================================================================
|
||||
|
||||
function baseMemberContext(overrides: Partial<MemberContext> = {}): MemberContext {
|
||||
return {
|
||||
competition: {} as any,
|
||||
round: {} as any,
|
||||
roundConfig: {} as any,
|
||||
submissionWindows: [],
|
||||
juryGroup: null,
|
||||
member: {
|
||||
id: 'member-1',
|
||||
juryGroupId: 'jg-1',
|
||||
userId: 'user-1',
|
||||
role: 'MEMBER',
|
||||
maxAssignmentsOverride: null,
|
||||
capModeOverride: null,
|
||||
categoryQuotasOverride: null,
|
||||
preferredStartupRatio: null,
|
||||
availabilityNotes: null,
|
||||
selfServiceCap: null,
|
||||
selfServiceRatio: null,
|
||||
joinedAt: new Date(),
|
||||
} as any,
|
||||
user: { id: 'user-1', name: 'Test Juror', email: 'test@example.com', role: 'JURY_MEMBER' },
|
||||
currentAssignmentCount: 0,
|
||||
assignmentsByCategory: {},
|
||||
pendingIntents: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function withJuryGroup(
|
||||
ctx: MemberContext,
|
||||
groupOverrides: Record<string, unknown> = {},
|
||||
): MemberContext {
|
||||
const defaultGroup = {
|
||||
id: 'jg-1',
|
||||
competitionId: 'comp-1',
|
||||
name: 'Panel A',
|
||||
slug: 'panel-a',
|
||||
description: null,
|
||||
sortOrder: 0,
|
||||
defaultMaxAssignments: 20,
|
||||
defaultCapMode: 'SOFT' as CapMode,
|
||||
softCapBuffer: 3,
|
||||
categoryQuotasEnabled: false,
|
||||
defaultCategoryQuotas: null,
|
||||
allowJurorCapAdjustment: false,
|
||||
allowJurorRatioAdjustment: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...groupOverrides,
|
||||
}
|
||||
return { ...ctx, juryGroup: defaultGroup as any }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// resolveEffectiveCap
|
||||
// ============================================================================
|
||||
|
||||
describe('resolveEffectiveCap', () => {
|
||||
it('returns system default (15) when no jury group', () => {
|
||||
const ctx = baseMemberContext()
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(SYSTEM_DEFAULT_CAP)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
it('returns jury group default when group is set', () => {
|
||||
const ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 25 })
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(25)
|
||||
expect(result.source).toBe('jury_group')
|
||||
})
|
||||
|
||||
it('admin per-member override takes precedence over group default', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 25 })
|
||||
ctx = { ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(10)
|
||||
expect(result.source).toBe('member')
|
||||
expect(result.explanation).toContain('Admin per-member override')
|
||||
})
|
||||
|
||||
it('self-service cap overrides admin when allowJurorCapAdjustment is true', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 25,
|
||||
allowJurorCapAdjustment: true,
|
||||
})
|
||||
ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 12 } }
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(12)
|
||||
expect(result.source).toBe('member')
|
||||
expect(result.explanation).toContain('Self-service cap')
|
||||
})
|
||||
|
||||
it('self-service cap is bounded by admin max (override)', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 25,
|
||||
allowJurorCapAdjustment: true,
|
||||
})
|
||||
ctx = {
|
||||
...ctx,
|
||||
member: {
|
||||
...ctx.member,
|
||||
selfServiceCap: 30, // Juror wants 30 but admin max is 10
|
||||
maxAssignmentsOverride: 10,
|
||||
},
|
||||
}
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(10) // Bounded to admin max
|
||||
expect(result.explanation).toContain('bounded')
|
||||
})
|
||||
|
||||
it('self-service cap is bounded by group default when no admin override', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 20,
|
||||
allowJurorCapAdjustment: true,
|
||||
})
|
||||
ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 50 } }
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(20) // Bounded to group default
|
||||
})
|
||||
|
||||
it('self-service cap is ignored when allowJurorCapAdjustment is false', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 20,
|
||||
allowJurorCapAdjustment: false,
|
||||
})
|
||||
ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 5 } }
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
// Should fall through to group default since self-service is disabled
|
||||
expect(result.value).toBe(20)
|
||||
expect(result.source).toBe('jury_group')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// resolveEffectiveCapMode
|
||||
// ============================================================================
|
||||
|
||||
describe('resolveEffectiveCapMode', () => {
|
||||
it('returns system default (SOFT) when no group', () => {
|
||||
const ctx = baseMemberContext()
|
||||
const result = resolveEffectiveCapMode(ctx)
|
||||
expect(result.value).toBe(SYSTEM_DEFAULT_CAP_MODE)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
it('returns jury group default cap mode', () => {
|
||||
const ctx = withJuryGroup(baseMemberContext(), { defaultCapMode: 'HARD' })
|
||||
const result = resolveEffectiveCapMode(ctx)
|
||||
expect(result.value).toBe('HARD')
|
||||
expect(result.source).toBe('jury_group')
|
||||
})
|
||||
|
||||
it('admin per-member cap mode override takes precedence', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), { defaultCapMode: 'SOFT' })
|
||||
ctx = { ...ctx, member: { ...ctx.member, capModeOverride: 'NONE' as CapMode } }
|
||||
const result = resolveEffectiveCapMode(ctx)
|
||||
expect(result.value).toBe('NONE')
|
||||
expect(result.source).toBe('member')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// resolveEffectiveSoftCapBuffer
|
||||
// ============================================================================
|
||||
|
||||
describe('resolveEffectiveSoftCapBuffer', () => {
|
||||
it('returns system default (2) when no group', () => {
|
||||
const ctx = baseMemberContext()
|
||||
const result = resolveEffectiveSoftCapBuffer(ctx)
|
||||
expect(result.value).toBe(SYSTEM_DEFAULT_SOFT_BUFFER)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
it('returns jury group buffer', () => {
|
||||
const ctx = withJuryGroup(baseMemberContext(), { softCapBuffer: 5 })
|
||||
const result = resolveEffectiveSoftCapBuffer(ctx)
|
||||
expect(result.value).toBe(5)
|
||||
expect(result.source).toBe('jury_group')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// resolveEffectiveCategoryBias
|
||||
// ============================================================================
|
||||
|
||||
describe('resolveEffectiveCategoryBias', () => {
|
||||
it('returns null when no bias configured', () => {
|
||||
const ctx = baseMemberContext()
|
||||
const result = resolveEffectiveCategoryBias(ctx)
|
||||
expect(result.value).toBeNull()
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
it('uses admin-set preferredStartupRatio', () => {
|
||||
let ctx = baseMemberContext()
|
||||
ctx = { ...ctx, member: { ...ctx.member, preferredStartupRatio: 0.7 } }
|
||||
const result = resolveEffectiveCategoryBias(ctx)
|
||||
expect(result.value!.STARTUP).toBeCloseTo(0.7)
|
||||
expect(result.value!.BUSINESS_CONCEPT).toBeCloseTo(0.3)
|
||||
expect(result.source).toBe('member')
|
||||
})
|
||||
|
||||
it('uses self-service ratio when allowJurorRatioAdjustment is true', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
allowJurorRatioAdjustment: true,
|
||||
})
|
||||
ctx = { ...ctx, member: { ...ctx.member, selfServiceRatio: 0.6 } }
|
||||
const result = resolveEffectiveCategoryBias(ctx)
|
||||
expect(result.value).toEqual({ STARTUP: 0.6, BUSINESS_CONCEPT: 0.4 })
|
||||
expect(result.source).toBe('member')
|
||||
})
|
||||
|
||||
it('self-service ratio takes precedence over admin ratio', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
allowJurorRatioAdjustment: true,
|
||||
})
|
||||
ctx = {
|
||||
...ctx,
|
||||
member: { ...ctx.member, selfServiceRatio: 0.8, preferredStartupRatio: 0.5 },
|
||||
}
|
||||
const result = resolveEffectiveCategoryBias(ctx)
|
||||
expect(result.value!.STARTUP).toBe(0.8) // Self-service wins
|
||||
})
|
||||
|
||||
it('derives bias from group category quotas', () => {
|
||||
const ctx = withJuryGroup(baseMemberContext(), {
|
||||
categoryQuotasEnabled: true,
|
||||
defaultCategoryQuotas: {
|
||||
STARTUP: { min: 5, max: 15 },
|
||||
BUSINESS_CONCEPT: { min: 3, max: 5 },
|
||||
},
|
||||
})
|
||||
const result = resolveEffectiveCategoryBias(ctx)
|
||||
expect(result.source).toBe('jury_group')
|
||||
// 15/(15+5) = 0.75, 5/(15+5) = 0.25
|
||||
expect(result.value!.STARTUP).toBe(0.75)
|
||||
expect(result.value!.BUSINESS_CONCEPT).toBe(0.25)
|
||||
})
|
||||
|
||||
it('ignores group quotas when categoryQuotasEnabled is false', () => {
|
||||
const ctx = withJuryGroup(baseMemberContext(), {
|
||||
categoryQuotasEnabled: false,
|
||||
defaultCategoryQuotas: {
|
||||
STARTUP: { min: 5, max: 15 },
|
||||
BUSINESS_CONCEPT: { min: 3, max: 5 },
|
||||
},
|
||||
})
|
||||
const result = resolveEffectiveCategoryBias(ctx)
|
||||
expect(result.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// evaluateAssignmentPolicy — canAssignMore / remainingCapacity / isOverCap
|
||||
// ============================================================================
|
||||
|
||||
describe('evaluateAssignmentPolicy', () => {
|
||||
describe('HARD cap mode', () => {
|
||||
it('canAssignMore is true when below cap', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 10,
|
||||
defaultCapMode: 'HARD',
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 7 }
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
expect(result.canAssignMore).toBe(true)
|
||||
expect(result.remainingCapacity).toBe(3)
|
||||
expect(result.isOverCap).toBe(false)
|
||||
})
|
||||
|
||||
it('canAssignMore is false when at cap', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 10,
|
||||
defaultCapMode: 'HARD',
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 10 }
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
expect(result.canAssignMore).toBe(false)
|
||||
expect(result.remainingCapacity).toBe(0)
|
||||
})
|
||||
|
||||
it('detects over-cap with correct count', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 10,
|
||||
defaultCapMode: 'HARD',
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 13 }
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
expect(result.isOverCap).toBe(true)
|
||||
expect(result.overCapBy).toBe(3)
|
||||
expect(result.canAssignMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SOFT cap mode', () => {
|
||||
it('canAssignMore is true within buffer zone', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 10,
|
||||
defaultCapMode: 'SOFT',
|
||||
softCapBuffer: 3,
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 11 } // Over cap but within buffer
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
expect(result.canAssignMore).toBe(true)
|
||||
expect(result.isOverCap).toBe(true) // Over the nominal cap
|
||||
expect(result.remainingCapacity).toBe(2) // 10+3-11
|
||||
})
|
||||
|
||||
it('canAssignMore is false beyond buffer', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 10,
|
||||
defaultCapMode: 'SOFT',
|
||||
softCapBuffer: 3,
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 13 } // cap(10) + buffer(3) = 13 → full
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
expect(result.canAssignMore).toBe(false)
|
||||
expect(result.remainingCapacity).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NONE cap mode', () => {
|
||||
it('canAssignMore is always true', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 10,
|
||||
defaultCapMode: 'NONE',
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 100 }
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
expect(result.canAssignMore).toBe(true)
|
||||
expect(result.remainingCapacity).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregate provenance', () => {
|
||||
it('returns all four policy resolutions with correct sources', () => {
|
||||
let ctx = withJuryGroup(baseMemberContext(), {
|
||||
defaultMaxAssignments: 20,
|
||||
defaultCapMode: 'SOFT',
|
||||
softCapBuffer: 2,
|
||||
})
|
||||
ctx = { ...ctx, currentAssignmentCount: 5 }
|
||||
const result = evaluateAssignmentPolicy(ctx)
|
||||
|
||||
expect(result.effectiveCap.source).toBe('jury_group')
|
||||
expect(result.effectiveCap.value).toBe(20)
|
||||
expect(result.effectiveCapMode.source).toBe('jury_group')
|
||||
expect(result.effectiveCapMode.value).toBe('SOFT')
|
||||
expect(result.softCapBuffer.source).toBe('jury_group')
|
||||
expect(result.softCapBuffer.value).toBe(2)
|
||||
expect(result.categoryBias.source).toBe('system')
|
||||
expect(result.categoryBias.value).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* U-010: Award Governance — Unauthorized AWARD_MASTER
|
||||
*
|
||||
* Tests that non-AWARD_MASTER users cannot call finalizeWinners.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma, createTestContext } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { awardRouter } from '@/server/routers/award'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Award Gov Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('U-010: Award Governance — Unauthorized AWARD_MASTER', () => {
|
||||
it('rejects finalizeWinners when called by a JURY_MEMBER', async () => {
|
||||
const jury = await createTestUser('JURY_MEMBER', { name: 'Unauthorized Jury' })
|
||||
userIds.push(jury.id)
|
||||
|
||||
// Create award track infrastructure using admin
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await prisma.track.create({
|
||||
data: {
|
||||
pipelineId: pipeline.id,
|
||||
name: 'Award Track',
|
||||
slug: `award-${Date.now()}`,
|
||||
kind: 'AWARD',
|
||||
sortOrder: 1,
|
||||
decisionMode: 'AWARD_MASTER_DECISION',
|
||||
},
|
||||
})
|
||||
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Award Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create a SpecialAward linked to the track
|
||||
const award = await prisma.specialAward.create({
|
||||
data: {
|
||||
programId,
|
||||
name: 'Best Innovation',
|
||||
trackId: track.id,
|
||||
status: 'VOTING_OPEN',
|
||||
scoringMode: 'PICK_WINNER',
|
||||
sortOrder: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Create an eligible project
|
||||
const project = await createTestProject(programId, { title: 'Award Project' })
|
||||
await prisma.awardEligibility.create({
|
||||
data: {
|
||||
awardId: award.id,
|
||||
projectId: project.id,
|
||||
eligible: true,
|
||||
method: 'MANUAL',
|
||||
},
|
||||
})
|
||||
|
||||
// Attempt to call finalizeWinners as JURY_MEMBER — should be rejected
|
||||
const juryCtx = createTestContext(jury)
|
||||
const juryCaller = awardRouter.createCaller(juryCtx)
|
||||
|
||||
await expect(
|
||||
juryCaller.finalizeWinners({
|
||||
trackId: track.id,
|
||||
winnerProjectId: project.id,
|
||||
})
|
||||
).rejects.toThrow() // Should throw FORBIDDEN or UNAUTHORIZED
|
||||
|
||||
// Verify the award still has no winner
|
||||
const unchangedAward = await prisma.specialAward.findUnique({
|
||||
where: { id: award.id },
|
||||
})
|
||||
expect(unchangedAward!.winnerProjectId).toBeNull()
|
||||
})
|
||||
|
||||
it('allows SUPER_ADMIN to finalize winners (awardMasterProcedure permits)', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN', { name: 'Admin Award Master' })
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await prisma.track.create({
|
||||
data: {
|
||||
pipelineId: pipeline.id,
|
||||
name: 'Admin Award Track',
|
||||
slug: `admin-award-${Date.now()}`,
|
||||
kind: 'AWARD',
|
||||
sortOrder: 2,
|
||||
decisionMode: 'AWARD_MASTER_DECISION',
|
||||
},
|
||||
})
|
||||
|
||||
await createTestStage(track.id, {
|
||||
name: 'Admin Award Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const award = await prisma.specialAward.create({
|
||||
data: {
|
||||
programId,
|
||||
name: 'Admin Award',
|
||||
trackId: track.id,
|
||||
status: 'VOTING_OPEN',
|
||||
scoringMode: 'PICK_WINNER',
|
||||
sortOrder: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const project = await createTestProject(programId, { title: 'Winning Project' })
|
||||
await prisma.awardEligibility.create({
|
||||
data: {
|
||||
awardId: award.id,
|
||||
projectId: project.id,
|
||||
eligible: true,
|
||||
method: 'MANUAL',
|
||||
},
|
||||
})
|
||||
|
||||
const adminCtx = createTestContext(admin)
|
||||
const adminCaller = awardRouter.createCaller(adminCtx)
|
||||
|
||||
const result = await adminCaller.finalizeWinners({
|
||||
trackId: track.id,
|
||||
winnerProjectId: project.id,
|
||||
})
|
||||
|
||||
expect(result.winnerProjectId).toBe(project.id)
|
||||
expect(result.status).toBe('CLOSED')
|
||||
})
|
||||
})
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* U-009: Live Cursor — Concurrent Update Handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
createTestCohort,
|
||||
createTestCohortProject,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import {
|
||||
startSession,
|
||||
setActiveProject,
|
||||
jumpToProject,
|
||||
pauseResume,
|
||||
openCohortWindow,
|
||||
closeCohortWindow,
|
||||
} from '@/server/services/live-control'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Live Control Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('U-009: Live Cursor Operations', () => {
|
||||
it('starts a session for a LIVE_FINAL stage', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Live Final',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create cohort with projects
|
||||
const project1 = await createTestProject(programId, { title: 'Live Project 1' })
|
||||
const project2 = await createTestProject(programId, { title: 'Live Project 2' })
|
||||
const cohort = await createTestCohort(stage.id, { name: 'Final Cohort' })
|
||||
await createTestCohortProject(cohort.id, project1.id, 0)
|
||||
await createTestCohortProject(cohort.id, project2.id, 1)
|
||||
|
||||
const result = await startSession(stage.id, admin.id, prisma)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.sessionId).not.toBeNull()
|
||||
expect(result.cursorId).not.toBeNull()
|
||||
|
||||
// Verify cursor state
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId: stage.id },
|
||||
})
|
||||
expect(cursor).not.toBeNull()
|
||||
expect(cursor!.activeProjectId).toBe(project1.id) // First project
|
||||
expect(cursor!.activeOrderIndex).toBe(0)
|
||||
expect(cursor!.isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects starting a session on non-LIVE_FINAL stage', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Evaluation Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const result = await startSession(stage.id, admin.id, prisma)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors!.some(e => e.includes('LIVE_FINAL'))).toBe(true)
|
||||
})
|
||||
|
||||
it('handles concurrent cursor updates without corruption', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Concurrent Test',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const project1 = await createTestProject(programId, { title: 'ConcP1' })
|
||||
const project2 = await createTestProject(programId, { title: 'ConcP2' })
|
||||
const project3 = await createTestProject(programId, { title: 'ConcP3' })
|
||||
const cohort = await createTestCohort(stage.id, { name: 'Conc Cohort' })
|
||||
await createTestCohortProject(cohort.id, project1.id, 0)
|
||||
await createTestCohortProject(cohort.id, project2.id, 1)
|
||||
await createTestCohortProject(cohort.id, project3.id, 2)
|
||||
|
||||
// Start session
|
||||
await startSession(stage.id, admin.id, prisma)
|
||||
|
||||
// Fire 2 concurrent setActiveProject calls
|
||||
const [result1, result2] = await Promise.all([
|
||||
setActiveProject(stage.id, project2.id, admin.id, prisma),
|
||||
setActiveProject(stage.id, project3.id, admin.id, prisma),
|
||||
])
|
||||
|
||||
// Both should succeed (last-write-wins)
|
||||
expect(result1.success).toBe(true)
|
||||
expect(result2.success).toBe(true)
|
||||
|
||||
// Final cursor state should be consistent (one of the two writes wins)
|
||||
const cursor = await prisma.liveProgressCursor.findUnique({
|
||||
where: { stageId: stage.id },
|
||||
})
|
||||
expect(cursor).not.toBeNull()
|
||||
// The active project should be either project2 or project3, not corrupted
|
||||
expect([project2.id, project3.id]).toContain(cursor!.activeProjectId)
|
||||
})
|
||||
|
||||
it('jump, pause/resume, and cohort window operations work correctly', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Full Live Test',
|
||||
stageType: 'LIVE_FINAL',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
const p1 = await createTestProject(programId, { title: 'P1' })
|
||||
const p2 = await createTestProject(programId, { title: 'P2' })
|
||||
const cohort = await createTestCohort(stage.id, { name: 'Test Cohort', isOpen: false })
|
||||
await createTestCohortProject(cohort.id, p1.id, 0)
|
||||
await createTestCohortProject(cohort.id, p2.id, 1)
|
||||
|
||||
await startSession(stage.id, admin.id, prisma)
|
||||
|
||||
// Jump to index 1
|
||||
const jumpResult = await jumpToProject(stage.id, 1, admin.id, prisma)
|
||||
expect(jumpResult.success).toBe(true)
|
||||
expect(jumpResult.projectId).toBe(p2.id)
|
||||
|
||||
// Pause
|
||||
const pauseResult = await pauseResume(stage.id, true, admin.id, prisma)
|
||||
expect(pauseResult.success).toBe(true)
|
||||
|
||||
const cursorPaused = await prisma.liveProgressCursor.findUnique({ where: { stageId: stage.id } })
|
||||
expect(cursorPaused!.isPaused).toBe(true)
|
||||
|
||||
// Resume
|
||||
const resumeResult = await pauseResume(stage.id, false, admin.id, prisma)
|
||||
expect(resumeResult.success).toBe(true)
|
||||
|
||||
// Open cohort window
|
||||
const openResult = await openCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(openResult.success).toBe(true)
|
||||
|
||||
const openCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(openCohort!.isOpen).toBe(true)
|
||||
expect(openCohort!.windowOpenAt).not.toBeNull()
|
||||
|
||||
// Close cohort window
|
||||
const closeResult = await closeCohortWindow(cohort.id, admin.id, prisma)
|
||||
expect(closeResult.success).toBe(true)
|
||||
|
||||
const closedCohort = await prisma.cohort.findUnique({ where: { id: cohort.id } })
|
||||
expect(closedCohort!.isOpen).toBe(false)
|
||||
expect(closedCohort!.windowCloseAt).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* U-008: Override — Missing Reason Fields
|
||||
*
|
||||
* Tests that the decision.override procedure rejects mutations
|
||||
* with invalid or missing reasonCode via Zod validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma, createTestContext } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { decisionRouter } from '@/server/routers/decision'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Override Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('U-008: Override — Missing Reason Fields', () => {
|
||||
it('rejects override with invalid reasonCode', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, { status: 'STAGE_ACTIVE' })
|
||||
const project = await createTestProject(programId)
|
||||
const pss = await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = decisionRouter.createCaller(ctx)
|
||||
|
||||
// Attempt override with invalid reasonCode — should be rejected by Zod
|
||||
await expect(
|
||||
caller.override({
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: pss.id,
|
||||
newValue: { state: 'PASSED' },
|
||||
reasonCode: 'INVALID_CODE' as any,
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('accepts override with valid reasonCode and persists OverrideAction', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, { status: 'STAGE_ACTIVE' })
|
||||
const project = await createTestProject(programId)
|
||||
const pss = await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
const ctx = createTestContext(admin)
|
||||
const caller = decisionRouter.createCaller(ctx)
|
||||
|
||||
const result = await caller.override({
|
||||
entityType: 'ProjectStageState',
|
||||
entityId: pss.id,
|
||||
newValue: { state: 'PASSED' },
|
||||
reasonCode: 'ADMIN_DISCRETION',
|
||||
reasonText: 'Manually promoted by admin',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
// Verify OverrideAction was created
|
||||
const overrideAction = await prisma.overrideAction.findFirst({
|
||||
where: { entityType: 'ProjectStageState', entityId: pss.id },
|
||||
})
|
||||
expect(overrideAction).not.toBeNull()
|
||||
expect(overrideAction!.reasonCode).toBe('ADMIN_DISCRETION')
|
||||
expect(overrideAction!.reasonText).toBe('Manually promoted by admin')
|
||||
expect(overrideAction!.actorId).toBe(admin.id)
|
||||
|
||||
// Verify the PSS state was actually updated
|
||||
const updatedPSS = await prisma.projectStageState.findUnique({
|
||||
where: { id: pss.id },
|
||||
})
|
||||
expect(updatedPSS!.state).toBe('PASSED')
|
||||
|
||||
// Verify DecisionAuditLog was created
|
||||
const auditLog = await prisma.decisionAuditLog.findFirst({
|
||||
where: { entityType: 'ProjectStageState', entityId: pss.id, eventType: 'override.applied' },
|
||||
})
|
||||
expect(auditLog).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* U-006: Assignment — COI Conflict Excluded
|
||||
* U-007: Assignment — Insufficient Capacity / Overflow
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { previewStageAssignment } from '@/server/services/stage-assignment'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Assignment Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('U-006: COI Conflict Excluded', () => {
|
||||
it('excludes jurors with declared COI from the assignment pool', async () => {
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Eval Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create 3 jurors
|
||||
const juror1 = await createTestUser('JURY_MEMBER', { name: 'Juror 1' })
|
||||
const juror2 = await createTestUser('JURY_MEMBER', { name: 'Juror 2' })
|
||||
const juror3 = await createTestUser('JURY_MEMBER', { name: 'Juror 3' })
|
||||
userIds.push(juror1.id, juror2.id, juror3.id)
|
||||
|
||||
// Create a project
|
||||
const project = await createTestProject(programId)
|
||||
await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
// Juror 1 has a COI with this project
|
||||
await prisma.conflictOfInterest.create({
|
||||
data: {
|
||||
// Need an assignment first for the FK constraint
|
||||
assignmentId: (await prisma.assignment.create({
|
||||
data: {
|
||||
userId: juror1.id,
|
||||
projectId: project.id,
|
||||
stageId: stage.id,
|
||||
method: 'MANUAL',
|
||||
},
|
||||
})).id,
|
||||
userId: juror1.id,
|
||||
projectId: project.id,
|
||||
hasConflict: true,
|
||||
conflictType: 'personal',
|
||||
description: 'Test COI',
|
||||
},
|
||||
})
|
||||
|
||||
const preview = await previewStageAssignment(
|
||||
stage.id,
|
||||
{ requiredReviews: 2, respectCOI: true },
|
||||
prisma
|
||||
)
|
||||
|
||||
// Juror 1 should NOT appear in any assignment (already assigned + COI)
|
||||
// The remaining assignments should come from juror2 and juror3
|
||||
const assignedUserIds = preview.assignments.map(a => a.userId)
|
||||
expect(assignedUserIds).not.toContain(juror1.id)
|
||||
|
||||
// Should have 2 assignments (requiredReviews=2, juror1 excluded via existing assignment + COI)
|
||||
// juror1 already has an existing assignment, but COI would exclude them from new ones too
|
||||
expect(preview.assignments.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('U-007: Insufficient Capacity / Overflow', () => {
|
||||
it('flags overflow when more projects than juror capacity', async () => {
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Overflow Stage',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create 2 jurors with maxAssignments=3
|
||||
const jurorA = await createTestUser('JURY_MEMBER', {
|
||||
name: 'Juror A',
|
||||
maxAssignments: 3,
|
||||
})
|
||||
const jurorB = await createTestUser('JURY_MEMBER', {
|
||||
name: 'Juror B',
|
||||
maxAssignments: 3,
|
||||
})
|
||||
userIds.push(jurorA.id, jurorB.id)
|
||||
|
||||
// Deactivate all other jury members so only our 2 test jurors are in the pool.
|
||||
// The service queries ALL active JURY_MEMBER users globally.
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
id: { notIn: [jurorA.id, jurorB.id] },
|
||||
},
|
||||
data: { status: 'SUSPENDED' },
|
||||
})
|
||||
|
||||
// Create 10 projects — total capacity is 6 assignments (2 jurors * 3 max)
|
||||
// With requiredReviews=2, we need 20 assignment slots for 10 projects,
|
||||
// but only 6 are available, so many will be unassigned
|
||||
const projectIds: string[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const project = await createTestProject(programId, {
|
||||
title: `Overflow Project ${i}`,
|
||||
})
|
||||
await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
projectIds.push(project.id)
|
||||
}
|
||||
|
||||
const preview = await previewStageAssignment(
|
||||
stage.id,
|
||||
{
|
||||
requiredReviews: 2,
|
||||
maxAssignmentsPerJuror: 3,
|
||||
},
|
||||
prisma
|
||||
)
|
||||
|
||||
// Reactivate jury members for subsequent tests
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
role: 'JURY_MEMBER',
|
||||
status: 'SUSPENDED',
|
||||
},
|
||||
data: { status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
// Total capacity = 6 slots, need 20 → many projects unassigned
|
||||
expect(preview.stats.totalProjects).toBe(10)
|
||||
expect(preview.stats.totalJurors).toBe(2)
|
||||
|
||||
// No juror should exceed their max assignments
|
||||
const jurorAssignmentCounts = new Map<string, number>()
|
||||
for (const a of preview.assignments) {
|
||||
const current = jurorAssignmentCounts.get(a.userId) ?? 0
|
||||
jurorAssignmentCounts.set(a.userId, current + 1)
|
||||
}
|
||||
for (const [, count] of jurorAssignmentCounts) {
|
||||
expect(count).toBeLessThanOrEqual(3)
|
||||
}
|
||||
|
||||
// There should be unassigned projects (capacity 6 < needed 20)
|
||||
expect(preview.unassignedProjects.length).toBeGreaterThan(0)
|
||||
|
||||
// Coverage should be < 100%
|
||||
expect(preview.stats.coveragePercent).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* U-001: Stage Transition — Legal Transition
|
||||
* U-002: Stage Transition — Illegal Transition
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestTransition,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { validateTransition, executeTransition } from '@/server/services/stage-engine'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'StageEngine Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('U-001: Legal Transition', () => {
|
||||
it('validates and executes a legal transition between two stages', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stageA = await createTestStage(track.id, {
|
||||
name: 'Stage A',
|
||||
stageType: 'FILTER',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 0,
|
||||
})
|
||||
const stageB = await createTestStage(track.id, {
|
||||
name: 'Stage B',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
// Create a legal transition path A → B
|
||||
await createTestTransition(stageA.id, stageB.id)
|
||||
|
||||
// Create project with PSS in stage A
|
||||
const project = await createTestProject(programId)
|
||||
await createTestPSS(project.id, track.id, stageA.id, { state: 'PENDING' })
|
||||
|
||||
// Validate
|
||||
const validation = await validateTransition(project.id, stageA.id, stageB.id, prisma)
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.errors).toHaveLength(0)
|
||||
|
||||
// Execute
|
||||
const result = await executeTransition(
|
||||
project.id, track.id, stageA.id, stageB.id, 'PENDING', admin.id, prisma
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.projectStageState).not.toBeNull()
|
||||
expect(result.projectStageState!.stageId).toBe(stageB.id)
|
||||
expect(result.projectStageState!.state).toBe('PENDING')
|
||||
|
||||
// Verify source PSS was exited
|
||||
const sourcePSS = await prisma.projectStageState.findFirst({
|
||||
where: { projectId: project.id, stageId: stageA.id },
|
||||
})
|
||||
expect(sourcePSS!.exitedAt).not.toBeNull()
|
||||
|
||||
// Verify dest PSS was created
|
||||
const destPSS = await prisma.projectStageState.findFirst({
|
||||
where: { projectId: project.id, stageId: stageB.id, exitedAt: null },
|
||||
})
|
||||
expect(destPSS).not.toBeNull()
|
||||
expect(destPSS!.state).toBe('PENDING')
|
||||
|
||||
// Verify audit log entry was created
|
||||
const auditLog = await prisma.decisionAuditLog.findFirst({
|
||||
where: {
|
||||
entityType: 'ProjectStageState',
|
||||
eventType: 'stage.transitioned',
|
||||
entityId: destPSS!.id,
|
||||
},
|
||||
})
|
||||
expect(auditLog).not.toBeNull()
|
||||
expect(auditLog!.actorId).toBe(admin.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('U-002: Illegal Transition', () => {
|
||||
it('rejects transition when no StageTransition record exists', async () => {
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stageA = await createTestStage(track.id, {
|
||||
name: 'Stage A (no path)',
|
||||
stageType: 'FILTER',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 0,
|
||||
})
|
||||
const stageB = await createTestStage(track.id, {
|
||||
name: 'Stage B (no path)',
|
||||
stageType: 'EVALUATION',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
// No StageTransition created between A and B
|
||||
|
||||
const project = await createTestProject(programId)
|
||||
await createTestPSS(project.id, track.id, stageA.id, { state: 'PENDING' })
|
||||
|
||||
const validation = await validateTransition(project.id, stageA.id, stageB.id, prisma)
|
||||
expect(validation.valid).toBe(false)
|
||||
expect(validation.errors.length).toBeGreaterThan(0)
|
||||
expect(validation.errors.some(e => e.includes('No transition defined'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects transition when destination stage is archived', async () => {
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stageA = await createTestStage(track.id, {
|
||||
name: 'Active Stage',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 0,
|
||||
})
|
||||
const stageB = await createTestStage(track.id, {
|
||||
name: 'Archived Stage',
|
||||
status: 'STAGE_ARCHIVED',
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
await createTestTransition(stageA.id, stageB.id)
|
||||
|
||||
const project = await createTestProject(programId)
|
||||
await createTestPSS(project.id, track.id, stageA.id, { state: 'PENDING' })
|
||||
|
||||
const validation = await validateTransition(project.id, stageA.id, stageB.id, prisma)
|
||||
expect(validation.valid).toBe(false)
|
||||
expect(validation.errors.some(e => e.includes('archived'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects transition when project has no active PSS in source stage', async () => {
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stageA = await createTestStage(track.id, {
|
||||
name: 'Source',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 0,
|
||||
})
|
||||
const stageB = await createTestStage(track.id, {
|
||||
name: 'Dest',
|
||||
status: 'STAGE_ACTIVE',
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
await createTestTransition(stageA.id, stageB.id)
|
||||
|
||||
const project = await createTestProject(programId)
|
||||
// No PSS created for this project
|
||||
|
||||
const validation = await validateTransition(project.id, stageA.id, stageB.id, prisma)
|
||||
expect(validation.valid).toBe(false)
|
||||
expect(validation.errors.some(e => e.includes('no active state'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* U-004: Filtering Gates — Missing Required Docs
|
||||
* U-005: AI Banding — Uncertain Confidence Band
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import {
|
||||
createTestUser,
|
||||
createTestProgram,
|
||||
createTestPipeline,
|
||||
createTestTrack,
|
||||
createTestStage,
|
||||
createTestProject,
|
||||
createTestPSS,
|
||||
createTestFilteringRule,
|
||||
cleanupTestData,
|
||||
} from '../helpers'
|
||||
import { runStageFiltering } from '@/server/services/stage-filtering'
|
||||
|
||||
let programId: string
|
||||
let userIds: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const program = await createTestProgram({ name: 'Filtering Test' })
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, userIds)
|
||||
})
|
||||
|
||||
describe('U-004: Filtering Gates — Missing Required Docs', () => {
|
||||
it('rejects a project that lacks required document types', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Filter Stage',
|
||||
stageType: 'FILTER',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create a DOCUMENT_CHECK rule requiring EXEC_SUMMARY
|
||||
await createTestFilteringRule(stage.id, {
|
||||
name: 'Require Exec Summary',
|
||||
ruleType: 'DOCUMENT_CHECK',
|
||||
configJson: {
|
||||
requiredFileTypes: ['EXEC_SUMMARY'],
|
||||
action: 'REJECT',
|
||||
},
|
||||
priority: 0,
|
||||
})
|
||||
|
||||
// Create project WITHOUT any files
|
||||
const project = await createTestProject(programId)
|
||||
await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
const result = await runStageFiltering(stage.id, admin.id, prisma)
|
||||
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.rejected).toBe(1)
|
||||
expect(result.passed).toBe(0)
|
||||
expect(result.manualQueue).toBe(0)
|
||||
|
||||
// Verify the FilteringResult was created with FILTERED_OUT
|
||||
const filteringResult = await prisma.filteringResult.findFirst({
|
||||
where: { stageId: stage.id, projectId: project.id },
|
||||
})
|
||||
expect(filteringResult).not.toBeNull()
|
||||
expect(filteringResult!.outcome).toBe('FILTERED_OUT')
|
||||
})
|
||||
|
||||
it('passes a project that has all required document types', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'Filter Stage 2',
|
||||
stageType: 'FILTER',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
await createTestFilteringRule(stage.id, {
|
||||
name: 'Require Exec Summary',
|
||||
ruleType: 'DOCUMENT_CHECK',
|
||||
configJson: {
|
||||
requiredFileTypes: ['EXEC_SUMMARY'],
|
||||
action: 'REJECT',
|
||||
},
|
||||
priority: 0,
|
||||
})
|
||||
|
||||
// Create project WITH the required file
|
||||
const project = await createTestProject(programId)
|
||||
await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
// Create a ProjectFile with the required type
|
||||
await prisma.projectFile.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
fileType: 'EXEC_SUMMARY',
|
||||
fileName: 'summary.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1024,
|
||||
bucket: 'test-bucket',
|
||||
objectKey: 'test/summary.pdf',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await runStageFiltering(stage.id, admin.id, prisma)
|
||||
|
||||
expect(result.total).toBe(1)
|
||||
// With no AI rules, a passing doc check results in PASSED
|
||||
expect(result.passed).toBe(1)
|
||||
expect(result.rejected).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('U-005: AI Banding — Uncertain Confidence Band', () => {
|
||||
it('flags a project for manual review when AI confidence is in the uncertain band', async () => {
|
||||
const admin = await createTestUser('SUPER_ADMIN')
|
||||
userIds.push(admin.id)
|
||||
|
||||
const pipeline = await createTestPipeline(programId)
|
||||
const track = await createTestTrack(pipeline.id)
|
||||
const stage = await createTestStage(track.id, {
|
||||
name: 'AI Filter Stage',
|
||||
stageType: 'FILTER',
|
||||
status: 'STAGE_ACTIVE',
|
||||
})
|
||||
|
||||
// Create an AI_SCREENING rule — the service generates a confidence value
|
||||
// between 0.25 and 0.75 for projects with minimal data → FLAGGED
|
||||
await prisma.filteringRule.create({
|
||||
data: {
|
||||
stageId: stage.id,
|
||||
name: 'AI Screen',
|
||||
ruleType: 'AI_SCREENING',
|
||||
configJson: { criteriaText: 'Evaluate ocean impact' },
|
||||
priority: 10,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Create project with title and description (hasMinimalData = true → confidence = 0.5)
|
||||
const project = await createTestProject(programId, {
|
||||
title: 'Ocean Cleanup',
|
||||
description: 'A project to clean the ocean',
|
||||
})
|
||||
await createTestPSS(project.id, track.id, stage.id, { state: 'PENDING' })
|
||||
|
||||
const result = await runStageFiltering(stage.id, admin.id, prisma)
|
||||
|
||||
expect(result.total).toBe(1)
|
||||
// Confidence 0.5 is between 0.25 and 0.75 → FLAGGED → manual queue
|
||||
expect(result.manualQueue).toBe(1)
|
||||
expect(result.passed).toBe(0)
|
||||
expect(result.rejected).toBe(0)
|
||||
|
||||
// Verify filtering result
|
||||
const fr = await prisma.filteringResult.findFirst({
|
||||
where: { stageId: stage.id, projectId: project.id },
|
||||
})
|
||||
expect(fr).not.toBeNull()
|
||||
expect(fr!.outcome).toBe('FLAGGED')
|
||||
expect(fr!.aiScreeningJson).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user