Shumiland/tests/api/leads.test.ts
Vadym Samoilenko cca4ea1d55
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: implement full frontend — all sections, components, Figma Code Connect
- All 8 home page sections: Hero, Locations slider, WhyParents accordion,
  Birthday pricing cards, Video, Gallery, Reviews slider, News
- UI components: NavLink, BtnPrimary, BtnGradient, BtnDetails, AccordionItem
- Layout: sticky Header (NavLink + BtnPrimary), Footer with logo
- Figma Code Connect: 5 components published (.figma.tsx + figma.config.json)
- Public assets: all Figma images and SVGs exported
- Pages: /kvytky, /lokatsii, /blog, /dni-narodzhennia, /grupovi-vidviduvannia
- Tests: Vitest unit/api suites, Playwright e2e screenshots
- Payload CMS: blocks, collections, seed data updates
- Hero negative-margin to extend behind sticky header
- Custom Tailwind breakpoints: lg=1440px, xl=1920px
- Fix ESLint config: drop FlatCompat, use eslint-config-next flat export
- Add tsconfig.tsbuildinfo, test-results/, agentdb.rvf* to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:40:56 +01:00

182 lines
6.3 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)
})
it('accepts a phone number containing valid special characters (+, spaces, dashes, parens)', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-special' })
const res = await POST(makeRequest({ ...VALID_LEAD, phone: '+38 (050) 123-45-67' }, '7.7.7.1'))
expect(res.status).toBe(201)
})
it('returns 422 when formSource is an empty string', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, formSource: '' }, '7.7.7.2'))
expect(res.status).toBe(422)
})
it('returns 422 when phone contains letters', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, phone: '+380abc234567' }, '7.7.7.3'))
expect(res.status).toBe(422)
})
it('accepts name at exactly 100 characters (boundary)', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-boundary' })
const res = await POST(makeRequest({ ...VALID_LEAD, name: 'A'.repeat(100) }, '7.7.7.4'))
expect(res.status).toBe(201)
})
it('returns 422 when name exceeds 100 characters', async () => {
const res = await POST(makeRequest({ ...VALID_LEAD, name: 'A'.repeat(101) }, '7.7.7.5'))
expect(res.status).toBe(422)
})
it('does NOT fire alerts when DB create throws (early return before allSettled)', async () => {
const { sendLeadAlert: telegramAlert } = await import('@/lib/telegram')
const { sendLeadAlert: emailAlert } = await import('@/lib/resend')
vi.mocked(telegramAlert).mockClear()
vi.mocked(emailAlert).mockClear()
mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
await POST(makeRequest(VALID_LEAD, '7.7.7.6'))
expect(vi.mocked(telegramAlert)).not.toHaveBeenCalled()
expect(vi.mocked(emailAlert)).not.toHaveBeenCalled()
})
it('fires Telegram and email alerts with the correct lead data after a successful save', async () => {
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-alert' })
await POST(makeRequest({ ...VALID_LEAD, utmSource: 'google' }, '8.8.8.8'))
const { sendLeadAlert: telegramAlert } = await import('@/lib/telegram')
const { sendLeadAlert: emailAlert } = await import('@/lib/resend')
expect(vi.mocked(telegramAlert)).toHaveBeenCalledWith(
expect.objectContaining({
name: VALID_LEAD.name,
phone: VALID_LEAD.phone,
formSource: VALID_LEAD.formSource,
utmSource: 'google',
})
)
expect(vi.mocked(emailAlert)).toHaveBeenCalledWith(
expect.objectContaining({
name: VALID_LEAD.name,
phone: VALID_LEAD.phone,
formSource: VALID_LEAD.formSource,
})
)
})
})