""" 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