/** * PR8 — Multi-mentor stacking + change-request procedures * * Covers the API surface added by PR8 Tasks 4 + 6: * - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId), * idempotent per-row email notification (via MentorAssignment.notificationSentAt), * re-assignment after drop creates a new row and re-fires the email. * - mentor.requestChange: auth (team-member or admin), validation, single open * request per (user, project), target-assignment cross-project guard. * - mentor.listChangeRequests: admin-only, PENDING-first ordering. * - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved. */ import { afterAll, describe, expect, it } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestProject, cleanupTestData, uid, } from '../helpers' import { mentorRouter } from '../../src/server/routers/mentor' import type { UserRole } from '@prisma/client' async function createUserWithRoles( primaryRole: UserRole, rolesArray: UserRole[], overrides: { name?: string; expertiseTags?: string[] } = {}, ) { const id = uid('user') return prisma.user.create({ data: { id, email: `${id}@test.local`, name: overrides.name ?? `Test ${primaryRole}`, role: primaryRole, roles: rolesArray, status: 'ACTIVE', expertiseTags: overrides.expertiseTags ?? [], }, }) } /** * mentor.assign and mentor.bulkAssign now require the project to be enrolled * in some MENTORING round. This helper sets up the minimum: one competition * + one MENTORING round + one ProjectRoundState linking the project. */ async function attachToMentoringRound(programId: string, projectId: string) { const compSlug = `comp-${uid()}` const competition = await prisma.competition.create({ data: { name: `Comp ${compSlug}`, slug: compSlug, programId, status: 'ACTIVE', }, }) const round = await prisma.round.create({ data: { name: `Mentoring ${uid()}`, slug: `mentoring-${uid()}`, roundType: 'MENTORING', sortOrder: 1, status: 'ROUND_ACTIVE', competitionId: competition.id, }, }) await prisma.projectRoundState.create({ data: { roundId: round.id, projectId }, }) return { competitionId: competition.id, roundId: round.id } } describe('mentor.assign — stacking + per-team email idempotency', () => { 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('stacks two different mentors on the same project (both rows active)', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `assign-stack-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: 'Stacking Project' }) await attachToMentoringRound(program.id, project.id) const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' }) const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' }) userIds.push(m1.id, m2.id) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id }) const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id }) expect(a1.id).not.toBe(a2.id) expect(a1.mentorId).toBe(m1.id) expect(a2.mentorId).toBe(m2.id) const rows = await prisma.mentorAssignment.findMany({ where: { projectId: project.id }, orderBy: { assignedAt: 'asc' }, }) expect(rows).toHaveLength(2) expect(rows.every((r) => r.droppedAt === null)).toBe(true) }) it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `assign-dup-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: 'Dup Project' }) await attachToMentoringRound(program.id, project.id) const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) userIds.push(mentor.id) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) await caller.assign({ projectId: project.id, mentorId: mentor.id }) await expect( caller.assign({ projectId: project.id, mentorId: mentor.id }), ).rejects.toThrow(/already assigned/i) }) it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `assign-email-${uid()}` }) programIds.push(program.id) const project1 = await createTestProject(program.id, { title: 'Project Alpha' }) await attachToMentoringRound(program.id, project1.id) const project2 = await createTestProject(program.id, { title: 'Project Beta' }) await attachToMentoringRound(program.id, project2.id) const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) userIds.push(mentor.id) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id }) const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id }) // assign() returns the row before the post-write stamp; re-read for the // current value. const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } }) const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } }) expect(row1?.notificationSentAt).not.toBeNull() expect(row2?.notificationSentAt).not.toBeNull() // Each row carries its own timestamp — they're independent. expect(row1?.id).not.toBe(row2?.id) }) it('stamps notificationSentAt independently for each co-mentor on the same project', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `assign-comentor-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: 'Co-mentor Project' }) await attachToMentoringRound(program.id, project.id) const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' }) const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' }) userIds.push(m1.id, m2.id) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id }) const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id }) const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } }) const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } }) expect(row1?.notificationSentAt).not.toBeNull() expect(row2?.notificationSentAt).not.toBeNull() }) it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `assign-redrop-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: 'Re-assign Project' }) await attachToMentoringRound(program.id, project.id) const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) userIds.push(mentor.id) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id }) const stamp1 = ( await prisma.mentorAssignment.findUnique({ where: { id: a1.id } }) )?.notificationSentAt expect(stamp1).not.toBeNull() // Hard-delete the first row (simulates a "fully dropped → repository clean" // state — the unique constraint also blocks any re-assign while the row // exists, so the row must go away). await prisma.mentorAssignment.delete({ where: { id: a1.id } }) // Re-assign: new row, new notificationSentAt stamp. const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id }) const stamp2 = ( await prisma.mentorAssignment.findUnique({ where: { id: a2.id } }) )?.notificationSentAt expect(a2.id).not.toBe(a1.id) expect(stamp2).not.toBeNull() }) }) describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => { const programIds: string[] = [] const userIds: string[] = [] afterAll(async () => { for (const programId of programIds) { await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } }) await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) await prisma.teamMember.deleteMany({ where: { project: { programId } } }) await cleanupTestData(programId, []) } if (userIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: userIds } } }) } }) /** * Builds a project with a LEAD team member (applicant), an unrelated * non-team-member (applicant), and an admin. Returns the IDs. */ async function setupProjectWithTeam(label: string) { const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` }) userIds.push(admin.id) const program = await createTestProgram({ name: `${label}-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: `Project ${label}` }) const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], { name: `Team ${label}`, }) const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], { name: `Outsider ${label}`, }) userIds.push(teamMember.id, outsider.id) await prisma.teamMember.create({ data: { projectId: project.id, userId: teamMember.id, role: 'LEAD', }, }) return { admin, program, project, teamMember, outsider } } it('team member can open a change request (PENDING)', async () => { const { project, teamMember } = await setupProjectWithTeam('rc-teamok') const caller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) const created = await caller.requestChange({ projectId: project.id, reason: 'We would like a mentor with deeper marine biology experience.', }) expect(created.status).toBe('PENDING') const persisted = await prisma.mentorChangeRequest.findUnique({ where: { id: created.id }, }) expect(persisted?.requestedByUserId).toBe(teamMember.id) expect(persisted?.projectId).toBe(project.id) }) it('non-team-member non-admin is rejected with FORBIDDEN', async () => { const { project, outsider } = await setupProjectWithTeam('rc-outsider') const caller = createCaller(mentorRouter, { id: outsider.id, email: outsider.email, role: 'APPLICANT', }) await expect( caller.requestChange({ projectId: project.id, reason: 'I have no business asking for this.', }), ).rejects.toThrow(/FORBIDDEN|not a member/i) }) it('admin (no team membership) can open a change request', async () => { const { admin, project } = await setupProjectWithTeam('rc-admin') const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const created = await caller.requestChange({ projectId: project.id, reason: 'Admin-initiated mentor swap due to internal escalation.', }) expect(created.status).toBe('PENDING') }) it('reason < 10 chars is rejected (Zod validation)', async () => { const { project, teamMember } = await setupProjectWithTeam('rc-short') const caller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) await expect( caller.requestChange({ projectId: project.id, reason: 'too short' }), ).rejects.toThrow() }) it('opening a second request while the first is still PENDING throws CONFLICT', async () => { const { project, teamMember } = await setupProjectWithTeam('rc-conflict') const caller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) await caller.requestChange({ projectId: project.id, reason: 'First request — still pending please review.', }) await expect( caller.requestChange({ projectId: project.id, reason: 'Second request while first is open.', }), ).rejects.toThrow(/already.*open|CONFLICT/i) }) it('after the first request is resolved, the same user can open a new one', async () => { const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen') const teamCaller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) const adminCaller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const first = await teamCaller.requestChange({ projectId: project.id, reason: 'First request — please address my concerns.', }) await adminCaller.resolveChangeRequest({ id: first.id, status: 'RESOLVED', resolutionNote: 'Mentor swapped.', }) const second = await teamCaller.requestChange({ projectId: project.id, reason: 'Second request — new concern after resolution.', }) expect(second.status).toBe('PENDING') expect(second.id).not.toBe(first.id) }) it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => { const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj') // Make a second project + mentor assignment NOT on the requester's project. const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` }) programIds.push(otherProgram.id) const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' }) const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) userIds.push(mentor.id) const foreignAssignment = await prisma.mentorAssignment.create({ data: { projectId: otherProject.id, mentorId: mentor.id, method: 'MANUAL', assignedBy: admin.id, }, }) const caller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) await expect( caller.requestChange({ projectId: projectA.id, targetAssignmentId: foreignAssignment.id, reason: 'Trying to point at a foreign assignment row.', }), ).rejects.toThrow(/does not belong|BAD_REQUEST/i) }) it('listChangeRequests is FORBIDDEN for applicant', async () => { const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden') const teamCaller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) await teamCaller.requestChange({ projectId: project.id, reason: 'A real request, but list should still be admin-only.', }) await expect(teamCaller.listChangeRequests({})).rejects.toThrow() }) it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => { const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order') const teamCaller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) const adminCaller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) // Create two requests: open one, resolve it; open a second one (still PENDING). const resolvedReq = await teamCaller.requestChange({ projectId: project.id, reason: 'Will be resolved before the second request opens.', }) await adminCaller.resolveChangeRequest({ id: resolvedReq.id, status: 'RESOLVED', }) const pendingReq = await teamCaller.requestChange({ projectId: project.id, reason: 'Still pending — should be listed first.', }) const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{ id: string status: string }> const ids = rows.map((r) => r.id) // PENDING must come before RESOLVED in the listing. expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id)) expect(rows[0].status).toBe('PENDING') }) it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => { const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve') const teamCaller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) const adminCaller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const req = await teamCaller.requestChange({ projectId: project.id, reason: 'Please resolve this request.', }) const result = await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED', resolutionNote: 'Replacement mentor assigned.', }) expect(result.status).toBe('RESOLVED') expect(result.resolvedByUserId).toBe(admin.id) expect(result.resolvedAt).not.toBeNull() expect(result.resolutionNote).toBe('Replacement mentor assigned.') }) it('resolveChangeRequest by non-admin is FORBIDDEN', async () => { const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid') const teamCaller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) const adminCaller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const req = await adminCaller.requestChange({ projectId: project.id, reason: 'Admin opens, applicant should not resolve.', }) await expect( teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }), ).rejects.toThrow() }) it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => { const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice') const teamCaller = createCaller(mentorRouter, { id: teamMember.id, email: teamMember.email, role: 'APPLICANT', }) const adminCaller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) const req = await teamCaller.requestChange({ projectId: project.id, reason: 'Will resolve, then try to resolve again.', }) await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }) await expect( adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }), ).rejects.toThrow(/already resolved|BAD_REQUEST/i) }) })