- 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>
118 lines
3.7 KiB
TypeScript
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)
|
|
})
|
|
})
|