mentor.autoAssignBulkForRound now skips any project whose finalist confirmation isn't CONFIRMED — there's no point assigning a mentor to a team that won't be at the grand finale. Other eligibility rules (wantsMentorship, admin_selected, already-assigned) are preserved. Updated existing requested_only and skip-already-assigned tests to seed CONFIRMED confirmations so they continue to isolate their target gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest'
|
|
import { prisma, createCaller } from '../setup'
|
|
import {
|
|
createTestUser,
|
|
createTestProgram,
|
|
createTestProject,
|
|
createTestCompetition,
|
|
createTestRound,
|
|
cleanupTestData,
|
|
uid,
|
|
} from '../helpers'
|
|
import { mentorRouter } from '../../src/server/routers/mentor'
|
|
import type { UserRole } from '@prisma/client'
|
|
|
|
/**
|
|
* The default `createTestUser` helper only populates the singular `role`
|
|
* column — but `mentor.getCandidates` filters on the multi-role `roles[]`
|
|
* array. Wrap creation here so test users land with the right shape.
|
|
*/
|
|
async function createUserWithRoles(
|
|
primaryRole: UserRole,
|
|
rolesArray: UserRole[],
|
|
overrides: { expertiseTags?: string[]; maxAssignments?: number | null; country?: string | null } = {},
|
|
) {
|
|
const id = uid('user')
|
|
return prisma.user.create({
|
|
data: {
|
|
id,
|
|
email: `${id}@test.local`,
|
|
name: `Test ${primaryRole}`,
|
|
role: primaryRole,
|
|
roles: rolesArray,
|
|
status: 'ACTIVE',
|
|
expertiseTags: overrides.expertiseTags ?? [],
|
|
maxAssignments: overrides.maxAssignments ?? null,
|
|
country: overrides.country ?? null,
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('mentor.getCandidates', () => {
|
|
const programIds: string[] = []
|
|
const userIds: string[] = []
|
|
|
|
afterAll(async () => {
|
|
for (const programId of programIds) {
|
|
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
|
await cleanupTestData(programId, [])
|
|
}
|
|
if (userIds.length > 0) {
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
|
}
|
|
})
|
|
|
|
it('returns all MENTOR-role users sorted by expertise overlap, excluding non-mentors', async () => {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `getCandidates-${uid()}` })
|
|
programIds.push(program.id)
|
|
|
|
const project = await createTestProject(program.id, {
|
|
title: 'Reef Monitor',
|
|
description: 'Coral reef IoT sensor for ocean acidification.',
|
|
tags: ['coral', 'iot', 'sensors'],
|
|
})
|
|
|
|
const mentorHigh = await createUserWithRoles('MENTOR', ['MENTOR'], {
|
|
expertiseTags: ['coral', 'iot', 'marine biology'],
|
|
})
|
|
userIds.push(mentorHigh.id)
|
|
|
|
const mentorLow = await createUserWithRoles('MENTOR', ['MENTOR'], {
|
|
expertiseTags: ['marketing'],
|
|
})
|
|
userIds.push(mentorLow.id)
|
|
|
|
const nonMentor = await createUserWithRoles('JURY_MEMBER', ['JURY_MEMBER'], {
|
|
expertiseTags: ['coral', 'iot'],
|
|
})
|
|
userIds.push(nonMentor.id)
|
|
|
|
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
|
const result = await caller.getCandidates({ projectId: project.id })
|
|
|
|
const mentorIds = result.candidates.map((c: { id: string }) => c.id)
|
|
expect(mentorIds).toContain(mentorHigh.id)
|
|
expect(mentorIds).toContain(mentorLow.id)
|
|
expect(mentorIds).not.toContain(nonMentor.id)
|
|
|
|
const high = result.candidates.find((c: { id: string }) => c.id === mentorHigh.id)!
|
|
const low = result.candidates.find((c: { id: string }) => c.id === mentorLow.id)!
|
|
expect(high.overlapScore).toBeGreaterThan(low.overlapScore)
|
|
|
|
// Default sort: highest overlap first
|
|
const indexHigh = mentorIds.indexOf(mentorHigh.id)
|
|
const indexLow = mentorIds.indexOf(mentorLow.id)
|
|
expect(indexHigh).toBeLessThan(indexLow)
|
|
})
|
|
|
|
it('exposes load + capacity per candidate', async () => {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `getCandidates-load-${uid()}` })
|
|
programIds.push(program.id)
|
|
|
|
const project = await createTestProject(program.id, { title: 'P1', tags: ['x'] })
|
|
const otherProject = await createTestProject(program.id, { title: 'P2', tags: ['x'] })
|
|
|
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], {
|
|
expertiseTags: ['x'],
|
|
maxAssignments: 3,
|
|
})
|
|
userIds.push(mentor.id)
|
|
|
|
await prisma.mentorAssignment.create({
|
|
data: {
|
|
projectId: otherProject.id,
|
|
mentorId: mentor.id,
|
|
method: 'MANUAL',
|
|
assignedBy: admin.id,
|
|
},
|
|
})
|
|
|
|
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
|
const result = await caller.getCandidates({ projectId: project.id })
|
|
const found = result.candidates.find((c: { id: string }) => c.id === mentor.id)!
|
|
expect(found.currentAssignments).toBe(1)
|
|
expect(found.maxAssignments).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('mentor.autoAssignBulkForRound', () => {
|
|
const programIds: string[] = []
|
|
const userIds: string[] = []
|
|
|
|
afterAll(async () => {
|
|
for (const programId of programIds) {
|
|
await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } })
|
|
await cleanupTestData(programId, [])
|
|
}
|
|
if (userIds.length > 0) {
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
|
}
|
|
})
|
|
|
|
it('respects requested_only eligibility — skips projects without wantsMentorship=true', async () => {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `bulk-requested-${uid()}` })
|
|
programIds.push(program.id)
|
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
|
const round = await createTestRound(competition.id, {
|
|
roundType: 'MENTORING',
|
|
configJson: { eligibility: 'requested_only' },
|
|
})
|
|
|
|
const projWithRequest = await prisma.project.create({
|
|
data: {
|
|
id: uid('proj'),
|
|
title: 'Wants Mentorship',
|
|
programId: program.id,
|
|
tags: ['x'],
|
|
wantsMentorship: true,
|
|
},
|
|
})
|
|
const projWithoutRequest = await prisma.project.create({
|
|
data: {
|
|
id: uid('proj'),
|
|
title: 'No Request',
|
|
programId: program.id,
|
|
tags: ['x'],
|
|
wantsMentorship: false,
|
|
},
|
|
})
|
|
await prisma.projectRoundState.create({
|
|
data: { projectId: projWithRequest.id, roundId: round.id, state: 'PENDING' },
|
|
})
|
|
await prisma.projectRoundState.create({
|
|
data: { projectId: projWithoutRequest.id, roundId: round.id, state: 'PENDING' },
|
|
})
|
|
|
|
// Both projects must have CONFIRMED FinalistConfirmation to qualify for
|
|
// mentor assignment — the wantsMentorship gate is what we're isolating here.
|
|
await prisma.finalistConfirmation.createMany({
|
|
data: [
|
|
{
|
|
projectId: projWithRequest.id,
|
|
category: 'STARTUP',
|
|
status: 'CONFIRMED',
|
|
deadline: new Date(Date.now() + 86_400_000),
|
|
token: `tok_${uid()}`,
|
|
confirmedAt: new Date(),
|
|
},
|
|
{
|
|
projectId: projWithoutRequest.id,
|
|
category: 'STARTUP',
|
|
status: 'CONFIRMED',
|
|
deadline: new Date(Date.now() + 86_400_000),
|
|
token: `tok_${uid()}`,
|
|
confirmedAt: new Date(),
|
|
},
|
|
],
|
|
})
|
|
|
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
|
|
userIds.push(mentor.id)
|
|
|
|
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
|
const result = await caller.autoAssignBulkForRound({ roundId: round.id, useAI: false })
|
|
|
|
expect(result.assigned).toBe(1)
|
|
|
|
const requestedAssigned = await prisma.mentorAssignment.findUnique({
|
|
where: { projectId: projWithRequest.id },
|
|
})
|
|
expect(requestedAssigned).not.toBeNull()
|
|
|
|
const skippedNotAssigned = await prisma.mentorAssignment.findUnique({
|
|
where: { projectId: projWithoutRequest.id },
|
|
})
|
|
expect(skippedNotAssigned).toBeNull()
|
|
})
|
|
|
|
it('skips projects already assigned (any method)', async () => {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `bulk-skip-${uid()}` })
|
|
programIds.push(program.id)
|
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
|
const round = await createTestRound(competition.id, {
|
|
roundType: 'MENTORING',
|
|
configJson: { eligibility: 'all_advancing' },
|
|
})
|
|
|
|
const existingMentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
|
|
const otherMentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
|
|
userIds.push(existingMentor.id, otherMentor.id)
|
|
|
|
const projAlreadyAssigned = await prisma.project.create({
|
|
data: { id: uid('proj'), title: 'Already', programId: program.id, tags: ['x'], wantsMentorship: true },
|
|
})
|
|
const projUnassigned = await prisma.project.create({
|
|
data: { id: uid('proj'), title: 'Open', programId: program.id, tags: ['x'], wantsMentorship: false },
|
|
})
|
|
await prisma.projectRoundState.create({
|
|
data: { projectId: projAlreadyAssigned.id, roundId: round.id, state: 'PENDING' },
|
|
})
|
|
await prisma.projectRoundState.create({
|
|
data: { projectId: projUnassigned.id, roundId: round.id, state: 'PENDING' },
|
|
})
|
|
|
|
// Both projects must have CONFIRMED FinalistConfirmation to qualify for
|
|
// mentor assignment — the existing-assignment gate is what we're isolating here.
|
|
await prisma.finalistConfirmation.createMany({
|
|
data: [
|
|
{
|
|
projectId: projAlreadyAssigned.id,
|
|
category: 'STARTUP',
|
|
status: 'CONFIRMED',
|
|
deadline: new Date(Date.now() + 86_400_000),
|
|
token: `tok_${uid()}`,
|
|
confirmedAt: new Date(),
|
|
},
|
|
{
|
|
projectId: projUnassigned.id,
|
|
category: 'STARTUP',
|
|
status: 'CONFIRMED',
|
|
deadline: new Date(Date.now() + 86_400_000),
|
|
token: `tok_${uid()}`,
|
|
confirmedAt: new Date(),
|
|
},
|
|
],
|
|
})
|
|
|
|
await prisma.mentorAssignment.create({
|
|
data: {
|
|
projectId: projAlreadyAssigned.id,
|
|
mentorId: existingMentor.id,
|
|
method: 'MANUAL',
|
|
assignedBy: admin.id,
|
|
},
|
|
})
|
|
|
|
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
|
const result = await caller.autoAssignBulkForRound({ roundId: round.id, useAI: false })
|
|
|
|
expect(result.assigned).toBe(1)
|
|
expect(result.skipped).toBe(1)
|
|
|
|
const stillExisting = await prisma.mentorAssignment.findUnique({
|
|
where: { projectId: projAlreadyAssigned.id },
|
|
})
|
|
expect(stillExisting?.mentorId).toBe(existingMentor.id) // unchanged
|
|
})
|
|
|
|
it('only assigns mentors to projects with CONFIRMED FinalistConfirmation', async () => {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `bulk-finalist-${uid()}` })
|
|
programIds.push(program.id)
|
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
|
const round = await createTestRound(competition.id, {
|
|
roundType: 'MENTORING',
|
|
configJson: { eligibility: 'all_advancing' },
|
|
})
|
|
|
|
const projConfirmed = await prisma.project.create({
|
|
data: {
|
|
id: uid('proj'),
|
|
title: 'Confirmed Finalist',
|
|
programId: program.id,
|
|
tags: ['x'],
|
|
wantsMentorship: true,
|
|
},
|
|
})
|
|
const projPending = await prisma.project.create({
|
|
data: {
|
|
id: uid('proj'),
|
|
title: 'Pending Finalist',
|
|
programId: program.id,
|
|
tags: ['x'],
|
|
wantsMentorship: true,
|
|
},
|
|
})
|
|
const projNoConfirmation = await prisma.project.create({
|
|
data: {
|
|
id: uid('proj'),
|
|
title: 'No Confirmation',
|
|
programId: program.id,
|
|
tags: ['x'],
|
|
wantsMentorship: true,
|
|
},
|
|
})
|
|
|
|
await prisma.projectRoundState.createMany({
|
|
data: [
|
|
{ projectId: projConfirmed.id, roundId: round.id, state: 'PENDING' },
|
|
{ projectId: projPending.id, roundId: round.id, state: 'PENDING' },
|
|
{ projectId: projNoConfirmation.id, roundId: round.id, state: 'PENDING' },
|
|
],
|
|
})
|
|
|
|
await prisma.finalistConfirmation.create({
|
|
data: {
|
|
projectId: projConfirmed.id,
|
|
category: 'STARTUP',
|
|
status: 'CONFIRMED',
|
|
deadline: new Date(Date.now() + 86_400_000),
|
|
token: `tok_${uid()}`,
|
|
confirmedAt: new Date(),
|
|
},
|
|
})
|
|
await prisma.finalistConfirmation.create({
|
|
data: {
|
|
projectId: projPending.id,
|
|
category: 'STARTUP',
|
|
status: 'PENDING',
|
|
deadline: new Date(Date.now() + 86_400_000),
|
|
token: `tok_${uid()}`,
|
|
},
|
|
})
|
|
|
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
|
|
userIds.push(mentor.id)
|
|
|
|
const caller = createCaller(mentorRouter, {
|
|
id: admin.id,
|
|
email: admin.email,
|
|
role: 'SUPER_ADMIN',
|
|
})
|
|
const result = await caller.autoAssignBulkForRound({ roundId: round.id, useAI: false })
|
|
|
|
expect(result.assigned).toBe(1)
|
|
|
|
const confirmedAssigned = await prisma.mentorAssignment.findUnique({
|
|
where: { projectId: projConfirmed.id },
|
|
})
|
|
expect(confirmedAssigned).not.toBeNull()
|
|
|
|
const pendingAssigned = await prisma.mentorAssignment.findUnique({
|
|
where: { projectId: projPending.id },
|
|
})
|
|
expect(pendingAssigned).toBeNull()
|
|
|
|
const noConfAssigned = await prisma.mentorAssignment.findUnique({
|
|
where: { projectId: projNoConfirmation.id },
|
|
})
|
|
expect(noConfAssigned).toBeNull()
|
|
})
|
|
|
|
it('refuses with admin_selected eligibility (admin must pick manually)', async () => {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `bulk-admin-selected-${uid()}` })
|
|
programIds.push(program.id)
|
|
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
|
|
const round = await createTestRound(competition.id, {
|
|
roundType: 'MENTORING',
|
|
configJson: { eligibility: 'admin_selected' },
|
|
})
|
|
|
|
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
|
await expect(
|
|
caller.autoAssignBulkForRound({ roundId: round.id, useAI: false }),
|
|
).rejects.toThrow(/admin_selected|admin-selected|manually/i)
|
|
})
|
|
})
|
|
|
|
describe('mentor.getSuggestions source flag', () => {
|
|
const programIds: string[] = []
|
|
const userIds: string[] = []
|
|
|
|
afterAll(async () => {
|
|
for (const programId of programIds) {
|
|
await cleanupTestData(programId, [])
|
|
}
|
|
if (userIds.length > 0) {
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
|
}
|
|
})
|
|
|
|
it('marks source=fallback when OPENAI_API_KEY is missing', async () => {
|
|
const original = process.env.OPENAI_API_KEY
|
|
delete process.env.OPENAI_API_KEY
|
|
try {
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
|
userIds.push(admin.id)
|
|
|
|
const program = await createTestProgram({ name: `source-flag-${uid()}` })
|
|
programIds.push(program.id)
|
|
|
|
const project = await createTestProject(program.id, { title: 'Fallback test', tags: ['x'] })
|
|
|
|
const mentor = await createUserWithRoles('MENTOR', ['MENTOR'], { expertiseTags: ['x'] })
|
|
userIds.push(mentor.id)
|
|
|
|
const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
|
const result = await caller.getSuggestions({ projectId: project.id, limit: 5 })
|
|
expect(result.source).toBe('fallback')
|
|
} finally {
|
|
if (original) process.env.OPENAI_API_KEY = original
|
|
}
|
|
})
|
|
})
|