Files
contexta_be/tests/test_leads.py
belviskhoremk 92d4c2fc5e feat: add appointments, campaigns, admin, storage, tests and various updates
- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:11:58 +00:00

406 lines
18 KiB
Python

"""
Tests for lead endpoints:
GET /api/v1/leads
PATCH /api/v1/leads/{id}
GET /api/v1/leads/export
POST /api/v1/chatbots/{chatbot_id}/leads (public)
"""
import pytest
from unittest.mock import MagicMock, patch
# ── Helpers ────────────────────────────────────────────────────────────────────
def make_user(uid="user-1"):
u = MagicMock()
u.id = uid
u.email = "user@example.com"
return u
SAMPLE_LEAD = {
"id": "lead-1",
"chatbot_id": "chatbot-1",
"conversation_id": None,
"email": "lead@example.com",
"name": "Jane Doe",
"phone": "+1234",
"company": "ACME",
"status": "new",
"notes": None,
"created_at": "2024-01-01T00:00:00",
}
def make_supabase(plan="starter", company_id="company-1",
leads=None, lead=None, chatbot_enabled=True):
sb = MagicMock()
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.insert = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.delete = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
t.range = MagicMock(return_value=t)
t.limit = MagicMock(return_value=t)
t.neq = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "company_id": company_id,
"lead_capture_enabled": chatbot_enabled}
]))
elif name == "leads":
if lead is not None:
t.execute = MagicMock(return_value=MagicMock(data=[lead]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=leads if leads is not None else [SAMPLE_LEAD]
))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list leads ─────────────────────────────────────────────────────────
class TestListLeads:
def test_requires_auth(self, client):
resp = client.get("/api/v1/leads")
assert resp.status_code == 401
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads", headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_lead_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads", headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["email"] == "lead@example.com"
assert data[0]["status"] == "new"
def test_returns_empty_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads", headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
def test_pagination_params(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads?page=2&limit=10",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# ── Tests: update lead ─────────────────────────────────────────────────────────
class TestUpdateLead:
def _make_owned_lead(self, company_id="company-1", status="new"):
return {**SAMPLE_LEAD, "status": status,
"chatbots": {"company_id": company_id}}
def test_requires_auth(self, client):
resp = client.patch("/api/v1/leads/lead-1", json={"status": "contacted"})
assert resp.status_code == 401
def test_update_status(self, client):
user = make_user()
owned_lead = self._make_owned_lead()
updated_lead = {**SAMPLE_LEAD, "status": "contacted"}
call_count = {"n": 0}
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
# First call: ownership check (select with chatbots join)
t.execute = MagicMock(return_value=MagicMock(data=[owned_lead]))
else:
# Second call: update returns updated row
t.execute = MagicMock(return_value=MagicMock(data=[updated_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": "contacted"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["status"] == "contacted"
def test_update_notes(self, client):
user = make_user()
owned_lead = self._make_owned_lead()
updated_lead = {**SAMPLE_LEAD, "notes": "Called on Monday"}
call_count = {"n": 0}
def table_side(name):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "business"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[owned_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[updated_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"notes": "Called on Monday"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["notes"] == "Called on Monday"
def test_invalid_status_returns_400(self, client):
user = make_user()
owned_lead = self._make_owned_lead()
sb = make_supabase(plan="starter", lead=owned_lead)
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": "banana"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_valid_statuses_accepted(self, client):
for status in ("new", "contacted", "qualified", "closed", "lost"):
user = make_user()
owned_lead = self._make_owned_lead()
updated = {**SAMPLE_LEAD, "status": status}
call_count = {"n": 0}
def table_side(name, _status=status):
t = MagicMock()
t.select = MagicMock(return_value=t)
t.update = MagicMock(return_value=t)
t.eq = MagicMock(return_value=t)
t.in_ = MagicMock(return_value=t)
t.order = MagicMock(return_value=t)
if name == "subscriptions":
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
elif name == "companies":
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
elif name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[owned_lead]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=[{**SAMPLE_LEAD, "status": _status}]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": status},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200, f"Failed for status={status}"
def test_not_found_returns_404(self, client):
user = make_user()
sb = make_supabase(plan="starter", leads=[])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "leads":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/nonexistent",
json={"status": "contacted"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_403_for_other_companys_lead(self, client):
user = make_user()
owned_lead = {**SAMPLE_LEAD, "chatbots": {"company_id": "company-OTHER"}}
sb = make_supabase(plan="starter", company_id="company-1", lead=owned_lead)
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.patch("/api/v1/leads/lead-1",
json={"status": "contacted"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
# ── Tests: export leads CSV ────────────────────────────────────────────────────
class TestExportLeads:
def test_requires_auth(self, client):
resp = client.get("/api/v1/leads/export")
assert resp.status_code == 401
def test_returns_csv_content_type(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads/export",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert "text/csv" in resp.headers["content-type"]
def test_csv_contains_headers_and_data(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads/export",
headers={"Authorization": "Bearer tok"})
text = resp.text
assert "email" in text
assert "lead@example.com" in text
def test_empty_csv_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_current_user", return_value=user), \
patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.get("/api/v1/leads/export",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# Only the header row
lines = [l for l in resp.text.strip().split("\n") if l]
assert len(lines) == 1
# ── Tests: public lead submission ─────────────────────────────────────────────
class TestPublicLeadSubmit:
def test_submit_lead_success(self, client):
sb = make_supabase(chatbot_enabled=True)
# No existing duplicate
original = sb.table.side_effect
call_count = {"n": 0}
def patched(name):
t = original(name)
if name == "leads":
call_count["n"] += 1
if call_count["n"] == 1:
# dedup check — no existing
t.execute = MagicMock(return_value=MagicMock(data=[]))
else:
# insert result
t.execute = MagicMock(return_value=MagicMock(data=[SAMPLE_LEAD]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/leads",
json={"email": "lead@example.com", "name": "Jane Doe"})
assert resp.status_code == 201
assert resp.json()["email"] == "lead@example.com"
def test_returns_existing_lead_on_duplicate_email(self, client):
"""Deduplication: same email + chatbot_id returns existing row."""
existing = {**SAMPLE_LEAD, "id": "lead-existing"}
sb = make_supabase(chatbot_enabled=True)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "leads":
t.execute = MagicMock(return_value=MagicMock(data=[existing]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/leads",
json={"email": "lead@example.com", "name": "Jane"})
assert resp.status_code == 201
assert resp.json()["id"] == "lead-existing"
def test_404_when_chatbot_not_found(self, client):
sb = make_supabase()
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/nonexistent/leads",
json={"email": "a@b.com"})
assert resp.status_code == 404
def test_400_when_lead_capture_disabled(self, client):
sb = make_supabase(chatbot_enabled=False)
with patch("app.routers.leads.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/leads",
json={"email": "a@b.com"})
assert resp.status_code == 400