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 createApplicant(role: 'LEAD' | 'MEMBER' | 'ADVISOR' = 'MEMBER') { const id = uid('user') return prisma.user.create({ data: { id, email: `${id}@test.local`, name: `Test ${role}`, role: 'APPLICANT', roles: ['APPLICANT'], status: 'ACTIVE', }, }) } async function setupFinalistWithTeam(opts: { programName: string status?: 'CONFIRMED' | 'PENDING' | 'DECLINED' | 'EXPIRED' | 'SUPERSEDED' windowOpenAt?: Date attendeeEditCutoffHours?: number defaultAttendeeCap?: number }) { const program = await createTestProgram({ name: opts.programName, defaultAttendeeCap: opts.defaultAttendeeCap ?? 3, }) const project = await createTestProject(program.id, { title: 'P', competitionCategory: 'STARTUP', }) const lead = await createApplicant('LEAD') const teammateA = await createApplicant('MEMBER') const teammateB = await createApplicant('MEMBER') await prisma.teamMember.createMany({ data: [ { projectId: project.id, userId: lead.id, role: 'LEAD' }, { projectId: project.id, userId: teammateA.id, role: 'MEMBER' }, { projectId: project.id, userId: teammateB.id, role: 'MEMBER' }, ], }) const competition = await createTestCompetition(program.id) const cfg: Record = {} if (opts.attendeeEditCutoffHours !== undefined) { cfg.attendeeEditCutoffHours = opts.attendeeEditCutoffHours } await createTestRound(competition.id, { roundType: 'LIVE_FINAL', sortOrder: 99, windowOpenAt: opts.windowOpenAt, configJson: cfg, }) const confirmation = await prisma.finalistConfirmation.create({ data: { projectId: project.id, category: 'STARTUP', status: opts.status ?? 'CONFIRMED', deadline: new Date(Date.now() + 86_400_000), token: `tok_${uid()}`, confirmedAt: opts.status === 'CONFIRMED' || opts.status === undefined ? new Date() : null, }, }) // seed one existing AttendingMember (just the lead) await prisma.attendingMember.create({ data: { confirmationId: confirmation.id, userId: lead.id, needsVisa: false }, }) return { program, project, lead, teammateA, teammateB, confirmation } } describe('finalist.editAttendees', () => { const programIds: string[] = [] const userIds: string[] = [] afterAll(async () => { for (const programId of programIds) { await prisma.attendingMember.deleteMany({ where: { confirmation: { project: { programId } } }, }) await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) await cleanupTestData(programId, []) } if (userIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: userIds } } }) } }) it('team lead can edit attendees on a CONFIRMED confirmation before cutoff', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-ok-${uid()}`, windowOpenAt: new Date(Date.now() + 30 * 86_400_000), // 30 days out attendeeEditCutoffHours: 48, }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const caller = createCaller(finalistRouter, { id: lead.id, email: lead.email, role: 'APPLICANT', }) await caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id], visaFlags: { [teammateA.id]: true }, }) const rows = await prisma.attendingMember.findMany({ where: { confirmationId: confirmation.id }, orderBy: { userId: 'asc' }, }) expect(rows).toHaveLength(2) const userIdSet = new Set(rows.map((r) => r.userId)) expect(userIdSet.has(lead.id)).toBe(true) expect(userIdSet.has(teammateA.id)).toBe(true) const teammateRow = rows.find((r) => r.userId === teammateA.id)! expect(teammateRow.needsVisa).toBe(true) }) it('preserves AttendingMember row (and its FlightDetail) for users that stay', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-preserve-${uid()}`, windowOpenAt: new Date(Date.now() + 30 * 86_400_000), }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const leadRow = await prisma.attendingMember.findFirstOrThrow({ where: { confirmationId: confirmation.id, userId: lead.id }, }) await prisma.flightDetail.create({ data: { attendingMemberId: leadRow.id, arrivalFlightNumber: 'AF123', }, }) const caller = createCaller(finalistRouter, { id: lead.id, email: lead.email, role: 'APPLICANT', }) await caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id], visaFlags: {}, }) const stillThere = await prisma.attendingMember.findFirst({ where: { confirmationId: confirmation.id, userId: lead.id }, }) expect(stillThere?.id).toBe(leadRow.id) const flight = await prisma.flightDetail.findFirst({ where: { attendingMemberId: leadRow.id }, }) expect(flight?.arrivalFlightNumber).toBe('AF123') }) it('rejects edits past the cutoff', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-cutoff-${uid()}`, windowOpenAt: new Date(Date.now() + 24 * 3_600_000), // 24h out attendeeEditCutoffHours: 48, // cutoff was 24h ago }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const caller = createCaller(finalistRouter, { id: lead.id, email: lead.email, role: 'APPLICANT', }) await expect( caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id], visaFlags: {}, }), ).rejects.toThrow(/cutoff|closed/i) }) it('rejects when caller is not a team member', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-foreign-${uid()}`, windowOpenAt: new Date(Date.now() + 30 * 86_400_000), }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const stranger = await createApplicant('MEMBER') userIds.push(stranger.id) const caller = createCaller(finalistRouter, { id: stranger.id, email: stranger.email, role: 'APPLICANT', }) await expect( caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id], visaFlags: {}, }), ).rejects.toThrow(/team member|forbidden/i) }) it('rejects when caller is a team member but not the lead', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-not-lead-${uid()}`, windowOpenAt: new Date(Date.now() + 30 * 86_400_000), }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const caller = createCaller(finalistRouter, { id: teammateA.id, email: teammateA.email, role: 'APPLICANT', }) await expect( caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id], visaFlags: {}, }), ).rejects.toThrow(/lead/i) }) it('rejects when confirmation is not CONFIRMED', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-pending-${uid()}`, status: 'PENDING', windowOpenAt: new Date(Date.now() + 30 * 86_400_000), }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const caller = createCaller(finalistRouter, { id: lead.id, email: lead.email, role: 'APPLICANT', }) await expect( caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id], visaFlags: {}, }), ).rejects.toThrow(/CONFIRMED/i) }) it('rejects when attendees exceed defaultAttendeeCap', async () => { const { program, lead, teammateA, teammateB, confirmation } = await setupFinalistWithTeam({ programName: `edit-cap-${uid()}`, windowOpenAt: new Date(Date.now() + 30 * 86_400_000), defaultAttendeeCap: 2, }) programIds.push(program.id) userIds.push(lead.id, teammateA.id, teammateB.id) const caller = createCaller(finalistRouter, { id: lead.id, email: lead.email, role: 'APPLICANT', }) await expect( caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, teammateA.id, teammateB.id], visaFlags: {}, }), ).rejects.toThrow(/cap|exceeds/i) }) it('rejects when an attendingUserId is not a team member', async () => { const { program, lead, teammateA, confirmation } = await setupFinalistWithTeam({ programName: `edit-nonteam-${uid()}`, windowOpenAt: new Date(Date.now() + 30 * 86_400_000), }) programIds.push(program.id) userIds.push(lead.id, teammateA.id) const outsider = await createApplicant('MEMBER') userIds.push(outsider.id) const caller = createCaller(finalistRouter, { id: lead.id, email: lead.email, role: 'APPLICANT', }) await expect( caller.editAttendees({ confirmationId: confirmation.id, attendingUserIds: [lead.id, outsider.id], visaFlags: {}, }), ).rejects.toThrow(/not a team member/i) }) })