Files
resolutionflow/backend/tests/test_sales_leads.py
Michael Chihlas 694279f89e feat(sales): add POST /sales-leads public endpoint
Phase 2 Task 29 — public Talk-to-Sales submission endpoint.

- New POST /api/v1/sales-leads (public, no auth, rate-limited 5/hour per IP).
- Inserts a sales_leads row, fires best-effort notification email and
  PostHog server-side capture; failures are logged but never fail the
  request.
- New EmailService.send_sales_lead_notification static method.
- New SALES_LEAD_RECIPIENT_EMAIL setting (defaults to sales@resolutionflow.com).
- Schemas: SalesLeadCreate / SalesLeadCreateResponse with literal source enum.
- Tests: happy path (row + email), email-failure resilience, and rate-limit
  enforcement (re-enables the slowapi limiter for the rate-limit assertion
  since DEBUG=true disables it by default in tests).

PostHog server-side instrumentation point is wired in but no-ops gracefully
until app.core.analytics.posthog exists — turning it on is a one-line
change when the backend SDK is configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 20:12:03 -04:00

135 lines
4.5 KiB
Python

"""Integration tests for the public Talk-to-Sales endpoint.
POST /api/v1/sales-leads — no auth, rate-limited 5/hour per IP.
"""
from unittest.mock import AsyncMock, patch
import pytest
import sqlalchemy as sa
@pytest.mark.asyncio
async def test_sales_lead_creates_row_and_sends_notification_email(client, test_db):
"""Happy path: row inserted, notification email fired, 201 returned."""
payload = {
"email": "buyer@acme.example",
"name": "Pat Buyer",
"company": "Acme MSP",
"team_size": "11-50",
"message": "We're evaluating ResolutionFlow for our NOC team.",
"source": "pricing_page",
"posthog_distinct_id": "ph_distinct_123",
}
with patch(
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
new=AsyncMock(return_value=True),
) as mock_email:
response = await client.post("/api/v1/sales-leads", json=payload)
assert response.status_code == 201, response.text
body = response.json()
assert body["status"] == "received"
assert "id" in body
# Notification email was attempted (asyncio.create_task — give it a tick).
import asyncio
await asyncio.sleep(0)
await asyncio.sleep(0)
assert mock_email.await_count == 1
kwargs = mock_email.await_args.kwargs
assert kwargs["to_email"] # default placeholder until cutover
assert kwargs["lead"].email == "buyer@acme.example"
assert kwargs["lead"].source == "pricing_page"
# Row was inserted with normalized email + all fields preserved.
result = await test_db.execute(
sa.text("SELECT email, name, company, team_size, message, source, posthog_distinct_id, status FROM sales_leads")
)
rows = result.all()
assert len(rows) == 1
row = rows[0]
assert row.email == "buyer@acme.example"
assert row.name == "Pat Buyer"
assert row.company == "Acme MSP"
assert row.team_size == "11-50"
assert row.message == "We're evaluating ResolutionFlow for our NOC team."
assert row.source == "pricing_page"
assert row.posthog_distinct_id == "ph_distinct_123"
assert row.status == "new"
@pytest.mark.asyncio
async def test_sales_lead_email_failure_does_not_fail_request(client, test_db):
"""If the email send raises, the API still returns 201 and the row persists."""
payload = {
"email": "buyer2@acme.example",
"name": "Sam Lead",
"company": "Acme MSP",
"source": "register_footer",
}
with patch(
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
new=AsyncMock(side_effect=RuntimeError("resend exploded")),
):
response = await client.post("/api/v1/sales-leads", json=payload)
assert response.status_code == 201, response.text
# Row must still be persisted even though email failed.
import asyncio
await asyncio.sleep(0)
result = await test_db.execute(
sa.text("SELECT count(*) FROM sales_leads WHERE email = 'buyer2@acme.example'")
)
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_sales_lead_rate_limited_after_5_per_hour(client):
"""The 6th submission within an hour from the same IP returns 429.
The default `limiter` is disabled in tests (DEBUG=true). We re-enable it
for this test, then reset its state on teardown so other tests aren't
affected.
"""
from app.core.rate_limit import limiter
was_enabled = limiter.enabled
limiter.enabled = True
try:
limiter.reset()
with patch(
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
new=AsyncMock(return_value=True),
):
for i in range(5):
payload = {
"email": f"lead{i}@acme.example",
"name": f"Lead {i}",
"company": "Acme MSP",
"source": "landing_page",
}
resp = await client.post("/api/v1/sales-leads", json=payload)
assert resp.status_code == 201, f"submission {i}: {resp.text}"
# 6th should be rate-limited.
resp = await client.post(
"/api/v1/sales-leads",
json={
"email": "lead6@acme.example",
"name": "Lead 6",
"company": "Acme MSP",
"source": "landing_page",
},
)
assert resp.status_code == 429, resp.text
finally:
limiter.reset()
limiter.enabled = was_enabled