mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
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>
This commit is contained in:
405
tests/test_leads.py
Normal file
405
tests/test_leads.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user