/** * Regression: mentor-assignment emails must be deferred while the * project's MENTORING round is still ROUND_DRAFT. The earlier fix only * deferred the explicit `sendMentorBulkAssignmentEmail` path; the parallel * in-app-notification → email path (MENTEE_ASSIGNED, MENTOR_ASSIGNED) kept * firing immediately, causing duplicate sends both at assign-time AND * again when activateRound coalesced the same assignments. Verified * against prod incident 2026-05-26 (Camille Lopez received 9 emails). * * These tests assert that: * - in DRAFT: in-app notifications still create rows, but the styled * notification email is NOT sent; * - in ACTIVE: the styled notification email IS sent (legacy behaviour * preserved when the round is open). */ import { afterAll, beforeEach, describe, expect, it, vi } 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' vi.mock('@/lib/email', async () => { const actual = await vi.importActual('@/lib/email') return { ...actual, sendStyledNotificationEmail: vi.fn(async () => undefined), sendMentorTeamAssignmentEmail: vi.fn(async () => undefined), sendMentorBulkAssignmentEmail: vi.fn(async () => undefined), sendTeamMentorIntroductionEmail: vi.fn(async () => undefined), } }) const email = await import('@/lib/email') const sendStyledMock = email.sendStyledNotificationEmail as ReturnType const sendMentorBulkMock = email.sendMentorBulkAssignmentEmail as ReturnType const sendTeamIntroMock = email.sendTeamMentorIntroductionEmail as ReturnType async function makeMentor(): Promise<{ id: string; email: string }> { const id = uid('mentor') const u = await prisma.user.create({ data: { id, email: `${id}@test.local`, name: `Mentor ${id}`, role: 'MENTOR' as UserRole, roles: ['MENTOR'] as UserRole[], status: 'ACTIVE', // Email path requires the user to opt into emails. Default for new test // users is EMAIL so styled-email sends fire when the gate is open. notificationPreference: 'EMAIL', }, }) return { id: u.id, email: u.email } } async function makeTeamMember(projectId: string): Promise { const id = uid('teamuser') const u = await prisma.user.create({ data: { id, email: `${id}@test.local`, name: `Team ${id}`, role: 'APPLICANT' as UserRole, roles: ['APPLICANT'] as UserRole[], status: 'ACTIVE', notificationPreference: 'EMAIL', }, }) await prisma.teamMember.create({ data: { projectId, userId: u.id, role: 'MEMBER' }, }) return u.id } async function attachToMentoringRound( programId: string, projectId: string, status: 'ROUND_DRAFT' | 'ROUND_ACTIVE', ): Promise { const slug = uid() const competition = await prisma.competition.create({ data: { name: `Comp ${slug}`, slug: `comp-${slug}`, programId, status: 'ACTIVE', }, }) const round = await prisma.round.create({ data: { name: `Mentoring ${slug}`, slug: `mentoring-${slug}`, roundType: 'MENTORING', sortOrder: 1, status, competitionId: competition.id, }, }) await prisma.projectRoundState.create({ data: { roundId: round.id, projectId }, }) return round.id } describe('mentor-assignment email deferral (regression for 2026-05-26 duplicate-email incident)', () => { const programIds: string[] = [] const userIds: string[] = [] beforeEach(() => { sendStyledMock.mockClear() sendMentorBulkMock.mockClear() sendTeamIntroMock.mockClear() }) afterAll(async () => { for (const programId of programIds) { 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 } } }) } }) it('mentor.assign in DRAFT round creates in-app notif rows but sends ZERO emails', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `defer-draft-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: 'Draft Project' }) await attachToMentoringRound(program.id, project.id, 'ROUND_DRAFT') const mentor = await makeMentor() userIds.push(mentor.id) const teamUser = await makeTeamMember(project.id) userIds.push(teamUser) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) await caller.assign({ projectId: project.id, mentorId: mentor.id }) expect(sendStyledMock).not.toHaveBeenCalled() expect(sendMentorBulkMock).not.toHaveBeenCalled() expect(sendTeamIntroMock).not.toHaveBeenCalled() // In-app notification rows still fire so admin + mentor see staged state. const mentorNotifs = await prisma.inAppNotification.findMany({ where: { userId: mentor.id, type: 'MENTEE_ASSIGNED' }, }) expect(mentorNotifs.length).toBe(1) const teamNotifs = await prisma.inAppNotification.findMany({ where: { userId: teamUser, type: 'MENTOR_ASSIGNED' }, }) expect(teamNotifs.length).toBe(1) }) it('mentor.assign in ACTIVE round still sends the per-assignment emails (legacy behaviour preserved)', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `defer-active-${uid()}` }) programIds.push(program.id) const project = await createTestProject(program.id, { title: 'Active Project' }) await attachToMentoringRound(program.id, project.id, 'ROUND_ACTIVE') const mentor = await makeMentor() userIds.push(mentor.id) const teamUser = await makeTeamMember(project.id) userIds.push(teamUser) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) await caller.assign({ projectId: project.id, mentorId: mentor.id }) // Either styled notif email OR the explicit team-intro email is allowed // to fire here — point is: at least one outbound email happens when the // round is open. The DRAFT test above is the one that must stay at zero. const sentCount = sendStyledMock.mock.calls.length + sendTeamIntroMock.mock.calls.length expect(sentCount).toBeGreaterThan(0) }) it('mentor.bulkAssign in DRAFT round sends ZERO emails across multiple projects', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const program = await createTestProgram({ name: `defer-bulk-${uid()}` }) programIds.push(program.id) const p1 = await createTestProject(program.id, { title: 'BulkDraft 1' }) const p2 = await createTestProject(program.id, { title: 'BulkDraft 2' }) const p3 = await createTestProject(program.id, { title: 'BulkDraft 3' }) await attachToMentoringRound(program.id, p1.id, 'ROUND_DRAFT') await attachToMentoringRound(program.id, p2.id, 'ROUND_DRAFT') await attachToMentoringRound(program.id, p3.id, 'ROUND_DRAFT') const mentor = await makeMentor() userIds.push(mentor.id) userIds.push(await makeTeamMember(p1.id)) userIds.push(await makeTeamMember(p2.id)) userIds.push(await makeTeamMember(p3.id)) const caller = createCaller(mentorRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) await caller.bulkAssign({ mentorIds: [mentor.id], projectIds: [p1.id, p2.id, p3.id], }) expect(sendStyledMock).not.toHaveBeenCalled() expect(sendMentorBulkMock).not.toHaveBeenCalled() expect(sendTeamIntroMock).not.toHaveBeenCalled() }) })