205 lines
7.2 KiB
TypeScript
205 lines
7.2 KiB
TypeScript
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
|
import { prisma } from '../setup'
|
||
|
|
import {
|
||
|
|
createTestUser,
|
||
|
|
createTestProgram,
|
||
|
|
createTestProject,
|
||
|
|
cleanupTestData,
|
||
|
|
uid,
|
||
|
|
} from '../helpers'
|
||
|
|
|
||
|
|
vi.mock('@/lib/email', async () => {
|
||
|
|
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
|
||
|
|
return {
|
||
|
|
...actual,
|
||
|
|
sendLunchReminderEmail: vi.fn(async () => undefined),
|
||
|
|
sendLunchRecapEmail: vi.fn(async () => undefined),
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const programIds: string[] = []
|
||
|
|
const userIds: string[] = []
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
for (const programId of programIds) {
|
||
|
|
await prisma.memberLunchPick.deleteMany({
|
||
|
|
where: { attendingMember: { confirmation: { project: { programId } } } },
|
||
|
|
})
|
||
|
|
await prisma.attendingMember.deleteMany({
|
||
|
|
where: { confirmation: { project: { programId } } },
|
||
|
|
})
|
||
|
|
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
||
|
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||
|
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||
|
|
await cleanupTestData(programId, [])
|
||
|
|
}
|
||
|
|
if (userIds.length > 0) {
|
||
|
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
async function callCron(
|
||
|
|
path: 'lunch-reminders' | 'lunch-recap',
|
||
|
|
secret = process.env.CRON_SECRET ?? '',
|
||
|
|
) {
|
||
|
|
const mod =
|
||
|
|
path === 'lunch-reminders'
|
||
|
|
? await import('@/app/api/cron/lunch-reminders/route')
|
||
|
|
: await import('@/app/api/cron/lunch-recap/route')
|
||
|
|
const req = new Request(`http://test.local/api/cron/${path}`, {
|
||
|
|
method: 'GET',
|
||
|
|
headers: secret ? { 'x-cron-secret': secret } : {},
|
||
|
|
})
|
||
|
|
return mod.GET(req as unknown as Parameters<typeof mod.GET>[0])
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('GET /api/cron/lunch-reminders', () => {
|
||
|
|
beforeEach(async () => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects without CRON_SECRET', async () => {
|
||
|
|
const res = await callCron('lunch-reminders', '')
|
||
|
|
expect(res.status).toBe(401)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('skips events outside the reminder window', async () => {
|
||
|
|
const program = await createTestProgram({ name: `cron-rmd-out-${uid()}` })
|
||
|
|
programIds.push(program.id)
|
||
|
|
await prisma.lunchEvent.create({
|
||
|
|
data: {
|
||
|
|
programId: program.id,
|
||
|
|
enabled: true,
|
||
|
|
eventAt: new Date(Date.now() + 30 * 86_400_000),
|
||
|
|
changeCutoffHours: 48,
|
||
|
|
reminderHoursBeforeDeadline: 24,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const res = await callCron('lunch-reminders')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
const { sendLunchReminderEmail } = await import('@/lib/email')
|
||
|
|
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('sends reminders for unpicked attendees inside the window and stamps reminderSentAt', async () => {
|
||
|
|
const program = await createTestProgram({ name: `cron-rmd-in-${uid()}` })
|
||
|
|
programIds.push(program.id)
|
||
|
|
const u = await createTestUser('APPLICANT')
|
||
|
|
userIds.push(u.id)
|
||
|
|
const project = await createTestProject(program.id, {
|
||
|
|
title: `cron-${uid()}`, competitionCategory: 'STARTUP',
|
||
|
|
})
|
||
|
|
const conf = await prisma.finalistConfirmation.create({
|
||
|
|
data: {
|
||
|
|
projectId: project.id, category: 'STARTUP', status: 'CONFIRMED',
|
||
|
|
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const am = await prisma.attendingMember.create({
|
||
|
|
data: { confirmationId: conf.id, userId: u.id },
|
||
|
|
})
|
||
|
|
await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
|
||
|
|
const eventAt = new Date(Date.now() + 25 * 3_600_000) // 25h from now
|
||
|
|
await prisma.lunchEvent.create({
|
||
|
|
data: {
|
||
|
|
programId: program.id,
|
||
|
|
enabled: true,
|
||
|
|
eventAt, // deadline = eventAt - 24h cutoff = ~1h from now
|
||
|
|
changeCutoffHours: 24,
|
||
|
|
reminderHoursBeforeDeadline: 4, // window: deadline-4h .. deadline → spans now
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const res = await callCron('lunch-reminders')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
const { sendLunchReminderEmail } = await import('@/lib/email')
|
||
|
|
expect(sendLunchReminderEmail).toHaveBeenCalledTimes(1)
|
||
|
|
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
|
||
|
|
expect(row?.reminderSentAt).not.toBeNull()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('is idempotent — re-running with reminderSentAt set does not resend', async () => {
|
||
|
|
const program = await createTestProgram({ name: `cron-rmd-idem-${uid()}` })
|
||
|
|
programIds.push(program.id)
|
||
|
|
await prisma.lunchEvent.create({
|
||
|
|
data: {
|
||
|
|
programId: program.id,
|
||
|
|
enabled: true,
|
||
|
|
eventAt: new Date(Date.now() + 25 * 3_600_000),
|
||
|
|
changeCutoffHours: 24,
|
||
|
|
reminderHoursBeforeDeadline: 4,
|
||
|
|
reminderSentAt: new Date(),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const res = await callCron('lunch-reminders')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
const { sendLunchReminderEmail } = await import('@/lib/email')
|
||
|
|
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('GET /api/cron/lunch-recap', () => {
|
||
|
|
beforeEach(async () => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
||
|
|
})
|
||
|
|
|
||
|
|
it('rejects without CRON_SECRET', async () => {
|
||
|
|
const res = await callCron('lunch-recap', '')
|
||
|
|
expect(res.status).toBe(401)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('skips events with cronEnabled=false', async () => {
|
||
|
|
const program = await createTestProgram({ name: `cron-rec-off-${uid()}` })
|
||
|
|
programIds.push(program.id)
|
||
|
|
await prisma.lunchEvent.create({
|
||
|
|
data: {
|
||
|
|
programId: program.id, enabled: true, cronEnabled: false,
|
||
|
|
eventAt: new Date(Date.now() - 86_400_000),
|
||
|
|
changeCutoffHours: 24,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const res = await callCron('lunch-recap')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
const { sendLunchRecapEmail } = await import('@/lib/email')
|
||
|
|
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('skips events with recapSentAt already set', async () => {
|
||
|
|
const program = await createTestProgram({ name: `cron-rec-sent-${uid()}` })
|
||
|
|
programIds.push(program.id)
|
||
|
|
await prisma.lunchEvent.create({
|
||
|
|
data: {
|
||
|
|
programId: program.id, enabled: true, cronEnabled: true,
|
||
|
|
eventAt: new Date(Date.now() - 86_400_000),
|
||
|
|
changeCutoffHours: 24, recapSentAt: new Date(),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const res = await callCron('lunch-recap')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
const { sendLunchRecapEmail } = await import('@/lib/email')
|
||
|
|
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('sends recap once and stamps recapSentAt when past deadline', async () => {
|
||
|
|
const program = await createTestProgram({ name: `cron-rec-go-${uid()}` })
|
||
|
|
programIds.push(program.id)
|
||
|
|
const admin = await createTestUser('SUPER_ADMIN')
|
||
|
|
userIds.push(admin.id)
|
||
|
|
await prisma.lunchEvent.create({
|
||
|
|
data: {
|
||
|
|
programId: program.id, enabled: true, cronEnabled: true,
|
||
|
|
eventAt: new Date(Date.now() - 86_400_000),
|
||
|
|
changeCutoffHours: 24,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
const res = await callCron('lunch-recap')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
const { sendLunchRecapEmail } = await import('@/lib/email')
|
||
|
|
expect(sendLunchRecapEmail).toHaveBeenCalledTimes(1)
|
||
|
|
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
|
||
|
|
expect(row?.recapSentAt).not.toBeNull()
|
||
|
|
})
|
||
|
|
})
|