import { describe, it, expect, vi, beforeEach } from 'vitest' import '../../../__mocks__/prisma' import { NextRequest } from 'next/server' import { POST } from '@/app/api/webhooks/stripe/route' import { prisma } from '@/lib/prisma' import { mockOrder, mockPayment, mockStripeCheckoutEvent, mockStripePaymentSucceededEvent, mockStripePaymentFailedEvent, } from '../../../fixtures/orders' vi.mock('@/lib/stripe', () => ({ constructWebhookEvent: vi.fn(), })) vi.mock('@/lib/email', () => ({ sendOrderConfirmationEmail: vi.fn().mockResolvedValue(undefined), })) import { constructWebhookEvent } from '@/lib/stripe' import { sendOrderConfirmationEmail } from '@/lib/email' function makeRequest(body: string, signature = 'valid-sig') { return new NextRequest( new Request('http://localhost/api/webhooks/stripe', { method: 'POST', headers: { 'stripe-signature': signature, 'Content-Type': 'text/plain' }, body, }) ) } beforeEach(() => { vi.clearAllMocks() vi.mocked(prisma.order.update).mockResolvedValue(mockOrder as any) vi.mocked(prisma.payment.updateMany).mockResolvedValue({ count: 1 }) vi.mocked(prisma.payment.update).mockResolvedValue(mockPayment as any) vi.mocked(prisma.payment.findFirst).mockResolvedValue(mockPayment as any) vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: mockOrder.user } as any) }) describe('POST /api/webhooks/stripe', () => { it('returns 400 when stripe-signature header is missing', async () => { const req = new NextRequest( new Request('http://localhost/api/webhooks/stripe', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: '{}', }) ) const res = await POST(req) expect(res.status).toBe(400) expect((await res.json()).error).toMatch(/signature/) }) it('returns 400 when signature verification fails', async () => { vi.mocked(constructWebhookEvent).mockImplementation(() => { throw new Error('stripe_signature_mismatch') }) const res = await POST(makeRequest('{}', 'bad-sig')) expect(res.status).toBe(400) expect((await res.json()).error).toBe('Invalid signature') }) it('processes checkout.session.completed: updates order to PAID', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) const res = await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) expect(res.status).toBe(200) expect(prisma.order.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: mockOrder.id }, data: { status: 'PAID' } }) ) }) it('processes checkout.session.completed: updates payment record', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) expect(prisma.payment.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { orderId: mockOrder.id }, data: expect.objectContaining({ status: 'paid' }), }) ) }) it('processes checkout.session.completed: sends confirmation email', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) expect(sendOrderConfirmationEmail).toHaveBeenCalledWith( mockOrder.user.email, expect.objectContaining({ orderId: mockOrder.id }) ) }) it('skips email send if order user has no email', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripeCheckoutEvent as any) vi.mocked(prisma.order.findUnique).mockResolvedValue({ ...mockOrder, user: null } as any) await POST(makeRequest(JSON.stringify(mockStripeCheckoutEvent))) expect(sendOrderConfirmationEmail).not.toHaveBeenCalled() }) it('processes checkout.session.completed without orderId: no DB update', async () => { const eventWithoutOrderId = { type: 'checkout.session.completed', data: { object: { metadata: {}, payment_intent: 'pi_test' } }, } vi.mocked(constructWebhookEvent).mockReturnValue(eventWithoutOrderId as any) const res = await POST(makeRequest('{}')) expect(res.status).toBe(200) expect(prisma.order.update).not.toHaveBeenCalled() }) it('processes payment_intent.succeeded: updates payment and order', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any) const res = await POST(makeRequest(JSON.stringify(mockStripePaymentSucceededEvent))) expect(res.status).toBe(200) expect(prisma.payment.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'paid' }) }) ) expect(prisma.order.update).toHaveBeenCalledWith( expect.objectContaining({ data: { status: 'PAID' } }) ) }) it('processes payment_intent.succeeded: no-op when payment not found', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentSucceededEvent as any) vi.mocked(prisma.payment.findFirst).mockResolvedValue(null) const res = await POST(makeRequest('{}')) expect(res.status).toBe(200) expect(prisma.payment.update).not.toHaveBeenCalled() }) it('processes payment_intent.payment_failed: sets status to failed and CANCELLED', async () => { vi.mocked(constructWebhookEvent).mockReturnValue(mockStripePaymentFailedEvent as any) const res = await POST(makeRequest(JSON.stringify(mockStripePaymentFailedEvent))) expect(res.status).toBe(200) expect(prisma.payment.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) }) ) expect(prisma.order.update).toHaveBeenCalledWith( expect.objectContaining({ data: { status: 'CANCELLED' } }) ) }) it('returns 200 for unhandled event types', async () => { vi.mocked(constructWebhookEvent).mockReturnValue({ type: 'customer.created', data: { object: {} } } as any) const res = await POST(makeRequest('{}')) expect(res.status).toBe(200) expect((await res.json()).received).toBe(true) }) })