Files
MOPC-Portal/tests/unit/mentor-email-deferral.test.ts
Matt 03526fca97
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
fix(mentor): defer in-app-notification emails when mentoring round is draft
Mentor-assignment flows (mentor.assign, autoAssign, bulkAssign,
bulkAutoAssign, autoAssignBulkForRound) call createNotification and
notifyProjectTeam for MENTEE_ASSIGNED / MENTOR_ASSIGNED. Both
notification types have NotificationEmailSetting.sendEmail = true, so
the notification system fires its own styled email in addition to the
explicit mentor-team / coalesced emails on the same code path. The
earlier defer-emails-until-round-open fix only gated the explicit
sendMentorBulkAssignmentEmail / sendMentorTeamAssignmentEmail calls;
this parallel email path kept firing immediately at every assignment.

Result on prod 2026-05-26: Camille Lopez (assigned to 9 projects via
two bulk_assigns) received 7 emails at 15:04 + 1 at 15:32 from the
notification-system path during draft, plus 1 coalesced email at the
18:20 round activation = 9 sends instead of 1. Every PEARL team
member (and equivalents on other teams) received 3 emails for the
same reason.

Fix
- Add `skipEmail?: boolean` to CreateNotificationParams,
  createNotification, createBulkNotifications, and (via spread)
  notifyProjectTeam. When true the in-app notification row still
  fires but the parallel email send is suppressed; the coalesced
  mentor email and team intro at activateRound time remain the
  single source of email truth.
- Wire it up in every mentor-assignment site: compute the existing
  shouldDeferEmailsForProject gate once before the createNotification
  / notifyProjectTeam calls and pass `skipEmail: deferThisEmail`.
  bulkAssign precomputes draftProjectIds for the whole batch.
  autoAssignBulkForRound uses the round's status directly.
- New regression suite (mentor-email-deferral.test.ts, 3 cases):
  vi.mocks @/lib/email, asserts zero outbound sends when round is
  ROUND_DRAFT, confirms in-app notification rows still get written,
  and re-verifies the ACTIVE-round path still emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:12:41 +02:00

229 lines
8.0 KiB
TypeScript

/**
* 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<typeof import('@/lib/email')>('@/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<typeof vi.fn>
const sendMentorBulkMock = email.sendMentorBulkAssignmentEmail as ReturnType<typeof vi.fn>
const sendTeamIntroMock = email.sendTeamMentorIntroductionEmail as ReturnType<typeof vi.fn>
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<string> {
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<string> {
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()
})
})