mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-13 08:03:54 +00:00
- 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>
406 lines
18 KiB
Python
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
|