""" Tests for campaign endpoints: GET /api/v1/campaigns POST /api/v1/campaigns POST /api/v1/campaigns/{id}/send DELETE /api/v1/campaigns/{id} """ import pytest from unittest.mock import MagicMock, patch, AsyncMock # ── Helpers ──────────────────────────────────────────────────────────────────── def make_user(uid="user-1"): u = MagicMock() u.id = uid u.email = "user@example.com" return u SAMPLE_CAMPAIGN = { "id": "camp-1", "chatbot_id": "chatbot-1", "title": "Summer Sale", "message": "Big discount today!", "status": "draft", "recipients_count": 10, "sent_count": 0, "created_at": "2024-01-01T00:00:00", "sent_at": None, } def make_supabase(plan="starter", company_id="company-1", campaigns=None, campaign=None, subscribers_count=10): sb = MagicMock() def table_side(name): t = MagicMock() for m in ("select", "insert", "update", "delete", "eq", "in_", "order", "range", "limit", "neq"): getattr(t, m).return_value = t t.count = subscribers_count 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} ])) elif name == "campaigns": if campaign is not None: t.execute = MagicMock(return_value=MagicMock(data=[campaign])) else: t.execute = MagicMock(return_value=MagicMock( data=campaigns if campaigns is not None else [SAMPLE_CAMPAIGN] )) elif name == "channel_connections": t.execute = MagicMock(return_value=MagicMock(data=[ {"bot_token": "123456:ABCdef"} ])) elif name == "channel_sessions": mock_result = MagicMock() mock_result.data = [ {"external_id": "tg:123456:111"}, {"external_id": "tg:123456:222"}, ] mock_result.count = subscribers_count t.execute = MagicMock(return_value=mock_result) else: t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = table_side return sb # ── Tests: list campaigns ────────────────────────────────────────────────────── class TestListCampaigns: def test_requires_auth(self, client): resp = client.get("/api/v1/campaigns") 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.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.get("/api/v1/campaigns", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 402 def test_returns_campaign_list(self, client): user = make_user() sb = make_supabase(plan="starter") with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.get("/api/v1/campaigns", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["title"] == "Summer Sale" assert data[0]["status"] == "draft" def test_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.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.get("/api/v1/campaigns", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json() == [] def test_pagination_accepted(self, client): user = make_user() sb = make_supabase(plan="starter") with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.get("/api/v1/campaigns?page=2&limit=5", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 # ── Tests: create campaign ───────────────────────────────────────────────────── class TestCreateCampaign: def test_requires_auth(self, client): resp = client.post("/api/v1/campaigns", json={"chatbot_id": "chatbot-1", "title": "X", "message": "Y"}) assert resp.status_code == 401 def test_creates_campaign_draft(self, client): user = make_user() new_camp = {**SAMPLE_CAMPAIGN, "id": "camp-new"} sb = make_supabase(plan="starter") original = sb.table.side_effect def patched(name): t = original(name) if name == "campaigns": t.execute = MagicMock(return_value=MagicMock(data=[new_camp])) return t sb.table.side_effect = patched with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns", json={"chatbot_id": "chatbot-1", "title": "Summer Sale", "message": "Big discount today!"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 201 data = resp.json() assert data["status"] == "draft" assert data["id"] == "camp-new" def test_missing_fields_returns_422(self, client): user = make_user() sb = make_supabase(plan="starter") with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns", json={"chatbot_id": "chatbot-1"}, # missing title/message headers={"Authorization": "Bearer tok"}) assert resp.status_code == 422 def test_404_when_chatbot_not_owned(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.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns", json={"chatbot_id": "stranger-bot", "title": "X", "message": "Y"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 404 def test_recipients_count_uses_subscriber_count(self, client): """recipients_count should equal the number of Telegram channel_sessions.""" user = make_user() inserted = [] sb = make_supabase(plan="starter", subscribers_count=42) original = sb.table.side_effect def patched(name): t = original(name) if name == "campaigns": orig_insert = t.insert def track_insert(data): inserted.append(data) return t t.insert = track_insert t.execute = MagicMock(return_value=MagicMock(data=[{**SAMPLE_CAMPAIGN, "recipients_count": 42}])) return t sb.table.side_effect = patched with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns", json={"chatbot_id": "chatbot-1", "title": "T", "message": "M"}, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 201 assert resp.json()["recipients_count"] == 42 # ── Tests: send campaign ─────────────────────────────────────────────────────── class TestSendCampaign: def _make_owned_campaign(self, company_id="company-1", status="draft"): return {**SAMPLE_CAMPAIGN, "status": status, "chatbots": {"company_id": company_id}} def test_requires_auth(self, client): resp = client.post("/api/v1/campaigns/camp-1/send") assert resp.status_code == 401 def test_sends_to_subscribers(self, client): user = make_user() camp = self._make_owned_campaign() sent_camp = {**SAMPLE_CAMPAIGN, "status": "sent", "sent_count": 2} call_count = {"n": 0} def table_side(name): t = MagicMock() for m in ("select", "insert", "update", "delete", "eq", "in_", "order", "range", "limit", "neq"): getattr(t, m).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 == "campaigns": call_count["n"] += 1 if call_count["n"] == 1: t.execute = MagicMock(return_value=MagicMock(data=[camp])) else: t.execute = MagicMock(return_value=MagicMock(data=[sent_camp])) elif name == "channel_connections": t.execute = MagicMock(return_value=MagicMock(data=[ {"bot_token": "123456:ABCdef"} ])) elif name == "channel_sessions": t.execute = MagicMock(return_value=MagicMock(data=[ {"external_id": "tg:123456:111"}, {"external_id": "tg:123456:222"}, ])) else: t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb = MagicMock() sb.table.side_effect = table_side with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb), \ patch("app.services.telegram_service.send_message", new_callable=AsyncMock) as mock_send: mock_send.return_value = None resp = client.post("/api/v1/campaigns/camp-1/send", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["status"] == "sent" assert mock_send.call_count == 2 def test_already_sent_returns_400(self, client): user = make_user() camp = self._make_owned_campaign(status="sent") sb = make_supabase(plan="starter", campaign=camp) with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns/camp-1/send", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 400 assert "already sent" in resp.json()["detail"].lower() def test_400_when_no_telegram_connection(self, client): user = make_user() camp = self._make_owned_campaign() sb = make_supabase(plan="starter", campaign=camp) original = sb.table.side_effect def patched(name): t = original(name) if name == "channel_connections": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns/camp-1/send", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 400 assert "telegram" in resp.json()["detail"].lower() def test_403_for_other_companys_campaign(self, client): user = make_user() camp = self._make_owned_campaign(company_id="company-OTHER") sb = make_supabase(plan="starter", company_id="company-1", campaign=camp) with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns/camp-1/send", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 def test_404_when_campaign_not_found(self, client): user = make_user() sb = make_supabase(plan="starter") original = sb.table.side_effect def patched(name): t = original(name) if name == "campaigns": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.post("/api/v1/campaigns/nonexistent/send", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 404 def test_partial_failures_do_not_crash(self, client): """If some subscribers fail, the campaign still completes.""" user = make_user() camp = self._make_owned_campaign() sent_camp = {**SAMPLE_CAMPAIGN, "status": "sent", "sent_count": 1} call_count = {"n": 0} def table_side(name): t = MagicMock() for m in ("select", "insert", "update", "delete", "eq", "in_", "order", "range", "limit", "neq"): getattr(t, m).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 == "campaigns": call_count["n"] += 1 if call_count["n"] == 1: t.execute = MagicMock(return_value=MagicMock(data=[camp])) else: t.execute = MagicMock(return_value=MagicMock(data=[sent_camp])) elif name == "channel_connections": t.execute = MagicMock(return_value=MagicMock(data=[ {"bot_token": "123456:ABCdef"} ])) elif name == "channel_sessions": t.execute = MagicMock(return_value=MagicMock(data=[ {"external_id": "tg:123456:111"}, {"external_id": "bad-format"}, # malformed — should be skipped ])) else: t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb = MagicMock() sb.table.side_effect = table_side with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb), \ patch("app.services.telegram_service.send_message", new_callable=AsyncMock) as mock_send: mock_send.return_value = None resp = client.post("/api/v1/campaigns/camp-1/send", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["status"] == "sent" # ── Tests: delete campaign ───────────────────────────────────────────────────── class TestDeleteCampaign: def _make_owned_campaign(self, company_id="company-1", status="draft"): return {**SAMPLE_CAMPAIGN, "status": status, "chatbots": {"company_id": company_id}} def test_requires_auth(self, client): resp = client.delete("/api/v1/campaigns/camp-1") assert resp.status_code == 401 def test_deletes_draft_campaign(self, client): user = make_user() camp = self._make_owned_campaign(status="draft") sb = make_supabase(plan="starter", campaign=camp) with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.delete("/api/v1/campaigns/camp-1", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["success"] is True def test_deletes_sent_campaign(self, client): user = make_user() camp = self._make_owned_campaign(status="sent") sb = make_supabase(plan="starter", campaign=camp) with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.delete("/api/v1/campaigns/camp-1", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 def test_cannot_delete_sending_campaign(self, client): user = make_user() camp = self._make_owned_campaign(status="sending") sb = make_supabase(plan="starter", campaign=camp) with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.delete("/api/v1/campaigns/camp-1", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 400 def test_403_for_other_companys_campaign(self, client): user = make_user() camp = self._make_owned_campaign(company_id="company-OTHER") sb = make_supabase(plan="starter", company_id="company-1", campaign=camp) with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.delete("/api/v1/campaigns/camp-1", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 403 def test_404_when_not_found(self, client): user = make_user() sb = make_supabase(plan="starter") original = sb.table.side_effect def patched(name): t = original(name) if name == "campaigns": t.execute = MagicMock(return_value=MagicMock(data=[])) return t sb.table.side_effect = patched with patch("app.routers.campaigns.get_current_user", return_value=user), \ patch("app.routers.campaigns.get_supabase", return_value=sb): resp = client.delete("/api/v1/campaigns/nonexistent", headers={"Authorization": "Bearer tok"}) assert resp.status_code == 404