test: add integration tests for API routes (auth, Stripe webhook)
27 tests covering POST /api/auth/login (9), POST /api/auth/register (9), and POST /api/webhooks/stripe (11). Routes are tested by importing handlers directly as functions, no HTTP server needed. Stripe false-positive fixed: thrown error message now differs from the hardcoded 400 response to verify sanitization.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import '../../../__mocks__/prisma'
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { POST } from '@/app/api/auth/login/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { mockUser } from '../../../fixtures/users'
|
||||
|
||||
vi.mock('@/lib/auth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth')>()
|
||||
return {
|
||||
...actual,
|
||||
verifyPassword: vi.fn(),
|
||||
createSession: vi.fn().mockResolvedValue('mock-session-token'),
|
||||
setSessionCookie: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { verifyPassword, createSession, setSessionCookie } from '@/lib/auth'
|
||||
|
||||
function makeRequest(body: unknown, ip = '1.2.3.4') {
|
||||
return new NextRequest(
|
||||
new Request('http://localhost/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': ip },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0)
|
||||
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
|
||||
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
|
||||
})
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('returns 429 when rate limit exceeded', async () => {
|
||||
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10)
|
||||
|
||||
const res = await POST(makeRequest({ email: 'user@example.com', password: 'pass' }))
|
||||
expect(res.status).toBe(429)
|
||||
const data = await res.json()
|
||||
expect(data.error).toMatch(/Too many/)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid email format', async () => {
|
||||
const res = await POST(makeRequest({ email: 'not-an-email', password: 'pass' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for empty password', async () => {
|
||||
const res = await POST(makeRequest({ email: 'user@example.com', password: '' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for malformed JSON body', async () => {
|
||||
const req = new NextRequest(
|
||||
new Request('http://localhost/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
|
||||
body: 'not-json',
|
||||
})
|
||||
)
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 401 when user not found', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
|
||||
|
||||
const res = await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' }))
|
||||
expect(res.status).toBe(401)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Invalid email or password')
|
||||
})
|
||||
|
||||
it('records a failed attempt when user not found', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
|
||||
|
||||
await POST(makeRequest({ email: 'notfound@example.com', password: 'SomePass1!' }))
|
||||
|
||||
expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } })
|
||||
})
|
||||
|
||||
it('returns 401 when password is wrong', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
|
||||
vi.mocked(verifyPassword).mockResolvedValue(false)
|
||||
|
||||
const res = await POST(makeRequest({ email: mockUser.email, password: 'WrongPass1!' }))
|
||||
expect(res.status).toBe(401)
|
||||
expect((await res.json()).error).toBe('Invalid email or password')
|
||||
})
|
||||
|
||||
it('returns 200 with user data on successful login', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true)
|
||||
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
|
||||
|
||||
const res = await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' }))
|
||||
expect(res.status).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.user.email).toBe(mockUser.email)
|
||||
expect(data.user.role).toBe(mockUser.role)
|
||||
expect(data.user).not.toHaveProperty('passwordHash')
|
||||
})
|
||||
|
||||
it('creates a session and sets cookie on successful login', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true)
|
||||
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
|
||||
|
||||
await POST(makeRequest({ email: mockUser.email, password: 'ValidPass1!' }))
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(mockUser.id)
|
||||
expect(setSessionCookie).toHaveBeenCalledWith('mock-session-token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import '../../../__mocks__/prisma'
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { POST } from '@/app/api/auth/register/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { mockUser } from '../../../fixtures/users'
|
||||
|
||||
vi.mock('@/lib/auth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth')>()
|
||||
return {
|
||||
...actual,
|
||||
hashPassword: vi.fn().mockResolvedValue('$2a$12$hashedpassword'),
|
||||
createSession: vi.fn().mockResolvedValue('mock-token'),
|
||||
setSessionCookie: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { hashPassword, createSession, setSessionCookie } from '@/lib/auth'
|
||||
|
||||
function makeRequest(body: unknown) {
|
||||
return new NextRequest(
|
||||
new Request('http://localhost/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const validBody = {
|
||||
email: 'newuser@example.com',
|
||||
password: 'ValidPass123!',
|
||||
name: 'Nuovo Utente',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null)
|
||||
vi.mocked(prisma.user.create).mockResolvedValue({ ...mockUser, email: validBody.email, name: validBody.name } as any)
|
||||
vi.mocked(prisma.session.create).mockResolvedValue({} as any)
|
||||
})
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('returns 201 with user data on successful registration', async () => {
|
||||
const res = await POST(makeRequest(validBody))
|
||||
expect(res.status).toBe(201)
|
||||
const data = await res.json()
|
||||
expect(data.user.email).toBe(validBody.email)
|
||||
expect(data.user).not.toHaveProperty('passwordHash')
|
||||
})
|
||||
|
||||
it('hashes the password before storing', async () => {
|
||||
await POST(makeRequest(validBody))
|
||||
expect(hashPassword).toHaveBeenCalledWith(validBody.password)
|
||||
const createCall = vi.mocked(prisma.user.create).mock.calls[0][0]
|
||||
expect(createCall.data.passwordHash).toBe('$2a$12$hashedpassword')
|
||||
})
|
||||
|
||||
it('creates a session and sets cookie after registration', async () => {
|
||||
await POST(makeRequest(validBody))
|
||||
expect(createSession).toHaveBeenCalled()
|
||||
expect(setSessionCookie).toHaveBeenCalledWith('mock-token')
|
||||
})
|
||||
|
||||
it('sets role to CUSTOMER', async () => {
|
||||
await POST(makeRequest(validBody))
|
||||
const createCall = vi.mocked(prisma.user.create).mock.calls[0][0]
|
||||
expect(createCall.data.role).toBe('CUSTOMER')
|
||||
})
|
||||
|
||||
it('returns 409 when email is already in use', async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any)
|
||||
|
||||
const res = await POST(makeRequest(validBody))
|
||||
expect(res.status).toBe(409)
|
||||
const data = await res.json()
|
||||
expect(data.error).toMatch(/already/)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid email', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, email: 'not-an-email' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for weak password (too short)', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, password: 'Short1!' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for empty name', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, name: '' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for malformed JSON', async () => {
|
||||
const req = new NextRequest(
|
||||
new Request('http://localhost/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: 'bad-json',
|
||||
})
|
||||
)
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user