/** * Task 4: Withdrawal emails to teams * * Verifies that FINALIST_WITHDRAWN in-app notifications are sent to the team * lead when an admin withdraws a team's grand-finale slot via: * - adminDecline (PENDING → DECLINED) * - unconfirm (CONFIRMED → SUPERSEDED) * - unenroll (only when a CONFIRMED confirmation existed; NOT when never enrolled) */ import { afterAll, describe, expect, it } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestProject, createTestCompetition, createTestRound, cleanupTestData, uid, } from '../helpers' import { finalistRouter } from '../../src/server/routers/finalist' beforeAll(() => { process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' process.env.NEXTAUTH_URL = 'http://localhost:3001' }) import { beforeAll } from 'vitest' // ── helpers ──────────────────────────────────────────────────────────────── async function createApplicant(label = 'user') { const id = uid(label) return prisma.user.create({ data: { id, email: `${id}@test.local`, name: `Test ${label}`, role: 'APPLICANT', roles: ['APPLICANT'], status: 'ACTIVE', }, }) } /** Creates a program + competition + round (LIVE_FINAL) + project + team lead, * returns all. Callers can then create a FinalistConfirmation themselves so they * control the initial status. */ async function setupBase(label: string) { const program = await createTestProgram({ name: `withdrawal-${label}-${uid()}`, defaultAttendeeCap: 3 }) const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) await createTestRound(competition.id, { roundType: 'MENTORING', sortOrder: 60, }) const liveFinalRound = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', sortOrder: 70, configJson: { confirmationWindowHours: 24 }, }) const project = await createTestProject(program.id, { title: `Withdrawal Test ${label}`, competitionCategory: 'STARTUP', }) const lead = await createApplicant('lead') await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, }) // Put project in MENTORING round (enrollment candidate) await prisma.projectRoundState.create({ data: { projectId: project.id, roundId: ( await prisma.round.findFirstOrThrow({ where: { competition: { programId: program.id }, roundType: 'MENTORING' }, }) ).id, }, }) return { program, liveFinalRound, project, lead } } // ── suite ────────────────────────────────────────────────────────────────── describe('finalist withdrawal notifications (Task 4)', () => { const programIds: string[] = [] const userIds: string[] = [] const notificationIds: string[] = [] afterAll(async () => { if (notificationIds.length > 0) { await prisma.inAppNotification.deleteMany({ where: { id: { in: notificationIds } } }) } for (const programId of programIds) { await prisma.inAppNotification.deleteMany({ where: { metadata: { path: ['projectTitle'], string_contains: 'Withdrawal Test' } }, }) await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId } } }, }) await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) await prisma.waitlistEntry.deleteMany({ where: { programId } }) await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId } } }, }) await cleanupTestData(programId, []) } if (userIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: userIds } } }) } }) it('Test 1: adminDecline on PENDING sends FINALIST_WITHDRAWN to team lead', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const { program, project, lead } = await setupBase('adminDecline') programIds.push(program.id) userIds.push(lead.id) // Create a PENDING FinalistConfirmation manually const confirmation = await prisma.finalistConfirmation.create({ data: { projectId: project.id, category: 'STARTUP', status: 'PENDING', deadline: new Date(Date.now() + 86_400_000), token: `tok_${uid()}`, }, }) // Clear any prior notifications for lead await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) await caller.adminDecline({ confirmationId: confirmation.id, reason: 'No longer eligible', }) const notification = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, }) expect(notification, 'lead should have FINALIST_WITHDRAWN notification').not.toBeNull() expect(notification?.type).toBe('FINALIST_WITHDRAWN') if (notification) notificationIds.push(notification.id) }) it('Test 2: unconfirm (CONFIRMED→SUPERSEDED) sends FINALIST_WITHDRAWN to team lead', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const { program, project, lead } = await setupBase('unconfirm') programIds.push(program.id) userIds.push(lead.id) // Create a CONFIRMED FinalistConfirmation manually const confirmation = await prisma.finalistConfirmation.create({ data: { projectId: project.id, category: 'STARTUP', status: 'CONFIRMED', deadline: new Date(Date.now() + 86_400_000), token: `tok_${uid()}`, confirmedAt: new Date(), }, }) await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) await caller.unconfirm({ confirmationId: confirmation.id, reason: 'Quota change required — test', }) const notification = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, }) expect(notification, 'lead should have FINALIST_WITHDRAWN notification after unconfirm').not.toBeNull() expect(notification?.type).toBe('FINALIST_WITHDRAWN') if (notification) notificationIds.push(notification.id) }) it('Test 3: unenroll of CONFIRMED team sends FINALIST_WITHDRAWN to team lead', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const { program, liveFinalRound, project, lead } = await setupBase('unenroll-confirmed') programIds.push(program.id) userIds.push(lead.id) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) // Enroll via ADMIN_CONFIRM → status = CONFIRMED await caller.enrollFinalists({ programId: program.id, roundId: liveFinalRound.id, enrollments: [ { projectId: project.id, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id], visaFlags: {}, }, ], }) // Verify the confirmation is indeed CONFIRMED before we unenroll const confBefore = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId: project.id }, }) expect(confBefore.status).toBe('CONFIRMED') await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id }) const notification = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, }) expect(notification, 'lead should have FINALIST_WITHDRAWN after unenrolling a CONFIRMED team').not.toBeNull() expect(notification?.type).toBe('FINALIST_WITHDRAWN') if (notification) notificationIds.push(notification.id) }) it('Test 4: unenroll of never-enrolled project does NOT send FINALIST_WITHDRAWN', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const { program, liveFinalRound, project, lead } = await setupBase('unenroll-none') programIds.push(program.id) userIds.push(lead.id) await prisma.inAppNotification.deleteMany({ where: { userId: lead.id } }) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) // No prior enrollment — should be a no-op await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id }) const notification = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'FINALIST_WITHDRAWN' }, }) expect(notification, 'no FINALIST_WITHDRAWN when project was never enrolled').toBeNull() }) })