171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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)
|
||
|
|
})
|
||
|
|
})
|