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:
453
tests/test_campaigns.py
Normal file
453
tests/test_campaigns.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user