/** * Task 5: Confirmation reminder cron * * Tests that sendDueConfirmationReminders: * - sends a FINALIST_REMINDER notification for leads whose deadline is within the window * - stamps reminderSentAt so the second call is idempotent * - skips rows whose deadline is further away than reminderHoursBeforeDeadline */ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { prisma } from '../setup' import { createTestProgram, createTestCompetition, createTestRound, createTestProject, cleanupTestData, uid, } from '../helpers' import { sendDueConfirmationReminders } from '../../src/server/services/finalist-confirmation' beforeAll(() => { process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens' process.env.NEXTAUTH_URL = 'http://localhost:3001' }) describe('sendDueConfirmationReminders', () => { const programIds: string[] = [] const userIds: string[] = [] afterAll(async () => { for (const programId of programIds) { await prisma.inAppNotification.deleteMany({ where: { metadata: { path: ['projectId'], string_contains: '' } }, }) await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } }) await cleanupTestData(programId, []) } if (userIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: userIds } } }) } }) it('sends FINALIST_REMINDER for a lead whose deadline is within the reminder window', async () => { const program = await createTestProgram({ name: `reminder-due-${uid()}` }) programIds.push(program.id) const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) // LIVE_FINAL round with 12h reminder window await createTestRound(competition.id, { roundType: 'LIVE_FINAL', configJson: { reminderHoursBeforeDeadline: 12 }, }) const lead = await prisma.user.create({ data: { id: uid('user'), email: `lead-reminder-${uid()}@test.local`, name: 'Reminder Lead', role: 'APPLICANT', roles: ['APPLICANT'], status: 'ACTIVE', }, }) userIds.push(lead.id) const project = await createTestProject(program.id, { title: 'Reminder Project', competitionCategory: 'STARTUP', }) await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, }) // Deadline 6 hours from now — within the 12h window const deadline = new Date(Date.now() + 6 * 3_600_000) const token = `tok_reminder_${uid()}` await prisma.finalistConfirmation.create({ data: { projectId: project.id, category: 'STARTUP', status: 'PENDING', deadline, token, reminderSentAt: null, }, }) const result = await sendDueConfirmationReminders(prisma) expect(result.remindersSent).toBe(1) // Notification created for the lead const notification = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'FINALIST_REMINDER' }, }) expect(notification).not.toBeNull() expect(notification?.metadata).toMatchObject({ projectTitle: 'Reminder Project' }) // reminderSentAt is stamped const updated = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId: project.id }, }) expect(updated.reminderSentAt).not.toBeNull() // Clean up notification so it doesn't interfere with idempotency test await prisma.inAppNotification.deleteMany({ where: { userId: lead.id, type: 'FINALIST_REMINDER' } }) }) it('is idempotent — second call sends 0 reminders for the same row', async () => { // Reuse the row created above — reminderSentAt is now set const program = await createTestProgram({ name: `reminder-idempotent-${uid()}` }) programIds.push(program.id) const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) await createTestRound(competition.id, { roundType: 'LIVE_FINAL', configJson: { reminderHoursBeforeDeadline: 12 }, }) const lead = await prisma.user.create({ data: { id: uid('user'), email: `lead-idempotent-${uid()}@test.local`, name: 'Idempotent Lead', role: 'APPLICANT', roles: ['APPLICANT'], status: 'ACTIVE', }, }) userIds.push(lead.id) const project = await createTestProject(program.id, { title: 'Idempotent Project', competitionCategory: 'STARTUP', }) await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, }) const deadline = new Date(Date.now() + 6 * 3_600_000) const token = `tok_idempotent_${uid()}` await prisma.finalistConfirmation.create({ data: { projectId: project.id, category: 'STARTUP', status: 'PENDING', deadline, token, reminderSentAt: null, }, }) // First call — should send 1 const first = await sendDueConfirmationReminders(prisma) expect(first.remindersSent).toBeGreaterThanOrEqual(1) // Second call — same row, reminderSentAt is now set → 0 const second = await sendDueConfirmationReminders(prisma) expect(second.remindersSent).toBe(0) }) it('does NOT send for a PENDING row whose deadline is outside the reminder window (48h from now, 12h window)', async () => { const program = await createTestProgram({ name: `reminder-notdue-${uid()}` }) programIds.push(program.id) const competition = await createTestCompetition(program.id, { status: 'ACTIVE' }) await createTestRound(competition.id, { roundType: 'LIVE_FINAL', configJson: { reminderHoursBeforeDeadline: 12 }, }) const lead = await prisma.user.create({ data: { id: uid('user'), email: `lead-notdue-${uid()}@test.local`, name: 'Not-Due Lead', role: 'APPLICANT', roles: ['APPLICANT'], status: 'ACTIVE', }, }) userIds.push(lead.id) const project = await createTestProject(program.id, { title: 'Not Due Project', competitionCategory: 'STARTUP', }) await prisma.teamMember.create({ data: { projectId: project.id, userId: lead.id, role: 'LEAD' }, }) // Deadline 48 hours from now — far outside the 12h window const deadline = new Date(Date.now() + 48 * 3_600_000) const token = `tok_notdue_${uid()}` await prisma.finalistConfirmation.create({ data: { projectId: project.id, category: 'STARTUP', status: 'PENDING', deadline, token, reminderSentAt: null, }, }) const result = await sendDueConfirmationReminders(prisma) // The only unflagged row in this program has deadline 48h out, should not be sent const notification = await prisma.inAppNotification.findFirst({ where: { userId: lead.id, type: 'FINALIST_REMINDER' }, }) expect(notification).toBeNull() // reminderSentAt still null const row = await prisma.finalistConfirmation.findUniqueOrThrow({ where: { projectId: project.id }, }) expect(row.reminderSentAt).toBeNull() void result // result.remindersSent may be > 0 from other programs' rows already in DB }) })