Files
MOPC-Portal/tests/unit/finalist-confirmation.test.ts
Matt 895be93678 feat: selectFinalists creates PENDING confirmations and sends emails
- New service module createPendingConfirmation: writes a PENDING
  FinalistConfirmation row with a signed token whose exp matches the
  computed deadline.
- selectFinalists admin mutation: reads windowHours from the round's
  configJson.confirmationWindowHours (default 24), validates category
  match + quota, then creates one confirmation per selected project
  and sends a notification email to the team lead. Email failures are
  logged but never roll back the row creation.
- New email helpers: getFinalistConfirmationTemplate +
  sendFinalistConfirmationEmail.
2026-04-28 17:55:09 +02:00

190 lines
7.0 KiB
TypeScript

import { afterAll, beforeAll, 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'
beforeAll(() => {
process.env.NEXTAUTH_SECRET = 'test-secret-for-finalist-tokens'
process.env.NEXTAUTH_URL = 'http://localhost:3001'
})
describe('finalist.selectFinalists', () => {
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 prisma.finalistSlotQuota.deleteMany({ where: { programId } })
await cleanupTestData(programId, [])
}
if (userIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
}
})
it('creates PENDING confirmations with unique tokens for each selected project', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `select-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 24 },
})
const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
const p2 = await createTestProject(program.id, { title: 'P2', competitionCategory: 'STARTUP' })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
const result = await caller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [p1.id, p2.id],
roundId: round.id,
})
expect(result.created).toBe(2)
const confirmations = await prisma.finalistConfirmation.findMany({
where: { project: { programId: program.id } },
})
expect(confirmations).toHaveLength(2)
expect(new Set(confirmations.map((c) => c.token)).size).toBe(2)
for (const c of confirmations) {
expect(c.status).toBe('PENDING')
expect(c.deadline.getTime()).toBeGreaterThan(Date.now() + 23 * 3_600_000)
expect(c.deadline.getTime()).toBeLessThan(Date.now() + 25 * 3_600_000)
}
})
it('uses round configJson.confirmationWindowHours when configured', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `select-window-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 48 },
})
const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [p1.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findFirstOrThrow({
where: { projectId: p1.id },
})
expect(confirmation.deadline.getTime()).toBeGreaterThan(Date.now() + 47 * 3_600_000)
expect(confirmation.deadline.getTime()).toBeLessThan(Date.now() + 49 * 3_600_000)
})
it('defaults to 24h when confirmationWindowHours is not in configJson', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `select-default-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: {},
})
const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await caller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [p1.id],
roundId: round.id,
})
const confirmation = await prisma.finalistConfirmation.findFirstOrThrow({
where: { projectId: p1.id },
})
expect(confirmation.deadline.getTime()).toBeGreaterThan(Date.now() + 23 * 3_600_000)
expect(confirmation.deadline.getTime()).toBeLessThan(Date.now() + 25 * 3_600_000)
})
it('rejects selecting more projects than the category quota', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `select-quota-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 24 },
})
await prisma.finalistSlotQuota.create({
data: { programId: program.id, category: 'STARTUP', quota: 1 },
})
const p1 = await createTestProject(program.id, { title: 'P1', competitionCategory: 'STARTUP' })
const p2 = await createTestProject(program.id, { title: 'P2', competitionCategory: 'STARTUP' })
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [p1.id, p2.id],
roundId: round.id,
}),
).rejects.toThrow(/exceeds quota/i)
})
it('rejects projects whose category does not match', async () => {
const admin = await createTestUser('SUPER_ADMIN')
userIds.push(admin.id)
const program = await createTestProgram({ name: `select-cat-${uid()}` })
programIds.push(program.id)
const competition = await createTestCompetition(program.id, { status: 'ACTIVE' })
const round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
configJson: { confirmationWindowHours: 24 },
})
const p1 = await createTestProject(program.id, {
title: 'P1',
competitionCategory: 'BUSINESS_CONCEPT',
})
const caller = createCaller(finalistRouter, {
id: admin.id,
email: admin.email,
role: 'SUPER_ADMIN',
})
await expect(
caller.selectFinalists({
programId: program.id,
category: 'STARTUP',
projectIds: [p1.id],
roundId: round.id,
}),
).rejects.toThrow(/category mismatch/i)
})
})