- 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>
182 lines
6.3 KiB
TypeScript
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,
|
|
})
|
|
)
|
|
})
|
|
})
|