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

0
tests/__init__.py Normal file
View File

77
tests/conftest.py Normal file
View File

@@ -0,0 +1,77 @@
"""
Test fixtures for Contexta backend.
Uses unittest.mock to avoid hitting real Supabase/Qdrant in unit tests.
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
@pytest.fixture
def client():
"""FastAPI test client. Patches Supabase and Qdrant at module level."""
with patch("app.database.get_supabase") as mock_sb, \
patch("app.services.vector_store.vector_store") as mock_vs:
mock_sb.return_value = _make_supabase_mock()
mock_vs.create_collection = MagicMock()
mock_vs.delete_collection = MagicMock()
mock_vs.search = MagicMock(return_value=[])
from app.main import app
yield TestClient(app)
@pytest.fixture
def supabase_mock():
"""Standalone Supabase mock for direct use."""
return _make_supabase_mock()
@pytest.fixture
def auth_headers():
"""Mock auth headers (token is verified by patching get_current_user)."""
return {"Authorization": "Bearer test-token"}
@pytest.fixture
def mock_current_user():
"""A mock Supabase auth user object."""
user = MagicMock()
user.id = "test-user-id"
user.email = "test@example.com"
return user
@pytest.fixture
def mock_admin_user():
"""A mock admin auth user object."""
user = MagicMock()
user.id = "admin-user-id"
user.email = "admin@example.com"
return user
def _make_supabase_mock():
"""Build a chainable Supabase client mock."""
supabase = MagicMock()
# Default table chain returns empty data
table_mock = MagicMock()
table_mock.select.return_value = table_mock
table_mock.insert.return_value = table_mock
table_mock.update.return_value = table_mock
table_mock.delete.return_value = table_mock
table_mock.upsert.return_value = table_mock
table_mock.eq.return_value = table_mock
table_mock.in_.return_value = table_mock
table_mock.limit.return_value = table_mock
table_mock.order.return_value = table_mock
table_mock.range.return_value = table_mock
table_mock.gte.return_value = table_mock
table_mock.lt.return_value = table_mock
table_mock.execute.return_value = MagicMock(data=[], count=0)
supabase.table.return_value = table_mock
supabase.auth = MagicMock()
return supabase

94
tests/test_admin.py Normal file
View File

@@ -0,0 +1,94 @@
"""Tests for admin endpoints — access control and basic structure."""
import pytest
from unittest.mock import MagicMock, patch
class TestAdminAccessControl:
"""Admin endpoints must return 401 without auth and 403 for non-admin users."""
ADMIN_ENDPOINTS = [
("GET", "/api/v1/admin/stats"),
("GET", "/api/v1/admin/users"),
("GET", "/api/v1/admin/chatbots"),
("GET", "/api/v1/admin/conversations"),
("GET", "/api/v1/admin/system/health"),
]
def test_admin_endpoints_require_auth(self, client):
for method, path in self.ADMIN_ENDPOINTS:
resp = client.request(method, path)
assert resp.status_code == 401, f"{method} {path} should require auth, got {resp.status_code}"
def test_non_admin_user_gets_403(self, client):
"""Authenticated user without is_admin flag should get 403."""
user = MagicMock()
user.id = "normal-user-id"
user.email = "user@example.com"
with patch("app.dependencies.get_supabase") as mock_sb:
sb = MagicMock()
# get_current_user: no suspension
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"suspended_at": None, "is_admin": False}]
)
mock_sb.return_value = sb
with patch("app.dependencies.security") as mock_sec:
creds = MagicMock()
creds.credentials = "valid-token"
mock_sec.return_value = creds
with patch("app.database.get_supabase") as mock_db_sb:
db_sb = MagicMock()
db_sb.auth.get_user.return_value = MagicMock(user=user)
# Profile: not admin, not suspended
db_sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"is_admin": False, "suspended_at": None}]
)
mock_db_sb.return_value = db_sb
resp = client.get(
"/api/v1/admin/stats",
headers={"Authorization": "Bearer valid-token"},
)
assert resp.status_code in (401, 403)
class TestAdminModels:
def test_admin_stats_response_shape(self):
from app.models import AdminStatsResponse
stats = AdminStatsResponse(
total_users=10,
total_chatbots=5,
total_published_chatbots=3,
total_conversations=100,
total_messages=500,
active_subscriptions={"free": 8, "starter": 2},
)
assert stats.total_users == 10
assert stats.active_subscriptions["free"] == 8
def test_admin_user_list_item_defaults(self):
from app.models import AdminUserListItem
item = AdminUserListItem(id="id", email="test@example.com")
assert item.plan == "free"
assert item.is_admin == False
assert item.is_suspended == False
def test_admin_system_health_shape(self):
from app.models import AdminSystemHealth
from datetime import datetime
health = AdminSystemHealth(
db="healthy",
qdrant="healthy",
llm_providers={"openai": True, "anthropic": False},
timestamp=datetime.utcnow(),
)
assert health.db == "healthy"
assert health.llm_providers["openai"] == True
def test_change_plan_request_validates(self):
from app.models import AdminChangePlanRequest
req = AdminChangePlanRequest(plan="business", reason="Testing")
assert req.plan == "business"

255
tests/test_analytics.py Normal file
View File

@@ -0,0 +1,255 @@
"""Tests for analytics endpoints."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
def _make_sb_with_plan(plan: str):
sb = MagicMock()
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
m.execute.return_value = MagicMock(data=[{"plan": plan}], count=0)
sb.table.return_value = m
sb.auth = MagicMock()
return sb
def _make_starter_sb(company_data=None, chatbot_data=None):
"""Starter plan supabase mock with configurable data."""
sb = MagicMock()
def table_side_effect(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}], count=0)
elif name == "companies":
m.execute.return_value = MagicMock(
data=company_data if company_data is not None else [{"id": "company-1"}],
count=0,
)
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=chatbot_data if chatbot_data is not None else [],
count=0,
)
elif name == "conversations":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "message_feedback":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side_effect
sb.auth = MagicMock()
return sb
class TestAnalyticsAuth:
def test_overview_requires_auth(self, client):
resp = client.get("/api/v1/analytics/overview")
assert resp.status_code == 401
def test_chatbot_detail_requires_auth(self, client):
resp = client.get("/api/v1/analytics/chatbot/some-id")
assert resp.status_code == 401
def test_gaps_requires_auth(self, client):
resp = client.get("/api/v1/analytics/chatbot/some-id/gaps")
assert resp.status_code == 401
class TestAnalyticsPlanGating:
def test_free_plan_blocked_on_overview(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 402
assert "Starter" in resp.json()["detail"]
def test_free_plan_blocked_on_chatbot_detail(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/analytics/chatbot/some-id", headers=AUTH)
assert resp.status_code == 402
def test_free_plan_blocked_on_gaps(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/analytics/chatbot/some-id/gaps", headers=AUTH)
assert resp.status_code == 402
class TestAnalyticsOverview:
def test_overview_no_company_returns_404(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_starter_sb(company_data=[])
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 404
def test_overview_no_chatbots_returns_empty(self, client):
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_starter_sb(chatbot_data=[])
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["total_chatbots"] == 0
assert body["total_conversations"] == 0
assert body["chatbots"] == []
assert body["plan"] == "starter"
def test_overview_with_chatbots(self, client):
chatbot_data = [{"id": "cb-1", "name": "Bot One", "is_published": True, "average_rating": 4.5}]
with patch("app.routers.analytics.get_supabase") as mock_sb:
mock_sb.return_value = _make_starter_sb(chatbot_data=chatbot_data)
resp = client.get("/api/v1/analytics/overview", headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["total_chatbots"] == 1
assert body["published_chatbots"] == 1
assert len(body["chatbots"]) == 1
assert body["chatbots"][0]["chatbot_name"] == "Bot One"
class TestAnalyticsChatbotDetail:
def test_chatbot_not_found_returns_404(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[])
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/nonexistent-id", headers=AUTH)
assert resp.status_code == 404
def test_chatbot_detail_success(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[{"id": "cb-1", "name": "Bot", "average_rating": 4.0}])
elif name == "conversations":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "message_feedback":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/cb-1", headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["chatbot_name"] == "Bot"
class TestAnalyticsGaps:
def test_gaps_no_company_returns_404(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
else:
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/some-id/gaps", headers=AUTH)
assert resp.status_code == 404
def test_gaps_no_conversations_returns_empty(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[{"id": "cb-1"}])
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.analytics.get_supabase", return_value=sb):
resp = client.get("/api/v1/analytics/chatbot/cb-1/gaps", headers=AUTH)
assert resp.status_code == 200
assert resp.json() == []

554
tests/test_appointments.py Normal file
View File

@@ -0,0 +1,554 @@
"""
Tests for appointment endpoints:
GET /api/v1/appointments
PATCH /api/v1/appointments/{id}
GET /api/v1/appointments/chatbot/{chatbot_id}/hours
PUT /api/v1/appointments/chatbot/{chatbot_id}/hours
GET /api/v1/chatbots/{chatbot_id}/booking-info (public)
GET /api/v1/chatbots/{chatbot_id}/available-slots (public)
POST /api/v1/chatbots/{chatbot_id}/appointments (public)
"""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, timedelta, date
# ── Helpers ────────────────────────────────────────────────────────────────────
def make_user(uid="user-1"):
u = MagicMock()
u.id = uid
u.email = "user@example.com"
return u
SAMPLE_APPT = {
"id": "appt-1",
"chatbot_id": "chatbot-1",
"conversation_id": None,
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"service": "Consultation",
"slot_start": "2099-06-10T09:00:00",
"slot_end": "2099-06-10T10:00:00",
"status": "pending",
"notes": None,
"created_at": "2024-01-01T00:00:00",
}
SAMPLE_HOURS = {
"id": "bh-1",
"chatbot_id": "chatbot-1",
"day_of_week": 0, # Monday
"is_open": True,
"open_time": "09:00",
"close_time": "17:00",
"slot_duration_minutes": 60,
}
def make_supabase(plan="starter", company_id="company-1",
appointments=None, appointment=None,
hours=None, chatbot_booking=True):
sb = MagicMock()
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete", "eq", "neq",
"in_", "order", "range", "limit", "gte", "lt"):
getattr(t, m).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,
"booking_enabled": chatbot_booking,
"is_published": True,
}]))
elif name == "appointments":
if appointment is not None:
t.execute = MagicMock(return_value=MagicMock(data=[appointment]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=appointments if appointments is not None else [SAMPLE_APPT]
))
elif name == "business_hours":
rows = hours if hours is not None else [SAMPLE_HOURS]
t.execute = MagicMock(return_value=MagicMock(data=rows))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list appointments ───────────────────────────────────────────────────
class TestListAppointments:
def test_requires_auth(self, client):
resp = client.get("/api/v1/appointments")
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.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_appointment_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["customer_name"] == "Alice"
assert data[0]["status"] == "pending"
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.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
# ── Tests: update appointment status ──────────────────────────────────────────
class TestUpdateAppointmentStatus:
def _owned_appt(self, company_id="company-1", status="pending"):
return {**SAMPLE_APPT, "status": status,
"chatbots": {"company_id": company_id}}
def test_requires_auth(self, client):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": "confirmed"})
assert resp.status_code == 401
def test_valid_status_transitions(self, client):
for new_status in ("pending", "confirmed", "cancelled", "completed"):
user = make_user()
appt = self._owned_appt()
updated = {**SAMPLE_APPT, "status": new_status}
call_count = {"n": 0}
def table_side(name, _ns=new_status):
t = MagicMock()
for m in ("select", "insert", "update", "delete",
"eq", "neq", "in_", "order", "range", "limit"):
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 == "appointments":
call_count["n"] += 1
if call_count["n"] == 1:
t.execute = MagicMock(return_value=MagicMock(data=[appt]))
else:
t.execute = MagicMock(return_value=MagicMock(
data=[{**SAMPLE_APPT, "status": _ns}]))
elif name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "company_id": "company-1"}
]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb = MagicMock()
sb.table.side_effect = table_side
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": new_status},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200, f"Failed for status={new_status}"
def test_invalid_status_returns_400(self, client):
user = make_user()
appt = self._owned_appt()
sb = make_supabase(plan="starter", appointment=appt)
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": "flying"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_403_for_other_companys_appointment(self, client):
user = make_user()
appt = self._owned_appt(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", appointment=appt)
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/appt-1",
json={"status": "confirmed"},
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 == "appointments":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.patch("/api/v1/appointments/nonexistent",
json={"status": "confirmed"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
# ── Tests: business hours ──────────────────────────────────────────────────────
class TestBusinessHours:
def test_get_hours_requires_auth(self, client):
resp = client.get("/api/v1/appointments/chatbot/chatbot-1/hours")
assert resp.status_code == 401
def test_get_hours_returns_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/appointments/chatbot/chatbot-1/hours",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["day_of_week"] == 0
assert data[0]["open_time"] == "09:00"
def test_save_hours_requires_auth(self, client):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": []})
assert resp.status_code == 401
def test_save_hours_success(self, client):
user = make_user()
sb = make_supabase(plan="starter")
hours_payload = [
{"day_of_week": 0, "is_open": True, "open_time": "09:00",
"close_time": "17:00", "slot_duration_minutes": 60},
{"day_of_week": 6, "is_open": False, "open_time": "09:00",
"close_time": "17:00", "slot_duration_minutes": 60},
]
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": hours_payload},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_save_hours_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": []},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_save_hours_inserts_when_not_exists(self, client):
"""When no existing row, should INSERT."""
user = make_user()
sb = make_supabase(plan="starter", hours=[]) # no existing hours
insert_calls = []
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "business_hours":
orig_insert = t.insert
def track_insert(data):
insert_calls.append(data)
return t
t.insert = track_insert
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_current_user", return_value=user), \
patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.put("/api/v1/appointments/chatbot/chatbot-1/hours",
json={"hours": [
{"day_of_week": 1, "is_open": True,
"open_time": "08:00", "close_time": "16:00",
"slot_duration_minutes": 30}
]},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert len(insert_calls) == 1
assert insert_calls[0]["day_of_week"] == 1
assert "id" in insert_calls[0]
# ── Tests: public booking-info ─────────────────────────────────────────────────
class TestPublicBookingInfo:
def test_returns_booking_info(self, client):
sb = make_supabase(chatbot_booking=True)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1",
"name": "Support Bot",
"booking_enabled": True,
"companies": {"name": "ACME Corp"},
}]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/booking-info")
assert resp.status_code == 200
body = resp.json()
assert body["chatbot_name"] == "Support Bot"
assert body["company_name"] == "ACME Corp"
assert body["chatbot_id"] == "chatbot-1"
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.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/nonexistent/booking-info")
assert resp.status_code == 404
def test_400_when_booking_disabled(self, client):
sb = make_supabase(chatbot_booking=False)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1", "name": "Bot", "booking_enabled": False,
"companies": {"name": "ACME"},
}]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/booking-info")
assert resp.status_code == 400
# ── Tests: public available-slots ─────────────────────────────────────────────
class TestAvailableSlots:
def test_returns_slots_for_open_day(self, client):
sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS])
# No booked appointments
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
# Use a far-future Monday so none are "past"
future_monday = "2099-06-09" # a Monday
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get(f"/api/v1/chatbots/chatbot-1/available-slots?date={future_monday}")
assert resp.status_code == 200
body = resp.json()
assert body["date"] == future_monday
# 09:00-17:00 with 60-min slots = 8 slots
assert len(body["slots"]) == 8
def test_returns_empty_for_closed_day(self, client):
closed_hours = {**SAMPLE_HOURS, "is_open": False}
sb = make_supabase(chatbot_booking=True, hours=[closed_hours])
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 200
assert resp.json()["slots"] == []
def test_returns_empty_when_no_hours_configured(self, client):
sb = make_supabase(chatbot_booking=True, hours=[])
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 200
assert resp.json()["slots"] == []
def test_booked_slots_excluded(self, client):
"""A slot that is already booked should not appear."""
sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
# 09:00 is already booked
t.execute = MagicMock(return_value=MagicMock(data=[
{"slot_start": "2099-06-09T09:00:00",
"slot_end": "2099-06-09T10:00:00"}
]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 200
slots = resp.json()["slots"]
starts = [s["slot_start"] for s in slots]
assert not any("T09:00:00" in s for s in starts)
def test_invalid_date_format_returns_400(self, client):
sb = make_supabase(chatbot_booking=True)
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=not-a-date")
assert resp.status_code == 400
def test_booking_disabled_returns_400(self, client):
sb = make_supabase(chatbot_booking=False)
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[
{"id": "chatbot-1", "booking_enabled": False, "is_published": True}
]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert resp.status_code == 400
def test_30_min_slots_yield_correct_count(self, client):
"""09:00-11:00 with 30-min slots should yield 4 slots."""
short_hours = {**SAMPLE_HOURS, "close_time": "11:00", "slot_duration_minutes": 30}
sb = make_supabase(chatbot_booking=True, hours=[short_hours])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.get("/api/v1/chatbots/chatbot-1/available-slots?date=2099-06-09")
assert len(resp.json()["slots"]) == 4
# ── Tests: public create appointment ──────────────────────────────────────────
class TestCreateAppointment:
def _sb_with_open_slot(self, slot_start="2099-06-09T09:00:00"):
"""Supabase mock that returns one available slot matching slot_start."""
insert_result = {**SAMPLE_APPT, "slot_start": slot_start}
call_count = {"n": 0}
sb = MagicMock()
def table_side(name):
t = MagicMock()
for m in ("select", "insert", "update", "delete", "eq", "neq",
"in_", "order", "range", "limit", "gte", "lt"):
getattr(t, m).return_value = t
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[{
"id": "chatbot-1", "booking_enabled": True, "is_published": True,
}]))
elif name == "business_hours":
t.execute = MagicMock(return_value=MagicMock(data=[SAMPLE_HOURS]))
elif name == "appointments":
call_count["n"] += 1
if call_count["n"] == 1:
# _get_available_slots: booked slots check — nothing booked
t.execute = MagicMock(return_value=MagicMock(data=[]))
else:
# The actual INSERT
t.execute = MagicMock(return_value=MagicMock(data=[insert_result]))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
def test_creates_appointment_successfully(self, client):
sb = self._sb_with_open_slot()
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"slot_start": "2099-06-09T09:00:00",
"service": "Consultation",
})
assert resp.status_code == 201
assert resp.json()["customer_name"] == "Alice"
assert resp.json()["status"] == "pending"
def test_missing_required_fields_returns_422(self, client):
sb = make_supabase(chatbot_booking=True)
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/appointments",
json={"customer_name": "Alice"}) # missing contact + slot
assert resp.status_code == 422
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.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/nonexistent/appointments", json={
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"slot_start": "2099-06-09T09:00:00",
})
assert resp.status_code == 404
def test_409_when_slot_already_taken(self, client):
"""Slot is marked as booked, so should return 409 Conflict."""
sb = make_supabase(chatbot_booking=True, hours=[SAMPLE_HOURS])
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "appointments":
# 09:00 already booked
t.execute = MagicMock(return_value=MagicMock(data=[
{"slot_start": "2099-06-09T09:00:00",
"slot_end": "2099-06-09T10:00:00"}
]))
return t
sb.table.side_effect = patched
with patch("app.routers.appointments.get_supabase", return_value=sb):
resp = client.post("/api/v1/chatbots/chatbot-1/appointments", json={
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"slot_start": "2099-06-09T09:00:00",
})
assert resp.status_code == 409

119
tests/test_auth.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests for authentication endpoints."""
import pytest
from unittest.mock import MagicMock, patch
def make_auth_user(user_id="user-123", email="test@example.com"):
user = MagicMock()
user.id = user_id
user.email = email
return user
def make_session(token="test-access-token"):
session = MagicMock()
session.access_token = token
return session
class TestSignup:
def test_signup_returns_401_without_body(self, client):
resp = client.post("/api/v1/auth/signup")
assert resp.status_code == 422 # validation error
def test_signup_success(self, client):
user = make_auth_user()
session = make_session()
with patch("app.routers.auth.get_supabase") as mock_sb:
sb = MagicMock()
auth_resp = MagicMock()
auth_resp.user = user
auth_resp.session = session
sb.auth.sign_up.return_value = auth_resp
sb.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[{}])
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(data=[])
mock_sb.return_value = sb
resp = client.post("/api/v1/auth/signup", json={
"email": "new@example.com",
"password": "password123",
"company_name": "Test Corp",
})
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert data["user"]["email"] == "test@example.com"
assert data["user"]["plan"] == "free"
assert data["user"]["is_admin"] == False
class TestLogin:
def test_login_returns_422_without_body(self, client):
resp = client.post("/api/v1/auth/login")
assert resp.status_code == 422
def test_login_success(self, client):
user = make_auth_user()
session = make_session()
with patch("app.routers.auth.get_supabase") as mock_sb:
sb = MagicMock()
auth_resp = MagicMock()
auth_resp.user = user
auth_resp.session = session
sb.auth.sign_in_with_password.return_value = auth_resp
# company query
comp_exec = MagicMock(data=[{"name": "Test Corp"}])
# subscription query
sub_exec = MagicMock(data=[{"plan": "starter"}])
# profile query
profile_exec = MagicMock(data=[{"is_admin": False}])
# Chain: table().select().eq().eq().execute()
def table_side(name):
t = MagicMock()
t.select.return_value = t
t.eq.return_value = t
if name == "companies":
t.execute.return_value = comp_exec
elif name == "subscriptions":
t.execute.return_value = sub_exec
elif name == "user_profiles":
t.execute.return_value = profile_exec
else:
t.execute.return_value = MagicMock(data=[])
return t
sb.table.side_effect = table_side
mock_sb.return_value = sb
resp = client.post("/api/v1/auth/login", json={
"email": "test@example.com",
"password": "password123",
})
assert resp.status_code == 200
data = resp.json()
assert data["access_token"] == "test-access-token"
assert data["user"]["plan"] == "starter"
class TestMe:
def test_me_returns_401_without_auth(self, client):
resp = client.get("/api/v1/auth/me")
assert resp.status_code == 401
def test_forgot_password_always_returns_200(self, client):
with patch("app.routers.auth.get_supabase") as mock_sb:
sb = MagicMock()
sb.auth.reset_password_for_email.return_value = None
mock_sb.return_value = sb
resp = client.post("/api/v1/auth/forgot-password", json={"email": "any@example.com"})
assert resp.status_code == 200
assert "message" in resp.json()

81
tests/test_billing.py Normal file
View File

@@ -0,0 +1,81 @@
"""Tests for billing webhook idempotency and Stripe integration."""
import pytest
import json
from unittest.mock import MagicMock, patch
class TestStripeWebhookIdempotency:
def test_duplicate_event_returns_200_without_processing(self, client):
"""Same Stripe event ID sent twice should only process once."""
event_id = "evt_test_123"
payload = json.dumps({
"id": event_id,
"type": "checkout.session.completed",
"data": {"object": {"metadata": {"user_id": "user-123", "plan": "starter"}, "customer": "cus_123", "subscription": "sub_123"}},
}).encode()
with patch("app.routers.billing.get_supabase") as mock_sb, \
patch("app.routers.billing.settings") as mock_settings:
mock_settings.stripe_webhook_secret = ""
mock_settings.stripe_secret_key = "sk_test_123"
mock_settings.app_env = "development"
mock_settings.n8n_handoff_webhook_url = None
sb = MagicMock()
# First call: event not found
first_check = MagicMock(data=[])
# Second call: event found (already processed)
second_check = MagicMock(data=[{"stripe_event_id": event_id}])
call_count = 0
def table_exec(*args, **kwargs):
return MagicMock(data=[])
sb.table.return_value.select.return_value.eq.return_value.execute.side_effect = [
first_check, # first idempotency check → not found
MagicMock(data=[]), # subscription upsert check
MagicMock(data=[]), # insert event record
]
sb.table.return_value.upsert.return_value.execute.return_value = MagicMock(data=[{}])
sb.table.return_value.insert.return_value.execute.return_value = MagicMock(data=[{}])
mock_sb.return_value = sb
# First request
resp1 = client.post(
"/api/v1/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp1.status_code == 200
# Reset mock: now event IS found
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = second_check
# Second request with same event ID
resp2 = client.post(
"/api/v1/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
)
assert resp2.status_code == 200
assert resp2.json() == {"received": True}
def test_webhook_requires_stripe_signature_in_production(self, client):
"""In production, webhook without signature should fail."""
payload = json.dumps({"id": "evt_123", "type": "test"}).encode()
with patch("app.routers.billing.settings") as mock_settings:
mock_settings.stripe_webhook_secret = "whsec_real_secret"
mock_settings.stripe_secret_key = "sk_live_123"
mock_settings.app_env = "production"
resp = client.post(
"/api/v1/billing/webhook",
content=payload,
headers={"content-type": "application/json"},
# No stripe-signature header
)
# Should fail due to missing signature
assert resp.status_code in (400, 500)

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

258
tests/test_channels.py Normal file
View File

@@ -0,0 +1,258 @@
"""Tests for channels and webhook endpoints."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
AUTH = {"Authorization": "Bearer test-token"}
def _make_channels_sb(plan="starter", company=True, chatbot=True, connection=None):
"""Build a supabase mock for channel tests."""
sb = MagicMock()
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": plan, "status": "active"}])
elif name == "companies":
m.execute.return_value = MagicMock(
data=[{"id": "comp-1", "owner_id": "test-user-id"}] if company else []
)
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=[{"id": "cb-1", "company_id": "comp-1",
"qdrant_collection_name": "col-1",
"is_published": True,
"name": "Test Bot",
"welcome_message": "Hi!",
"companies": {"name": "Acme", "logo_url": None}}] if chatbot else []
)
elif name == "channel_connections":
conn = connection if connection is not None else []
m.execute.return_value = MagicMock(data=conn)
elif name == "channel_sessions":
m.execute.return_value = MagicMock(
data=[{"id": "sess-1", "session_id": "s-123", "chatbot_id": "cb-1",
"channel": "telegram", "external_id": "tg:abc:12345"}]
)
elif name == "conversations":
m.execute.return_value = MagicMock(data=[{"id": "conv-1", "session_id": "s-123",
"status": "open", "message_count": 0}])
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestChannelsAuth:
def test_list_channels_requires_auth(self, client):
resp = client.get("/api/v1/channels?chatbot_id=cb-1")
assert resp.status_code == 401
def test_connect_telegram_requires_auth(self, client):
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "fake:token"})
assert resp.status_code == 401
def test_disconnect_requires_auth(self, client):
resp = client.delete("/api/v1/channels/conn-1")
assert resp.status_code == 401
class TestListChannels:
def test_returns_empty_list_when_no_channels(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb()
resp = client.get("/api/v1/channels?chatbot_id=cb-1", headers=AUTH)
assert resp.status_code == 200
assert resp.json() == []
def test_returns_connection_list(self, client):
conn = [{"id": "conn-1", "channel": "telegram", "bot_username": "mybot",
"is_active": True, "created_at": "2024-01-01T00:00:00"}]
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = client.get("/api/v1/channels?chatbot_id=cb-1", headers=AUTH)
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["channel"] == "telegram"
def test_chatbot_not_found_returns_404(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(chatbot=False)
resp = client.get("/api/v1/channels?chatbot_id=bad-id", headers=AUTH)
assert resp.status_code == 404
class TestConnectTelegram:
def test_free_plan_blocked(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot:
mock_bot.return_value = {"username": "mybot"}
mock_sb.return_value = _make_channels_sb(plan="free")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "abc:token"},
headers=AUTH)
assert resp.status_code == 402
assert "Starter" in resp.json()["detail"]
def test_invalid_bot_token_returns_400(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot:
mock_bot.return_value = None # Invalid token
mock_sb.return_value = _make_channels_sb(plan="starter")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "invalid:token"},
headers=AUTH)
assert resp.status_code == 400
assert "Invalid bot token" in resp.json()["detail"]
def test_successful_connection(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot, \
patch("app.routers.channels.set_webhook", new_callable=AsyncMock) as mock_webhook, \
patch("app.routers.channels.settings") as mock_settings:
mock_bot.return_value = {"username": "mybot"}
mock_webhook.return_value = True
mock_settings.api_url = "https://api.example.com"
mock_sb.return_value = _make_channels_sb(plan="starter")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "valid:token"},
headers=AUTH)
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["bot_username"] == "mybot"
assert "t.me/mybot" in body["bot_link"]
def test_missing_api_url_returns_500(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.get_bot_info", new_callable=AsyncMock) as mock_bot, \
patch("app.routers.channels.settings") as mock_settings:
mock_bot.return_value = {"username": "mybot"}
mock_settings.api_url = None
mock_sb.return_value = _make_channels_sb(plan="starter")
resp = client.post("/api/v1/channels/telegram",
json={"chatbot_id": "cb-1", "bot_token": "valid:token"},
headers=AUTH)
assert resp.status_code == 500
class TestDisconnectChannel:
def test_connection_not_found_returns_404(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(connection=[])
resp = client.delete("/api/v1/channels/no-such-conn", headers=AUTH)
assert resp.status_code == 404
def test_disconnect_success(self, client):
conn = [{"id": "conn-1", "channel": "telegram", "chatbot_id": "cb-1",
"bot_token": "tok:en", "is_active": True}]
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.delete_webhook", new_callable=AsyncMock):
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = client.delete("/api/v1/channels/conn-1", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["success"] is True
class TestTelegramWebhook:
def _post_webhook(self, client, body, bot_token="abc:token"):
return client.post(f"/api/v1/webhooks/telegram/{bot_token}", json=body)
def test_webhook_non_message_returns_ok(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb()
resp = self._post_webhook(client, {"update_id": 1})
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_webhook_no_matching_connection_returns_ok(self, client):
with patch("app.routers.channels.get_supabase") as mock_sb:
mock_sb.return_value = _make_channels_sb(connection=[])
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "Hello"}
})
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_webhook_start_command_sends_welcome(self, client):
conn = [{"id": "conn-1", "chatbot_id": "cb-1", "channel": "telegram",
"bot_token": "abc:token", "is_active": True}]
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.tg_send", new_callable=AsyncMock) as mock_send, \
patch("app.routers.channels._check_chatbot_channel_subscription", return_value=True):
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "/start"}
})
assert resp.status_code == 200
mock_send.assert_called_once()
def test_webhook_processes_message_via_rag(self, client):
conn = [{"id": "conn-1", "chatbot_id": "cb-1", "channel": "telegram",
"bot_token": "abc:token", "is_active": True}]
rag_result = {"response": "I can help!", "sources": [], "model": "gpt-4", "tokens_used": 10}
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.tg_send", new_callable=AsyncMock) as mock_send, \
patch("app.routers.channels.rag_engine") as mock_rag, \
patch("app.routers.channels._check_chatbot_channel_subscription", return_value=True):
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "What are your hours?"}
})
assert resp.status_code == 200
mock_send.assert_called_once()
args = mock_send.call_args[0]
assert args[2] == "I can help!"
def test_webhook_subscription_expired_sends_unavailable(self, client):
conn = [{"id": "conn-1", "chatbot_id": "cb-1", "channel": "telegram",
"bot_token": "abc:token", "is_active": True}]
with patch("app.routers.channels.get_supabase") as mock_sb, \
patch("app.routers.channels.tg_send", new_callable=AsyncMock) as mock_send, \
patch("app.routers.channels._check_chatbot_channel_subscription", return_value=False):
mock_sb.return_value = _make_channels_sb(connection=conn)
resp = self._post_webhook(client, {
"message": {"chat": {"id": 12345}, "text": "Hello"}
})
assert resp.status_code == 200
mock_send.assert_called_once()
assert "unavailable" in mock_send.call_args[0][2].lower()
class TestDetectLanguage:
def test_arabic_text_detected(self):
from app.routers.channels import _detect_language
assert _detect_language("مرحبا كيف حالك") == "ar"
def test_chinese_text_detected(self):
from app.routers.channels import _detect_language
assert _detect_language("你好世界这是中文") == "zh"
def test_english_default(self):
from app.routers.channels import _detect_language
assert _detect_language("Hello how are you doing today") == "en"
def test_empty_string_defaults_to_english(self):
from app.routers.channels import _detect_language
assert _detect_language("") == "en"

289
tests/test_chat.py Normal file
View File

@@ -0,0 +1,289 @@
"""Tests for chat endpoints."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
AUTH = {"Authorization": "Bearer test-token"}
def _make_chatbot(published=True, collection="col-1", **kwargs):
base = {
"id": "cb-1",
"name": "Test Bot",
"is_published": published,
"qdrant_collection_name": collection,
"company_id": "company-1",
"handoff_enabled": False,
"handoff_keywords": [],
"lead_capture_enabled": False,
"lead_capture_trigger": None,
"booking_enabled": False,
"system_prompt": "You are helpful.",
"welcome_message": "Hello!",
"companies": {"name": "Acme", "logo_url": None},
}
base.update(kwargs)
return base
def _make_chat_sb(chatbot=None, existing_conv=None, insert_conv=None):
"""Build a chainable supabase mock for chat tests."""
sb = MagicMock()
chatbot_data = [chatbot] if chatbot is not None else [_make_chatbot()]
conversation_insert = insert_conv or {"id": "conv-1", "session_id": "sess-1",
"status": "open", "message_count": 0}
call_counts = {}
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "chatbots":
m.execute.return_value = MagicMock(data=chatbot_data)
elif name == "conversations":
if existing_conv is not None:
m.execute.return_value = MagicMock(data=existing_conv, count=len(existing_conv))
else:
call_counts.setdefault("conversations", 0)
original_execute = m.execute
def conv_execute():
call_counts["conversations"] += 1
if call_counts["conversations"] == 1:
return MagicMock(data=[], count=0)
return MagicMock(data=[conversation_insert], count=1)
m.execute.side_effect = conv_execute
elif name == "messages":
m.execute.return_value = MagicMock(data=[], count=0)
elif name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "company-1", "owner_id": "owner-1"}])
elif name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}])
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestChatAuth:
def test_chat_does_not_require_auth_for_published_bot(self, client):
"""Public chat endpoint should work without auth for published bots."""
rag_result = {
"response": "Hello!",
"sources": [],
"model": "gpt-4",
"tokens_used": 10,
}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb()
resp = client.post("/api/v1/chat/cb-1", json={"message": "Hi", "language": "en"})
assert resp.status_code == 200
def test_chat_unpublished_bot_requires_auth(self, client):
unpublished = _make_chatbot(published=False)
with patch("app.routers.chat.get_supabase") as mock_sb:
mock_sb.return_value = _make_chat_sb(chatbot=unpublished)
resp = client.post("/api/v1/chat/cb-1", json={"message": "Hi", "language": "en"})
assert resp.status_code == 403
def test_chat_returns_404_when_chatbot_missing(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.post("/api/v1/chat/no-such-bot", json={"message": "Hi", "language": "en"})
assert resp.status_code == 404
class TestChatRateLimiting:
def test_rate_limit_429_after_30_requests(self, client):
"""After 30 requests from same IP, should return 429."""
from app.routers.chat import _rate_store
import time
_rate_store["testclient"] = [time.time() for _ in range(30)]
try:
rag_result = {"response": "Hi", "sources": [], "model": "m", "tokens_used": 0}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb()
resp = client.post("/api/v1/chat/cb-1", json={"message": "Hi", "language": "en"})
finally:
_rate_store.pop("testclient", None)
assert resp.status_code == 429
class TestChatResponse:
def _do_chat(self, client, rag_result, chatbot=None, message="Hello"):
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot)
return client.post("/api/v1/chat/cb-1", json={"message": message, "language": "en"})
def test_response_shape(self, client):
rag_result = {"response": "Hello!", "sources": [], "model": "gpt-4", "tokens_used": 15}
resp = self._do_chat(client, rag_result)
assert resp.status_code == 200
body = resp.json()
assert "response" in body
assert "session_id" in body
assert "sources" in body
assert "model_used" in body
assert "tokens_used" in body
assert "needs_lead_capture" in body
assert "handoff" in body
def test_response_contains_rag_text(self, client):
rag_result = {"response": "42 is the answer", "sources": [], "model": "m", "tokens_used": 5}
resp = self._do_chat(client, rag_result)
assert resp.json()["response"] == "42 is the answer"
def test_chatbot_with_no_collection_returns_400(self, client):
no_collection_bot = _make_chatbot(collection=None)
rag_result = {"response": "", "sources": [], "model": "", "tokens_used": 0}
resp = self._do_chat(client, rag_result, chatbot=no_collection_bot)
assert resp.status_code == 400
assert "knowledge base" in resp.json()["detail"].lower()
def test_agent_handling_status_returns_empty_response(self, client):
chatbot = _make_chatbot()
conv_with_agent = [{"id": "conv-1", "session_id": "sess-1", "status": "agent_handling",
"message_count": 5, "language": "en", "user_id": None}]
rag_result = {"response": "Should not be reached", "sources": [], "model": "", "tokens_used": 0}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot, existing_conv=conv_with_agent)
resp = client.post("/api/v1/chat/cb-1",
json={"message": "Hi", "language": "en", "session_id": "sess-1"})
assert resp.status_code == 200
assert resp.json()["response"] == ""
class TestChatHandoff:
def test_handoff_triggered_by_keyword(self, client):
chatbot = _make_chatbot(handoff_enabled=True, handoff_keywords=["human", "agent"],
handoff_email="owner@test.com")
rag_result = {"response": "Connecting you...", "sources": [], "model": "m", "tokens_used": 5}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag, \
patch("app.routers.chat.send_handoff_notification", new_callable=AsyncMock, create=True):
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot)
resp = client.post("/api/v1/chat/cb-1", json={"message": "I need a human agent", "language": "en"})
assert resp.status_code == 200
assert resp.json()["handoff"] is True
def test_handoff_not_triggered_without_keyword_match(self, client):
chatbot = _make_chatbot(handoff_enabled=True, handoff_keywords=["human"])
rag_result = {"response": "Sure!", "sources": [], "model": "m", "tokens_used": 5}
with patch("app.routers.chat.get_supabase") as mock_sb, \
patch("app.routers.chat.rag_engine") as mock_rag:
mock_rag.process_query = AsyncMock(return_value=rag_result)
mock_sb.return_value = _make_chat_sb(chatbot=chatbot)
resp = client.post("/api/v1/chat/cb-1", json={"message": "What are your hours?", "language": "en"})
assert resp.status_code == 200
assert resp.json()["handoff"] is False
class TestChatHistory:
def test_history_returns_empty_for_unknown_session(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.order.return_value = m
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.get("/api/v1/chat/cb-1/history/no-such-session")
assert resp.status_code == 200
assert resp.json() == []
class TestChatFeedback:
def test_feedback_valid_positive(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[{"id": "msg-1", "conversation_id": "conv-1"}])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/chat/cb-1/feedback",
json={"message_id": "msg-1", "feedback": "positive"},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_feedback_invalid_value_rejected(self, client):
resp = client.post(
"/api/v1/chat/cb-1/feedback",
json={"message_id": "msg-1", "feedback": "meh"},
)
assert resp.status_code == 400
def test_feedback_message_not_found(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.chat.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/chat/cb-1/feedback",
json={"message_id": "no-such-msg", "feedback": "negative"},
)
assert resp.status_code == 404

90
tests/test_chatbots.py Normal file
View File

@@ -0,0 +1,90 @@
"""Tests for chatbot endpoints."""
import pytest
from unittest.mock import MagicMock, patch
class TestChatbotProtection:
def test_list_chatbots_requires_auth(self, client):
resp = client.get("/api/v1/chatbots")
assert resp.status_code == 401
def test_create_chatbot_requires_auth(self, client):
resp = client.post("/api/v1/chatbots", json={"name": "Test"})
assert resp.status_code == 401
def test_delete_chatbot_requires_auth(self, client):
resp = client.delete("/api/v1/chatbots/some-id")
assert resp.status_code == 401
class TestInputValidation:
def test_create_chatbot_rejects_long_name(self, client):
"""ChatbotCreate should reject names > 100 chars."""
from app.models import ChatbotCreate
import pytest
with pytest.raises(Exception):
ChatbotCreate(name="x" * 101)
def test_create_chatbot_strips_script_tags(self):
"""System prompt with script tags should be sanitized."""
from app.models import ChatbotCreate
data = ChatbotCreate(
name="Test",
system_prompt="Hello <script>alert('xss')</script> world",
)
assert "<script>" not in (data.system_prompt or "")
assert "world" in (data.system_prompt or "")
def test_create_chatbot_rejects_long_system_prompt(self):
"""System prompt > 10000 chars should raise validation error."""
from app.models import ChatbotCreate
import pytest
with pytest.raises(Exception):
ChatbotCreate(name="Test", system_prompt="x" * 10001)
def test_create_chatbot_strips_name_whitespace(self):
from app.models import ChatbotCreate
data = ChatbotCreate(name=" My Bot ")
assert data.name == "My Bot"
class TestQdrantOrphanCleanup:
def test_qdrant_collection_deleted_on_db_failure(self):
"""If DB insert fails after Qdrant creation, collection should be cleaned up."""
with patch("app.routers.chatbots.vector_store") as mock_vs, \
patch("app.routers.chatbots.get_supabase") as mock_sb, \
patch("app.routers.chatbots.get_current_user") as mock_auth:
# Auth passes
user = MagicMock()
user.id = "user-id"
mock_auth.return_value = user
sb = MagicMock()
# company lookup succeeds
sb.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock(
data=[{"id": "company-id", "owner_id": "user-id"}]
)
# DB insert fails
sb.table.return_value.insert.return_value.execute.side_effect = Exception("DB error")
mock_sb.return_value = sb
mock_vs.create_collection = MagicMock()
mock_vs.delete_collection = MagicMock()
from fastapi.testclient import TestClient
from app.main import app
test_client = TestClient(app)
resp = test_client.post(
"/api/v1/chatbots",
json={"name": "Test Bot"},
headers={"Authorization": "Bearer test"},
)
# Should have attempted cleanup
# (The response will be 500 due to DB failure)
# The key assertion is that delete_collection was attempted
# (This is a partial integration test — full assertion needs auth mock)

285
tests/test_config_plans.py Normal file
View File

@@ -0,0 +1,285 @@
"""
Tests for PLAN_LIMITS in config.py.
Ensures each plan has the correct feature gates and that the pricing
tiers are properly differentiated.
"""
import pytest
from app.config import PLAN_LIMITS, MODEL_CATALOG, DEFAULT_MODELS, MODEL_PROVIDERS
class TestPlanStructure:
"""Every plan must have all required keys."""
REQUIRED_KEYS = {
"max_chatbots", "max_published", "max_documents_per_chatbot",
"max_document_size_mb", "models", "conversations_limit",
"code_export", "analytics", "gap_suggestions", "channels",
"url_sources", "leads_per_month", "inbox_replies", "leads_editing",
"show_branding", "appointments", "appointments_chatbots",
"campaigns", "campaigns_per_month", "max_campaign_recipients",
}
@pytest.mark.parametrize("plan", ["free", "starter", "business", "agency", "enterprise"])
def test_all_required_keys_present(self, plan):
config = PLAN_LIMITS[plan]
missing = self.REQUIRED_KEYS - set(config.keys())
assert not missing, f"Plan '{plan}' missing keys: {missing}"
@pytest.mark.parametrize("plan", ["free", "starter", "business", "agency", "enterprise"])
def test_models_list_is_non_empty(self, plan):
models = PLAN_LIMITS[plan]["models"]
assert len(models) > 0
@pytest.mark.parametrize("plan", ["free", "starter", "business", "agency", "enterprise"])
def test_default_model_exists_for_plan(self, plan):
default = DEFAULT_MODELS.get(plan)
assert default is not None, f"No default model for {plan}"
class TestFreePlanRestrictions:
def setup_method(self):
self.plan = PLAN_LIMITS["free"]
def test_max_published_is_one(self):
assert self.plan["max_published"] == 1
def test_no_inbox_replies(self):
assert self.plan["inbox_replies"] is False
def test_no_leads_editing(self):
assert self.plan["leads_editing"] is False
def test_no_appointments(self):
assert self.plan["appointments"] is False
assert self.plan["appointments_chatbots"] == 0
def test_no_campaigns(self):
assert self.plan["campaigns"] is False
assert self.plan["campaigns_per_month"] == 0
assert self.plan["max_campaign_recipients"] == 0
def test_show_branding(self):
assert self.plan["show_branding"] is True
def test_no_analytics(self):
assert self.plan["analytics"] is False
def test_no_gap_suggestions(self):
assert self.plan["gap_suggestions"] is False
def test_no_channels(self):
assert self.plan["channels"] == []
def test_no_url_sources(self):
assert self.plan["url_sources"] == 0
def test_no_leads(self):
assert self.plan["leads_per_month"] == 0
def test_only_free_model(self):
models = self.plan["models"]
assert len(models) == 1
assert "llama" in models[0].lower()
class TestStarterPlan:
def setup_method(self):
self.plan = PLAN_LIMITS["starter"]
def test_max_published_is_three(self):
assert self.plan["max_published"] == 3
def test_has_inbox_replies(self):
assert self.plan["inbox_replies"] is True
def test_has_leads_editing(self):
assert self.plan["leads_editing"] is True
def test_has_appointments(self):
assert self.plan["appointments"] is True
def test_appointments_limited_to_one_chatbot(self):
assert self.plan["appointments_chatbots"] == 1
def test_has_campaigns(self):
assert self.plan["campaigns"] is True
def test_campaigns_limited_per_month(self):
assert 0 < self.plan["campaigns_per_month"] < 999999
def test_campaign_recipients_limited(self):
assert 0 < self.plan["max_campaign_recipients"] < 999999
def test_show_branding(self):
# Starter still shows branding
assert self.plan["show_branding"] is True
def test_has_analytics(self):
assert self.plan["analytics"] is True
def test_no_gap_suggestions(self):
assert self.plan["gap_suggestions"] is False
def test_has_telegram_channel(self):
assert "telegram" in self.plan["channels"]
def test_fireworks_models_only(self):
models = self.plan["models"]
for m in models:
assert "fireworks" in m
def test_no_premium_models(self):
premium = {"gpt-4o", "gpt-4o-mini", "claude-haiku-4-5-20251001",
"gemini-2.5-flash", "gemini-2.5-lite", "gemini-2.5-pro"}
assert not premium.intersection(set(self.plan["models"]))
class TestBusinessPlan:
def setup_method(self):
self.plan = PLAN_LIMITS["business"]
def test_max_published_is_ten(self):
assert self.plan["max_published"] == 10
def test_can_remove_branding(self):
assert self.plan["show_branding"] is False
def test_unlimited_appointments_chatbots(self):
assert self.plan["appointments_chatbots"] == 999999
def test_unlimited_campaigns_per_month(self):
assert self.plan["campaigns_per_month"] == 999999
def test_has_campaign_recipient_limit(self):
# Business is capped below Agency/Enterprise
assert self.plan["max_campaign_recipients"] < PLAN_LIMITS["agency"]["max_campaign_recipients"]
def test_has_gap_suggestions(self):
assert self.plan["gap_suggestions"] is True
def test_has_premium_models(self):
premium = {"gpt-4o", "gpt-4o-mini", "claude-haiku-4-5-20251001"}
assert premium.issubset(set(self.plan["models"]))
def test_has_google_models(self):
google = {"gemini-2.5-flash", "gemini-2.5-pro"}
assert google.issubset(set(self.plan["models"]))
class TestAgencyPlan:
def setup_method(self):
self.plan = PLAN_LIMITS["agency"]
def test_unlimited_published(self):
assert self.plan["max_published"] == 999999
def test_unlimited_campaign_recipients(self):
assert self.plan["max_campaign_recipients"] == 999999
def test_code_export_enabled(self):
assert self.plan["code_export"] is True
def test_gap_suggestions_enabled(self):
assert self.plan["gap_suggestions"] is True
def test_no_branding(self):
assert self.plan["show_branding"] is False
class TestEnterprisePlan:
def setup_method(self):
self.plan = PLAN_LIMITS["enterprise"]
def test_wildcard_models(self):
assert "*" in self.plan["models"]
def test_all_features_enabled(self):
assert self.plan["appointments"] is True
assert self.plan["campaigns"] is True
assert self.plan["inbox_replies"] is True
assert self.plan["leads_editing"] is True
assert self.plan["gap_suggestions"] is True
assert self.plan["code_export"] is True
assert self.plan["show_branding"] is False
def test_unlimited_everything(self):
BIG = 999999
assert self.plan["max_published"] == BIG
assert self.plan["appointments_chatbots"] == BIG
assert self.plan["campaigns_per_month"] == BIG
assert self.plan["max_campaign_recipients"] == BIG
class TestTierProgression:
"""Each higher tier must be strictly better than the tier below it."""
def test_max_published_increases_with_tier(self):
assert PLAN_LIMITS["free"]["max_published"] \
<= PLAN_LIMITS["starter"]["max_published"] \
<= PLAN_LIMITS["business"]["max_published"] \
<= PLAN_LIMITS["agency"]["max_published"]
def test_conversation_limits_increase_with_tier(self):
assert PLAN_LIMITS["free"]["conversations_limit"] \
< PLAN_LIMITS["starter"]["conversations_limit"] \
< PLAN_LIMITS["business"]["conversations_limit"] \
< PLAN_LIMITS["agency"]["conversations_limit"]
def test_business_has_more_models_than_starter(self):
starter_models = set(PLAN_LIMITS["starter"]["models"])
business_models = set(PLAN_LIMITS["business"]["models"])
assert starter_models.issubset(business_models)
assert len(business_models) > len(starter_models)
def test_appointment_chatbots_increases_with_tier(self):
assert PLAN_LIMITS["free"]["appointments_chatbots"] \
<= PLAN_LIMITS["starter"]["appointments_chatbots"] \
<= PLAN_LIMITS["business"]["appointments_chatbots"]
def test_campaign_recipients_increases_with_tier(self):
assert PLAN_LIMITS["free"]["max_campaign_recipients"] \
< PLAN_LIMITS["starter"]["max_campaign_recipients"] \
< PLAN_LIMITS["business"]["max_campaign_recipients"] \
<= PLAN_LIMITS["agency"]["max_campaign_recipients"]
class TestModelCatalog:
"""MODEL_CATALOG and MODEL_PROVIDERS consistency checks."""
def test_all_catalog_models_have_required_fields(self):
for model_id, meta in MODEL_CATALOG.items():
assert "name" in meta, f"{model_id} missing 'name'"
assert "provider" in meta, f"{model_id} missing 'provider'"
assert "badge" in meta, f"{model_id} missing 'badge'"
def test_all_catalog_models_have_provider_mapping(self):
for model_id in MODEL_CATALOG:
assert model_id in MODEL_PROVIDERS, \
f"{model_id} in MODEL_CATALOG but not in MODEL_PROVIDERS"
def test_provider_values_are_known(self):
known = {"fireworks", "openai", "anthropic", "google"}
for model_id, provider in MODEL_PROVIDERS.items():
assert provider in known, \
f"{model_id} has unknown provider '{provider}'"
def test_non_enterprise_plan_models_are_in_catalog(self):
for plan_name, plan in PLAN_LIMITS.items():
if plan_name == "enterprise":
continue
for model_id in plan["models"]:
assert model_id in MODEL_CATALOG, \
f"Plan '{plan_name}' references '{model_id}' not in MODEL_CATALOG"
def test_default_models_are_in_catalog(self):
for plan, model_id in DEFAULT_MODELS.items():
assert model_id in MODEL_CATALOG, \
f"DEFAULT_MODELS[{plan}] = '{model_id}' not in MODEL_CATALOG"
def test_default_models_are_in_plan_limits(self):
for plan, model_id in DEFAULT_MODELS.items():
plan_models = PLAN_LIMITS[plan]["models"]
if "*" not in plan_models:
assert model_id in plan_models, \
f"Default model '{model_id}' for plan '{plan}' not in that plan's models list"

267
tests/test_documents.py Normal file
View File

@@ -0,0 +1,267 @@
"""Tests for document upload, list, delete, and URL source endpoints."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
AUTH = {"Authorization": "Bearer test-token"}
def _make_doc_sb(company=True, chatbot=True, doc=None, url_source=None):
"""Build a supabase mock for document endpoint tests."""
sb = MagicMock()
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "companies":
m.execute.return_value = MagicMock(
data=[{"id": "company-1"}] if company else [],
count=1 if company else 0,
)
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=[{"id": "cb-1", "company_id": "company-1",
"qdrant_collection_name": "col-1"}] if chatbot else [],
count=1 if chatbot else 0,
)
elif name == "documents":
if doc is not None:
m.execute.return_value = MagicMock(data=[doc], count=1)
else:
m.execute.return_value = MagicMock(
data=[{
"id": "doc-1",
"chatbot_id": "cb-1",
"file_name": "test.pdf",
"file_type": ".pdf",
"file_size": 1024,
"chunk_count": 0,
"status": "processing",
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
"error_message": None,
"file_url": None,
}],
count=1,
)
elif name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "starter"}], count=1)
elif name == "url_sources":
if url_source is not None:
m.execute.return_value = MagicMock(data=[url_source], count=1)
else:
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestDocumentAuth:
def test_upload_requires_auth(self, client):
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("test.pdf", b"PDF content", "application/pdf")},
)
assert resp.status_code == 401
def test_list_requires_auth(self, client):
resp = client.get("/api/v1/chatbots/cb-1/documents")
assert resp.status_code == 401
def test_delete_requires_auth(self, client):
resp = client.delete("/api/v1/chatbots/cb-1/documents/doc-1")
assert resp.status_code == 401
class TestDocumentUpload:
def test_upload_unsupported_type_returns_400(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb()
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("image.png", b"image data", "image/png")},
headers=AUTH,
)
assert resp.status_code == 400
assert "not supported" in resp.json()["detail"]
@pytest.mark.parametrize("filename,mime", [
("report.pdf", "application/pdf"),
("data.csv", "text/csv"),
("doc.txt", "text/plain"),
("notes.md", "text/markdown"),
])
def test_upload_accepted_types(self, client, filename, mime):
with patch("app.routers.documents.get_supabase") as mock_sb, \
patch("app.routers.documents._process_document_bg", new_callable=AsyncMock):
mock_sb.return_value = _make_doc_sb()
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": (filename, b"content", mime)},
headers=AUTH,
)
assert resp.status_code == 201
def test_upload_company_not_found_returns_404(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb(company=False)
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("test.pdf", b"data", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 404
def test_upload_chatbot_not_found_returns_404(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb(chatbot=False)
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("test.pdf", b"data", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 404
def test_upload_response_has_processing_status(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb, \
patch("app.routers.documents._process_document_bg", new_callable=AsyncMock):
mock_sb.return_value = _make_doc_sb()
resp = client.post(
"/api/v1/chatbots/cb-1/documents",
files={"file": ("report.pdf", b"pdf content", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 201
assert resp.json()["status"] == "processing"
class TestDocumentList:
def test_list_returns_documents(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb()
resp = client.get("/api/v1/chatbots/cb-1/documents", headers=AUTH)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["file_name"] == "test.pdf"
def test_list_chatbot_not_found_returns_404(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb(chatbot=False)
resp = client.get("/api/v1/chatbots/cb-1/documents", headers=AUTH)
assert resp.status_code == 404
class TestDocumentDelete:
def test_delete_document_not_found_returns_404(self, client):
# Override the documents table to return empty for doc lookup
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.limit.return_value = m
m.order.return_value = m
if name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "company-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(
data=[{"id": "cb-1", "company_id": "company-1",
"qdrant_collection_name": "col-1"}]
)
elif name == "documents":
m.execute.return_value = MagicMock(data=[])
else:
m.execute.return_value = MagicMock(data=[])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.documents.get_supabase", return_value=sb):
resp = client.delete("/api/v1/chatbots/cb-1/documents/no-such-doc", headers=AUTH)
assert resp.status_code == 404
def test_delete_success(self, client):
doc = {
"id": "doc-1",
"chatbot_id": "cb-1",
"file_name": "report.pdf",
"file_type": ".pdf",
"file_size": 1024,
"chunk_count": 5,
"status": "completed",
"created_at": "2024-01-01T00:00:00",
"updated_at": "2024-01-01T00:00:00",
"error_message": None,
"file_url": None,
}
with patch("app.routers.documents.get_supabase") as mock_sb, \
patch("app.routers.documents.vector_store") as mock_vs:
mock_vs.delete_by_document_id = MagicMock()
mock_sb.return_value = _make_doc_sb(doc=doc)
resp = client.delete("/api/v1/chatbots/cb-1/documents/doc-1", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["success"] is True
class TestUrlSources:
def test_list_url_sources_requires_auth(self, client):
resp = client.get("/api/v1/chatbots/cb-1/url-sources")
assert resp.status_code == 401
def test_add_url_source_requires_auth(self, client):
resp = client.post("/api/v1/chatbots/cb-1/url-sources", json={"url": "https://example.com"})
assert resp.status_code == 401
def test_add_url_source_free_plan_blocked(self, client):
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[], count=0)
if name == "companies":
m.execute.return_value = MagicMock(data=[{"id": "comp-1"}])
elif name == "chatbots":
m.execute.return_value = MagicMock(data=[{"id": "cb-1", "company_id": "comp-1"}])
elif name == "subscriptions":
m.execute.return_value = MagicMock(data=[{"plan": "free"}])
return m
sb = MagicMock()
sb.table.side_effect = table_side
sb.auth = MagicMock()
with patch("app.routers.documents.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/chatbots/cb-1/url-sources",
json={"url": "https://example.com"},
headers=AUTH,
)
assert resp.status_code == 402
def test_list_url_sources_returns_empty(self, client):
with patch("app.routers.documents.get_supabase") as mock_sb:
mock_sb.return_value = _make_doc_sb()
resp = client.get("/api/v1/chatbots/cb-1/url-sources", headers=AUTH)
assert resp.status_code == 200
assert resp.json() == []

428
tests/test_inbox.py Normal file
View File

@@ -0,0 +1,428 @@
"""
Tests for inbox endpoints:
GET /api/v1/inbox/conversations
GET /api/v1/inbox/conversations/{id}
PATCH /api/v1/inbox/conversations/{id}/status
POST /api/v1/inbox/conversations/{id}/reply
DELETE /api/v1/inbox/conversations/{id}
"""
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
def make_supabase(plan="starter", company_id="company-1", conversations=None,
messages=None, conversation=None):
"""
Build a Supabase mock wired for inbox tests.
table() calls are routed by table name; every chain returns self so
.select().eq()…execute() works.
"""
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.neq = 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)
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", "name": "My Bot", "company_id": company_id}
]))
elif name == "conversations":
if conversation is not None:
t.execute = MagicMock(return_value=MagicMock(data=[conversation]))
else:
rows = conversations or [
{"id": "conv-1", "chatbot_id": "chatbot-1", "session_id": "s1",
"language": "en", "message_count": 3, "status": "open",
"last_agent_reply_at": None, "created_at": "2024-01-01T00:00:00"},
]
t.execute = MagicMock(return_value=MagicMock(data=rows))
elif name == "messages":
rows = messages or [
{"id": "msg-1", "role": "user", "content": "Hello",
"sources": None, "confidence_score": None,
"is_handoff": False, "created_at": "2024-01-01T00:00:00"},
]
t.execute = MagicMock(return_value=MagicMock(data=rows))
else:
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = table_side
return sb
# ── Tests: list conversations ──────────────────────────────────────────────────
class TestListConversations:
def test_requires_auth(self, client):
resp = client.get("/api/v1/inbox/conversations")
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.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
def test_returns_conversation_list(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["id"] == "conv-1"
assert data[0]["status"] == "open"
assert data[0]["chatbot_name"] == "My Bot"
def test_empty_when_no_chatbots(self, client):
user = make_user()
sb = make_supabase(plan="starter")
# Override chatbots table to return nothing
original_side = sb.table.side_effect
def patched(name):
t = original_side(name)
if name == "chatbots":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json() == []
def test_pagination_params_accepted(self, client):
user = make_user()
sb = make_supabase(plan="starter")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations?page=2&limit=5",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# ── Tests: get single conversation ────────────────────────────────────────────
class TestGetConversation:
def _owned_conv(self, company_id="company-1"):
return {
"id": "conv-1", "chatbot_id": "chatbot-1",
"session_id": "s1", "language": "en",
"status": "open", "last_agent_reply_at": None,
"created_at": "2024-01-01T00:00:00",
"chatbots": {"company_id": company_id, "name": "My Bot"},
}
def test_requires_auth(self, client):
resp = client.get("/api/v1/inbox/conversations/conv-1")
assert resp.status_code == 401
def test_returns_messages(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
body = resp.json()
assert body["conversation_id"] == "conv-1"
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
def test_returns_404_when_not_found(self, client):
user = make_user()
sb = make_supabase(plan="starter", conversation=None, conversations=[])
# conversations table returns empty
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations/nonexistent",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_returns_403_for_other_companys_conversation(self, client):
user = make_user()
# Conversation belongs to company-OTHER, user belongs to company-1
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.get("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
# ── Tests: update conversation status ─────────────────────────────────────────
class TestUpdateConversationStatus:
def _owned_conv(self, company_id="company-1", status="open"):
return {
"id": "conv-1", "status": status,
"chatbots": {"company_id": company_id},
}
def test_requires_auth(self, client):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": "resolved"})
assert resp.status_code == 401
def test_valid_statuses_accepted(self, client):
for s in ("open", "agent_handling", "resolved"):
user = make_user()
conv = self._owned_conv(status="open")
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": s},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["status"] == s
def test_invalid_status_returns_400(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": "invalid_status"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 400
def test_403_for_other_companys_conversation(self, client):
user = make_user()
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/conv-1/status",
json={"status": "resolved"},
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 == "conversations":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.patch("/api/v1/inbox/conversations/nonexistent/status",
json={"status": "resolved"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
# ── Tests: agent reply ─────────────────────────────────────────────────────────
class TestAgentReply:
def _owned_conv(self, company_id="company-1", status="open"):
return {
"id": "conv-1", "status": status,
"chatbots": {"company_id": company_id},
}
def test_requires_auth(self, client):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hello!"})
assert resp.status_code == 401
def test_sends_reply_and_returns_message_id(self, client):
user = make_user()
conv = self._owned_conv(status="open")
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Thanks for contacting us!"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert "message_id" in body
def test_empty_message_returns_422(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": ""},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 422
def test_sets_status_to_agent_handling_when_open(self, client):
user = make_user()
conv = self._owned_conv(status="open")
sb = make_supabase(plan="starter", conversation=conv)
update_calls = []
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
orig_update = t.update
def track_update(data):
update_calls.append(data)
return t
t.update = track_update
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hello"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# Should have updated status to agent_handling
statuses = [c.get("status") for c in update_calls if "status" in c]
assert "agent_handling" in statuses
def test_does_not_change_status_when_already_agent_handling(self, client):
user = make_user()
conv = self._owned_conv(status="agent_handling")
sb = make_supabase(plan="starter", conversation=conv)
update_calls = []
original = sb.table.side_effect
def patched(name):
t = original(name)
if name == "conversations":
orig_update = t.update
def track_update(data):
update_calls.append(data)
return t
t.update = track_update
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Follow-up"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
# status should NOT appear in any update when already agent_handling
new_statuses = [c.get("status") for c in update_calls if "status" in c]
assert "agent_handling" not in new_statuses
def test_403_for_other_companys_conversation(self, client):
user = make_user()
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hi"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403
def test_free_plan_returns_402(self, client):
user = make_user()
sb = make_supabase(plan="free")
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.post("/api/v1/inbox/conversations/conv-1/reply",
json={"message": "Hi"},
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 402
# ── Tests: delete conversation ─────────────────────────────────────────────────
class TestDeleteConversation:
def _owned_conv(self, company_id="company-1"):
return {
"id": "conv-1",
"chatbots": {"company_id": company_id},
}
def test_requires_auth(self, client):
resp = client.delete("/api/v1/inbox/conversations/conv-1")
assert resp.status_code == 401
def test_deletes_successfully(self, client):
user = make_user()
conv = self._owned_conv()
sb = make_supabase(plan="starter", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.delete("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_returns_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 == "conversations":
t.execute = MagicMock(return_value=MagicMock(data=[]))
return t
sb.table.side_effect = patched
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.delete("/api/v1/inbox/conversations/nonexistent",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 404
def test_returns_403_for_other_companys_conversation(self, client):
user = make_user()
conv = self._owned_conv(company_id="company-OTHER")
sb = make_supabase(plan="starter", company_id="company-1", conversation=conv)
with patch("app.routers.inbox.get_current_user", return_value=user), \
patch("app.routers.inbox.get_supabase", return_value=sb):
resp = client.delete("/api/v1/inbox/conversations/conv-1",
headers={"Authorization": "Bearer tok"})
assert resp.status_code == 403

405
tests/test_leads.py Normal file
View 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

212
tests/test_marketplace.py Normal file
View File

@@ -0,0 +1,212 @@
"""Tests for marketplace endpoints."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
def _make_marketplace_sb(chatbot_data=None, count=0):
"""Build a supabase mock for marketplace queries."""
sb = MagicMock()
def table_side(name):
m = MagicMock()
m.select.return_value = m
m.insert.return_value = m
m.update.return_value = m
m.delete.return_value = m
m.eq.return_value = m
m.in_.return_value = m
m.ilike.return_value = m
m.limit.return_value = m
m.order.return_value = m
m.range.return_value = m
m.gte.return_value = m
m.lt.return_value = m
if name == "chatbots":
m.execute.return_value = MagicMock(
data=chatbot_data if chatbot_data is not None else [],
count=count,
)
elif name == "conversations":
m.execute.return_value = MagicMock(data=[], count=0)
else:
m.execute.return_value = MagicMock(data=[], count=0)
return m
sb.table.side_effect = table_side
sb.auth = MagicMock()
return sb
class TestMarketplaceList:
def test_list_returns_empty_when_no_chatbots(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots")
assert resp.status_code == 200
body = resp.json()
assert body["chatbots"] == []
assert body["total"] == 0
def test_list_returns_chatbots(self, client):
bots = [{
"id": "bot-1",
"name": "Support Bot",
"description": "A test bot",
"category": "Customer Support",
"industry": "Technology & SaaS",
"languages": ["en"],
"primary_color": "#6366f1",
"welcome_message": "Hello!",
"logo_url": None,
"average_rating": 4.5,
"total_conversations": 100,
"is_published": True,
"created_at": "2024-01-01T00:00:00",
"published_at": "2024-01-02T00:00:00",
"companies": {"name": "Acme Inc", "logo_url": None},
}]
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=bots, count=1)
resp = client.get("/api/v1/marketplace/chatbots")
assert resp.status_code == 200
body = resp.json()
assert len(body["chatbots"]) == 1
assert body["chatbots"][0]["name"] == "Support Bot"
assert body["chatbots"][0]["company_name"] == "Acme Inc"
def test_list_pagination_fields(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(count=50)
resp = client.get("/api/v1/marketplace/chatbots?page=1&limit=20")
assert resp.status_code == 200
body = resp.json()
assert body["page"] == 1
assert body["limit"] == 20
assert "has_more" in body
def test_list_limit_max_100(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots?limit=200")
# FastAPI should reject > 100
assert resp.status_code == 422
def test_list_accepts_category_filter(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots?category=Customer+Support")
assert resp.status_code == 200
def test_list_accepts_search_filter(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb()
resp = client.get("/api/v1/marketplace/chatbots?search=bot")
assert resp.status_code == 200
def test_has_more_true_when_more_results(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(count=100)
resp = client.get("/api/v1/marketplace/chatbots?page=1&limit=20")
assert resp.json()["has_more"] is True
def test_has_more_false_on_last_page(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(count=10)
resp = client.get("/api/v1/marketplace/chatbots?page=1&limit=20")
assert resp.json()["has_more"] is False
class TestMarketplaceDetail:
def test_detail_not_found_returns_404(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[])
resp = client.get("/api/v1/marketplace/chatbots/nonexistent-id")
assert resp.status_code == 404
def test_detail_returns_chatbot(self, client):
bot = {
"id": "bot-1",
"name": "My Bot",
"description": "desc",
"category": "FAQ & Knowledge Base",
"industry": "Education & Training",
"languages": ["en", "fr"],
"primary_color": "#000000",
"welcome_message": "Hi!",
"logo_url": None,
"average_rating": 3.8,
"total_conversations": 50,
"is_published": True,
"created_at": "2024-01-01T00:00:00",
"published_at": "2024-01-02T00:00:00",
"companies": {"name": "Test Co", "logo_url": None},
}
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[bot])
resp = client.get("/api/v1/marketplace/chatbots/bot-1")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "My Bot"
assert body["languages"] == ["en", "fr"]
class TestMarketplaceCategories:
def test_categories_returns_lists(self, client):
resp = client.get("/api/v1/marketplace/categories")
assert resp.status_code == 200
body = resp.json()
assert "categories" in body
assert "industries" in body
assert isinstance(body["categories"], list)
assert isinstance(body["industries"], list)
assert len(body["categories"]) > 0
assert len(body["industries"]) > 0
def test_categories_includes_customer_support(self, client):
resp = client.get("/api/v1/marketplace/categories")
assert "Customer Support" in resp.json()["categories"]
class TestMarketplaceRating:
def test_rate_requires_auth(self, client):
resp = client.post("/api/v1/marketplace/chatbots/bot-1/rate", json={"rating": 4})
assert resp.status_code == 401
def test_rate_chatbot_not_found(self, client):
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[])
resp = client.post(
"/api/v1/marketplace/chatbots/nonexistent/rate",
json={"rating": 4},
headers=AUTH,
)
assert resp.status_code == 404
def test_rate_chatbot_success(self, client):
bot = {"id": "bot-1", "average_rating": 4.0}
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[bot])
resp = client.post(
"/api/v1/marketplace/chatbots/bot-1/rate",
json={"rating": 5},
headers=AUTH,
)
assert resp.status_code == 200
body = resp.json()
assert "new_average" in body
assert body["new_average"] == 4.5 # (4.0 + 5) / 2
def test_rate_chatbot_first_rating(self, client):
"""When average_rating is None, should use the submitted rating as both sides."""
bot = {"id": "bot-1", "average_rating": None}
with patch("app.routers.marketplace.get_supabase") as mock_sb:
mock_sb.return_value = _make_marketplace_sb(chatbot_data=[bot])
resp = client.post(
"/api/v1/marketplace/chatbots/bot-1/rate",
json={"rating": 5},
headers=AUTH,
)
assert resp.status_code == 200
assert resp.json()["new_average"] == 5.0

107
tests/test_models.py Normal file
View File

@@ -0,0 +1,107 @@
"""Tests for models router — plan-based model availability."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
def _make_sb_with_plan(plan: str):
sb = MagicMock()
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[{"plan": plan}])
sb.table.return_value = m
sb.auth = MagicMock()
return sb
class TestModelsAuth:
def test_available_requires_auth(self, client):
resp = client.get("/api/v1/models/available")
assert resp.status_code == 401
class TestModelsAvailable:
@pytest.mark.parametrize("plan", ["free", "starter", "pro", "enterprise"])
def test_returns_200_for_all_plans(self, client, plan):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan(plan)
resp = client.get("/api/v1/models/available", headers=AUTH)
assert resp.status_code == 200
def test_response_shape(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("starter")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert "models" in body
assert "plan" in body
assert "has_premium_access" in body
assert isinstance(body["models"], list)
def test_free_plan_has_no_premium_access(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("free")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert body["has_premium_access"] is False
assert body["upgrade_label"] is not None
def test_enterprise_has_premium_access(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("enterprise")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert body["has_premium_access"] is True
assert body["upgrade_label"] is None
def test_model_fields_present(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("starter")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
if body["models"]:
model = body["models"][0]
assert "id" in model
assert "name" in model
assert "provider" in model
assert "badge" in model
assert "is_default" in model
def test_exactly_one_default_model_per_plan(self, client):
for plan in ["starter", "pro"]:
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan(plan)
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
defaults = [m for m in body["models"] if m["is_default"]]
assert len(defaults) <= 1, f"Plan {plan} has {len(defaults)} default models"
def test_starter_upgrade_label_mentions_business(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("starter")
resp = client.get("/api/v1/models/available", headers=AUTH)
body = resp.json()
assert body["upgrade_label"] is not None
def test_unknown_plan_falls_back_to_free(self, client):
"""An unknown plan should fall back to free-tier behavior without crashing."""
with patch("app.routers.models.get_supabase") as mock_sb:
mock_sb.return_value = _make_sb_with_plan("banana")
resp = client.get("/api/v1/models/available", headers=AUTH)
assert resp.status_code == 200
def test_no_active_subscription_defaults_to_free(self, client):
with patch("app.routers.models.get_supabase") as mock_sb:
sb = MagicMock()
m = MagicMock()
m.select.return_value = m
m.eq.return_value = m
m.execute.return_value = MagicMock(data=[])
sb.table.return_value = m
sb.auth = MagicMock()
mock_sb.return_value = sb
resp = client.get("/api/v1/models/available", headers=AUTH)
assert resp.status_code == 200
assert resp.json()["plan"] == "free"

118
tests/test_rag.py Normal file
View File

@@ -0,0 +1,118 @@
"""Tests for RAG pipeline."""
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
class TestRAGEngine:
@pytest.fixture
def rag(self):
from app.services.rag import RAGEngine
engine = RAGEngine()
return engine
@pytest.fixture
def chatbot_config(self):
return {
"model": "accounts/fireworks/models/llama-v3p3-70b-instruct",
"max_tokens": 500,
"temperature": 0.7,
"company_name": "Test Corp",
"system_prompt": "You are helpful.",
}
async def test_returns_response_when_documents_found(self, rag, chatbot_config):
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[{
"payload": {"text": "Test content", "file_name": "test.pdf", "page_number": 1},
"score": 0.8,
}]), \
patch.object(rag.llm_svc, "generate", new_callable=AsyncMock, return_value={
"content": "Test response",
"tokens_used": 100,
"model": "test-model",
}):
result = await rag.process_query(
query="What is the test?",
collection_name="test-collection",
chatbot_config=chatbot_config,
language="en",
)
assert result["response"] == "Test response"
assert len(result["sources"]) == 1
assert result["sources"][0].score == 0.8
async def test_returns_graceful_message_on_embedding_failure(self, rag, chatbot_config):
with patch.object(rag.embedding_svc, "embed_text", side_effect=Exception("Embedding failed")):
result = await rag.process_query(
query="Test query",
collection_name="test-collection",
chatbot_config=chatbot_config,
)
assert "trouble" in result["response"].lower()
assert result["sources"] == []
async def test_language_instruction_injected_for_french(self, rag, chatbot_config):
injected_prompt = None
async def capture_generate(messages, **kwargs):
nonlocal injected_prompt
injected_prompt = messages[0]["content"]
return {"content": "Bonjour", "tokens_used": 10, "model": "test"}
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[]), \
patch.object(rag.llm_svc, "generate", side_effect=capture_generate):
await rag.process_query(
query="Bonjour",
collection_name="test",
chatbot_config=chatbot_config,
language="fr",
)
assert injected_prompt is not None
assert "French" in injected_prompt
async def test_no_language_instruction_for_english(self, rag, chatbot_config):
injected_prompt = None
async def capture_generate(messages, **kwargs):
nonlocal injected_prompt
injected_prompt = messages[0]["content"]
return {"content": "Hello", "tokens_used": 10, "model": "test"}
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[]), \
patch.object(rag.llm_svc, "generate", side_effect=capture_generate):
await rag.process_query(
query="Hello",
collection_name="test",
chatbot_config=chatbot_config,
language="en",
)
assert injected_prompt is not None
# English should NOT inject a language instruction
assert "Respond in English" not in injected_prompt
async def test_empty_result_when_no_documents(self, rag, chatbot_config):
with patch.object(rag.embedding_svc, "embed_text", return_value=[0.1] * 1536), \
patch.object(rag.vector_svc, "search", return_value=[]), \
patch.object(rag.llm_svc, "generate", new_callable=AsyncMock, return_value={
"content": "I don't have info on that.",
"tokens_used": 20,
"model": "test",
}):
result = await rag.process_query(
query="What is X?",
collection_name="empty-collection",
chatbot_config=chatbot_config,
)
assert result["sources"] == []
assert result["response"] == "I don't have info on that."

125
tests/test_upload.py Normal file
View File

@@ -0,0 +1,125 @@
"""Tests for upload endpoints."""
import pytest
from unittest.mock import MagicMock, patch
AUTH = {"Authorization": "Bearer test-token"}
class TestUploadAuth:
def test_logo_upload_requires_auth(self, client):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"fake-image-data", "image/png")},
)
assert resp.status_code == 401
class TestUploadLogoValidation:
def test_rejects_unsupported_file_type(self, client):
with patch("app.routers.upload.get_supabase"):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("doc.pdf", b"PDF content", "application/pdf")},
headers=AUTH,
)
assert resp.status_code == 400
assert "Invalid file type" in resp.json()["detail"]
def test_rejects_file_over_2mb(self, client):
big_bytes = b"x" * (2 * 1024 * 1024 + 1)
with patch("app.routers.upload.get_supabase"):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("big.png", big_bytes, "image/png")},
headers=AUTH,
)
assert resp.status_code == 413
@pytest.mark.parametrize("mime,filename", [
("image/png", "logo.png"),
("image/jpeg", "photo.jpg"),
("image/gif", "anim.gif"),
("image/svg+xml", "icon.svg"),
("image/webp", "img.webp"),
])
def test_accepts_all_allowed_image_types(self, client, mime, filename):
fake_url = "https://cdn.example.com/logos/test.png"
sb = MagicMock()
sb.storage.from_.return_value.upload.return_value = MagicMock()
sb.storage.from_.return_value.get_public_url.return_value = fake_url
sb.auth = MagicMock()
with patch("app.routers.upload.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/upload/logo",
files={"file": (filename, b"image-bytes", mime)},
headers=AUTH,
)
assert resp.status_code == 200
assert resp.json()["url"] == fake_url
class TestUploadLogoSuccess:
def test_returns_public_url(self, client):
public_url = "https://storage.example.com/logos/test-user-id/abc123.png"
sb = MagicMock()
sb.storage.from_.return_value.upload.return_value = MagicMock()
sb.storage.from_.return_value.get_public_url.return_value = public_url
sb.auth = MagicMock()
with patch("app.routers.upload.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"fake-png-data", "image/png")},
headers=AUTH,
)
assert resp.status_code == 200
body = resp.json()
assert "url" in body
assert body["url"] == public_url
def test_upload_path_uses_user_id(self, client):
"""Storage path should be scoped to the authenticated user's ID."""
sb = MagicMock()
upload_mock = sb.storage.from_.return_value.upload
sb.storage.from_.return_value.get_public_url.return_value = "https://url"
sb.auth = MagicMock()
# Track the actual user returned by auth
auth_user = MagicMock()
auth_user.id = "test-user-id"
sb.auth.get_user.return_value = MagicMock(user=auth_user)
# Also mock user_profiles check
profile_mock = MagicMock()
profile_mock.select.return_value = profile_mock
profile_mock.eq.return_value = profile_mock
profile_mock.execute.return_value = MagicMock(data=[])
sb.table.return_value = profile_mock
with patch("app.routers.upload.get_supabase", return_value=sb), \
patch("app.dependencies.get_supabase", return_value=sb):
client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"data", "image/png")},
headers=AUTH,
)
upload_mock.assert_called_once()
call_kwargs = upload_mock.call_args
path = call_kwargs[1].get("path") or call_kwargs[0][0]
# Path should start with the user's ID
assert path.startswith("test-user-id")
def test_storage_failure_returns_500(self, client):
sb = MagicMock()
sb.storage.from_.return_value.upload.side_effect = Exception("Storage error")
sb.auth = MagicMock()
with patch("app.routers.upload.get_supabase", return_value=sb):
resp = client.post(
"/api/v1/upload/logo",
files={"file": ("logo.png", b"data", "image/png")},
headers=AUTH,
)
assert resp.status_code == 500

151
tests/test_widget.py Normal file
View File

@@ -0,0 +1,151 @@
"""
Tests for the widget.js endpoint and the JS bundle it generates.
GET /widget.js must:
- Return 200 with application/javascript content-type
- Include CORS header (any site can load it as a <script>)
- Cache-Control must be set
- Body must be valid JavaScript (basic structural checks)
- APP_URL placeholder must be replaced with the real app URL
- Must NOT expose the raw __APP_URL__ placeholder
- Must contain the public API surface (window.Contexta)
- Must contain the chatbot ID read logic (data-chatbot)
- Must be a self-executing IIFE
"""
import pytest
from unittest.mock import patch
from app.services.widget import generate_widget_js
# ── Unit tests: generate_widget_js() ──────────────────────────────────────────
class TestGenerateWidgetJs:
def test_replaces_app_url_placeholder(self):
js = generate_widget_js("https://app.example.com")
assert "https://app.example.com" in js
def test_strips_trailing_slash(self):
js = generate_widget_js("https://app.example.com/")
assert "https://app.example.com/" not in js
assert "https://app.example.com" in js
def test_no_raw_placeholder_in_output(self):
js = generate_widget_js("https://app.example.com")
assert "__APP_URL__" not in js
def test_constructs_chat_url_pattern(self):
js = generate_widget_js("https://app.example.com")
# The JS concatenates base + '/chat/' + chatbotId
assert "/chat/" in js
def test_is_iife(self):
js = generate_widget_js("https://app.example.com")
assert "(function" in js
assert "}());" in js or "})()" in js or "}())" in js or "()})" in js or "}());" in js
def test_contains_double_init_guard(self):
js = generate_widget_js("https://app.example.com")
assert "__ctxa" in js
def test_reads_data_chatbot_attribute(self):
js = generate_widget_js("https://app.example.com")
assert "data-chatbot" in js
def test_uses_document_current_script(self):
js = generate_widget_js("https://app.example.com")
assert "currentScript" in js
def test_public_api_exposed(self):
js = generate_widget_js("https://app.example.com")
assert "window.Contexta" in js
assert "open" in js
assert "close" in js
assert "toggle" in js
def test_lazy_iframe_loading(self):
"""Iframe src should only be set on first open, not at init time."""
js = generate_widget_js("https://app.example.com")
# The _loaded flag pattern ensures lazy load
assert "_loaded" in js or "frameLoaded" in js or "loaded" in js.lower()
def test_escape_key_closes_panel(self):
js = generate_widget_js("https://app.example.com")
assert "Escape" in js
def test_aria_attributes_present(self):
js = generate_widget_js("https://app.example.com")
assert "aria-label" in js
assert "aria-expanded" in js
def test_mobile_responsive_css(self):
js = generate_widget_js("https://app.example.com")
assert "480px" in js # mobile breakpoint
def test_high_z_index(self):
"""Widget must sit on top of host-page content."""
js = generate_widget_js("https://app.example.com")
# 2147483647 is the highest browser-supported z-index
assert "2147483647" in js
def test_sandbox_attribute_on_iframe(self):
js = generate_widget_js("https://app.example.com")
assert "sandbox" in js
assert "allow-scripts" in js
def test_returns_string(self):
result = generate_widget_js("https://app.example.com")
assert isinstance(result, str)
assert len(result) > 500 # must be a substantial bundle
# ── Integration tests: GET /widget.js ─────────────────────────────────────────
class TestWidgetEndpoint:
def test_returns_200(self, client):
resp = client.get("/widget.js")
assert resp.status_code == 200
def test_content_type_is_javascript(self, client):
resp = client.get("/widget.js")
assert "javascript" in resp.headers.get("content-type", "")
def test_cors_header_allows_any_origin(self, client):
resp = client.get("/widget.js")
assert resp.headers.get("access-control-allow-origin") == "*"
def test_cache_control_is_set(self, client):
resp = client.get("/widget.js")
cc = resp.headers.get("cache-control", "")
assert "public" in cc
assert "max-age" in cc
def test_x_content_type_options(self, client):
resp = client.get("/widget.js")
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_body_contains_app_url(self, client):
resp = client.get("/widget.js")
# The test client uses the real settings.app_url baked into the JS
assert "/chat/" in resp.text
def test_body_does_not_contain_placeholder(self, client):
resp = client.get("/widget.js")
assert "__APP_URL__" not in resp.text
def test_body_contains_public_api(self, client):
resp = client.get("/widget.js")
assert "window.Contexta" in resp.text
def test_body_is_non_empty(self, client):
resp = client.get("/widget.js")
assert len(resp.text) > 200
def test_no_auth_required(self, client):
"""Widget.js must be publicly accessible — no Authorization header."""
resp = client.get("/widget.js")
assert resp.status_code == 200
def test_get_only(self, client):
"""Should not accept POST."""
resp = client.post("/widget.js")
assert resp.status_code == 405