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 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, }) ) }) })