import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers' vi.mock('@/lib/email', async () => { const actual = await vi.importActual('@/lib/email') return { ...actual, sendExternalDishInviteEmail: vi.fn(async () => undefined), } }) import { lunchRouter } from '@/server/routers/lunch' import { signExternalLunchToken } from '@/lib/external-lunch-token' const programIds: string[] = [] const userIds: string[] = [] beforeAll(() => { process.env.NEXTAUTH_SECRET = 'test-secret-for-external-lunch-tokens' }) beforeEach(() => { vi.clearAllMocks() }) afterAll(async () => { for (const programId of programIds) { await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { 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 newAdminCaller() { const admin = await createTestUser('SUPER_ADMIN') userIds.push(admin.id) return createCaller(lunchRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' }) } function publicCaller() { return lunchRouter.createCaller({ session: null, prisma, ip: '127.0.0.1', userAgent: 'vitest', }) } async function setupEvent(opts: { eventAt?: Date | null; changeCutoffHours?: number } = {}) { const program = await createTestProgram({ name: `xpick-${uid()}` }) programIds.push(program.id) const event = await prisma.lunchEvent.create({ data: { programId: program.id, enabled: true, eventAt: opts.eventAt ?? new Date(Date.now() + 30 * 86_400_000), venue: 'La Terrasse', changeCutoffHours: opts.changeCutoffHours ?? 48, }, }) const dish = await prisma.dish.create({ data: { lunchEventId: event.id, name: `Risotto-${uid()}`, dietaryTags: ['VEGETARIAN'] }, }) return { program, event, dish } } describe('lunch.createExternal auto-invite', () => { it('sends a dish invite and stamps inviteSentAt when an email is present', async () => { const { event } = await setupEvent() const caller = await newAdminCaller() const ext = await caller.createExternal({ lunchEventId: event.id, name: 'Marine', email: `marine-${uid()}@example.org`, }) const { sendExternalDishInviteEmail } = await import('@/lib/email') expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1) const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } }) expect(row?.inviteSentAt).not.toBeNull() }) it('does not send or stamp when no email is present', async () => { const { event } = await setupEvent() const caller = await newAdminCaller() const ext = await caller.createExternal({ lunchEventId: event.id, name: 'No Email' }) const { sendExternalDishInviteEmail } = await import('@/lib/email') expect(sendExternalDishInviteEmail).not.toHaveBeenCalled() const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } }) expect(row?.inviteSentAt).toBeNull() }) }) describe('lunch.getExternalByToken', () => { it('returns the external, event, and dishes for a valid token', async () => { const { event, dish } = await setupEvent() const ext = await prisma.externalAttendee.create({ data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` }, }) const token = signExternalLunchToken({ externalId: ext.id, exp: Math.floor(Date.now() / 1000) + 86_400, }) const res = await publicCaller().getExternalByToken({ token }) expect(res.external.id).toBe(ext.id) expect(res.external.name).toBe('Guest') expect(res.event.venue).toBe('La Terrasse') expect(res.dishes.map((d) => d.id)).toContain(dish.id) expect(res.changeDeadline).toBeInstanceOf(Date) }) it('rejects a tampered token', async () => { const { event } = await setupEvent() const ext = await prisma.externalAttendee.create({ data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` }, }) const token = signExternalLunchToken({ externalId: ext.id, exp: Math.floor(Date.now() / 1000) + 86_400, }) await expect( publicCaller().getExternalByToken({ token: token.slice(0, -2) + 'xx' }), ).rejects.toThrow(/signature/i) }) }) describe('lunch.setExternalPick', () => { it('sets the dish before the deadline', async () => { const { event, dish } = await setupEvent() const ext = await prisma.externalAttendee.create({ data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` }, }) const token = signExternalLunchToken({ externalId: ext.id, exp: Math.floor(Date.now() / 1000) + 86_400, }) await publicCaller().setExternalPick({ token, dishId: dish.id, allergens: ['GLUTEN'], allergenOther: 'wine', }) const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } }) expect(row?.dishId).toBe(dish.id) expect(row?.allergens).toEqual(['GLUTEN']) expect(row?.allergenOther).toBe('wine') }) it('rejects a pick after the change deadline', async () => { // eventAt 1h out, 24h cutoff → deadline is in the past const { event, dish } = await setupEvent({ eventAt: new Date(Date.now() + 3_600_000), changeCutoffHours: 24, }) const ext = await prisma.externalAttendee.create({ data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` }, }) const token = signExternalLunchToken({ externalId: ext.id, exp: Math.floor(Date.now() / 1000) + 86_400, }) await expect( publicCaller().setExternalPick({ token, dishId: dish.id, allergens: [], allergenOther: null, }), ).rejects.toThrow(/deadline/i) }) }) describe('lunch.sendExternalInvite', () => { it('sends and stamps inviteSentAt for an emailed external', async () => { const { event } = await setupEvent() const caller = await newAdminCaller() const ext = await prisma.externalAttendee.create({ data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` }, }) await caller.sendExternalInvite({ externalId: ext.id }) const { sendExternalDishInviteEmail } = await import('@/lib/email') expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1) const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } }) expect(row?.inviteSentAt).not.toBeNull() }) it('rejects when the external has no email', async () => { const { event } = await setupEvent() const caller = await newAdminCaller() const ext = await prisma.externalAttendee.create({ data: { lunchEventId: event.id, name: 'No Email' }, }) await expect(caller.sendExternalInvite({ externalId: ext.id })).rejects.toThrow(/email/i) }) })