Shumiland/tests/api/leads.test.ts
Vadym Samoilenko 9b41fa447a
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions
feat: complete backend B1-B7 — Payload CMS, ezy payments, leads, deploy
- B1: Next.js 15 + Payload CMS 3.0 + Postgres 16, ESLint, Prettier, Husky, Vitest
- B2: 9 collections, 6 globals, 12 Page Builder blocks, access control, slugify/revalidate hooks
- B3: ezy.com.ua payments, Binotel HMAC webhook, leads API, Telegram bot, Resend email, rate limiting
- B4: Tariffs collection with ezy API sync (cron + manual), dynamic pricing source-of-truth
- B5: 13 test files covering unit libs and all API routes
- B6: Dockerfile multi-stage, docker-compose.prod.yml, nginx.conf SSL, GitHub Actions CI/CD, health endpoint
- B7: docs/admin-guide-ua.md (marketer guide), docs/deploy.md (VPS instructions), README quickstart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:14:54 +01:00

118 lines
3.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const mockPayloadCreate = vi.fn()
const mockGetPayload = vi.fn(() => ({ create: mockPayloadCreate }))
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
vi.mock('@payload-config', () => ({ default: {} }))
vi.mock('@/lib/telegram', () => ({ sendLeadAlert: vi.fn().mockResolvedValue(undefined) }))
vi.mock('@/lib/resend', () => ({ sendLeadAlert: vi.fn().mockResolvedValue(undefined) }))
const VALID_LEAD = {
name: 'Іван Іванов',
phone: '+380501234567',
formSource: 'hero-form',
}
function makeRequest(body: unknown, ip = '1.2.3.4'): NextRequest {
return new NextRequest('http://localhost/api/leads', {
method: 'POST',
body: typeof body === 'string' ? body : JSON.stringify(body),
headers: {
'content-type': 'application/json',
'x-forwarded-for': ip,
},
})
}
let POST: (req: NextRequest) => Promise<Response>
beforeEach(async () => {
vi.resetModules()
mockPayloadCreate.mockReset()
// Re-import so rateLimit buckets are fresh
const mod = await import('@/app/api/leads/route')
POST = mod.POST
})
describe('POST /api/leads', () => {
it('returns 201 for a valid lead', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-1' })
const res = await POST(makeRequest(VALID_LEAD))
expect(res.status).toBe(201)
expect((await res.json()).ok).toBe(true)
})
it('returns 400 for invalid JSON body', async () => {
const res = await POST(makeRequest('not-json'))
expect(res.status).toBe(400)
})
it('returns 422 when required fields are missing', async () => {
const res = await POST(makeRequest({ phone: '123' }))
expect(res.status).toBe(422)
const json = await res.json()
expect(json.error).toBe('Validation failed')
expect(json.details).toBeDefined()
})
it('returns 422 when name is empty string', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, name: '' }))
expect(res.status).toBe(422)
})
it('returns 422 when phone is too short', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, phone: '12345' }))
expect(res.status).toBe(422)
})
it('returns 422 when email is present but invalid', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, email: 'not-an-email' }))
expect(res.status).toBe(422)
})
it('accepts optional UTM params and email', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-2' })
const res = await POST(
makeRequest({
...VALID_LEAD,
email: 'test@example.com',
utmSource: 'google',
utmMedium: 'cpc',
utmCampaign: 'summer',
utmContent: 'banner',
utmTerm: 'shoes',
gclid: 'abc123',
})
)
expect(res.status).toBe(201)
})
it('returns 500 when DB create throws', async () => {
mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
const res = await POST(makeRequest(VALID_LEAD, '9.9.9.1'))
expect(res.status).toBe(500)
})
it('returns 429 after 5 requests from the same IP', async () => {
mockPayloadCreate.mockResolvedValue({ id: 'x' })
const ip = '5.5.5.5'
for (let i = 0; i < 5; i++) {
await POST(makeRequest(VALID_LEAD, ip))
}
const res = await POST(makeRequest(VALID_LEAD, ip))
expect(res.status).toBe(429)
})
it('uses "unknown" when x-forwarded-for header is absent', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-3' })
const req = new NextRequest('http://localhost/api/leads', {
method: 'POST',
body: JSON.stringify(VALID_LEAD),
headers: { 'content-type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(201)
})
})