216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
|
|
/**
|
||
|
|
* 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
|
||
|
|
})
|
||
|
|
})
|