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' async function createApplicantUser(role: 'LEAD' | 'MEMBER' = 'MEMBER') { const id = uid('user') return prisma.user.create({ data: { id, email: `${id}@test.local`, name: `Test ${role}`, role: 'APPLICANT', roles: ['APPLICANT'], status: 'ACTIVE', }, }) } describe('finalist.unenroll', () => { const programIds: string[] = [] const userIds: string[] = [] afterAll(async () => { for (const id of programIds) { await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId: id } } }, }) await prisma.finalistConfirmation.deleteMany({ where: { project: { programId: id } } }) await prisma.projectRoundState.deleteMany({ where: { round: { competition: { programId: id } } }, }) await cleanupTestData(id, []) } if (userIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: userIds } } }) } }) async function setupUnenrollFixture(programName: string) { const program = await createTestProgram({ name: programName, defaultAttendeeCap: 3 }) const competition = await createTestCompetition(program.id) const mentoringRound = 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: 'Unenroll Test Project', competitionCategory: 'STARTUP', }) const lead = await createApplicantUser('LEAD') const member = await createApplicantUser('MEMBER') await prisma.teamMember.createMany({ data: [ { projectId: project.id, userId: lead.id, role: 'LEAD' }, { projectId: project.id, userId: member.id, role: 'MEMBER' }, ], }) // Put the project in the MENTORING round (as candidates) await prisma.projectRoundState.create({ data: { projectId: project.id, roundId: mentoringRound.id }, }) return { program, competition, mentoringRound, liveFinalRound, project, lead, member } } it('removes FinalistConfirmation, AttendingMember rows, and LIVE_FINAL PRS; logs FINALIST_UNENROLL audit', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const { program, liveFinalRound, project, lead, member } = await setupUnenrollFixture( `unenroll-main-${uid()}`, ) programIds.push(program.id) userIds.push(lead.id, member.id) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) // First enroll via ADMIN_CONFIRM so we have a full set of rows to verify removal await caller.enrollFinalists({ programId: program.id, roundId: liveFinalRound.id, enrollments: [ { projectId: project.id, mode: 'ADMIN_CONFIRM', attendingUserIds: [lead.id, member.id], visaFlags: { [member.id]: true }, }, ], }) // Verify pre-conditions: confirmation + attendees + PRS exist const confBefore = await prisma.finalistConfirmation.findUnique({ where: { projectId: project.id }, }) expect(confBefore).not.toBeNull() expect(confBefore!.status).toBe('CONFIRMED') const attendeesBefore = await prisma.attendingMember.count({ where: { confirmationId: confBefore!.id }, }) expect(attendeesBefore).toBe(2) const prsBefore = await prisma.projectRoundState.findFirst({ where: { projectId: project.id, roundId: liveFinalRound.id }, }) expect(prsBefore).not.toBeNull() // Now unenroll const result = await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id, }) expect(result).toEqual({ ok: true }) // FinalistConfirmation is gone const confAfter = await prisma.finalistConfirmation.findUnique({ where: { projectId: project.id }, }) expect(confAfter).toBeNull() // AttendingMember rows are gone (cascade from FinalistConfirmation deletion) const attendeesAfter = await prisma.attendingMember.count({ where: { confirmationId: confBefore!.id }, }) expect(attendeesAfter).toBe(0) // LIVE_FINAL ProjectRoundState is gone const prsAfter = await prisma.projectRoundState.findFirst({ where: { projectId: project.id, roundId: liveFinalRound.id }, }) expect(prsAfter).toBeNull() // MENTORING PRS is NOT touched const mentoringPrs = await prisma.projectRoundState.findFirst({ where: { projectId: project.id, roundId: { not: liveFinalRound.id } }, }) expect(mentoringPrs).not.toBeNull() // Audit row exists const auditRow = await prisma.auditLog.findFirst({ where: { action: 'FINALIST_UNENROLL', entityType: 'Project', entityId: project.id, }, orderBy: { timestamp: 'desc' }, }) expect(auditRow).not.toBeNull() expect((auditRow!.detailsJson as Record).projectId).toBe(project.id) expect((auditRow!.detailsJson as Record).roundId).toBe(liveFinalRound.id) }) it('is a no-op when the project was never enrolled (no rows to delete)', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const { program, liveFinalRound, project, lead, member } = await setupUnenrollFixture( `unenroll-noop-${uid()}`, ) programIds.push(program.id) userIds.push(lead.id, member.id) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) // No prior enrollment — should not throw const result = await caller.unenroll({ projectId: project.id, roundId: liveFinalRound.id, }) expect(result).toEqual({ ok: true }) // Nothing exists after const confAfter = await prisma.finalistConfirmation.findUnique({ where: { projectId: project.id }, }) expect(confAfter).toBeNull() const prsAfter = await prisma.projectRoundState.findFirst({ where: { projectId: project.id, roundId: liveFinalRound.id }, }) expect(prsAfter).toBeNull() }) it('rejects a project/round pair from different programs', async () => { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) const a = await setupUnenrollFixture(`unenroll-xprogram-a-${uid()}`) const b = await setupUnenrollFixture(`unenroll-xprogram-b-${uid()}`) programIds.push(a.program.id, b.program.id) userIds.push(a.lead.id, a.member.id, b.lead.id, b.member.id) const caller = createCaller(finalistRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN', }) // Program A's project + Program B's round → rejected before any delete. await expect( caller.unenroll({ projectId: a.project.id, roundId: b.liveFinalRound.id }), ).rejects.toThrow(/different programs/i) }) })