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:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

453
tests/test_campaigns.py Normal file
View 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