mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
feat: add appointments, campaigns, admin, storage, tests and various updates
- Add new routers: admin, appointments, campaigns - Add storage service and logging config - Add migrations directory and test suite with pytest config - Add supabase_migration_features.sql - Update models, dependencies, config, and existing routers - Remove whatsapp_service (deleted) - Update pyproject.toml and uv.lock dependencies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
77
tests/conftest.py
Normal file
77
tests/conftest.py
Normal 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
94
tests/test_admin.py
Normal 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
255
tests/test_analytics.py
Normal 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
554
tests/test_appointments.py
Normal 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
119
tests/test_auth.py
Normal 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
81
tests/test_billing.py
Normal 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
453
tests/test_campaigns.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
Tests for campaign endpoints:
|
||||
GET /api/v1/campaigns
|
||||
POST /api/v1/campaigns
|
||||
POST /api/v1/campaigns/{id}/send
|
||||
DELETE /api/v1/campaigns/{id}
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def make_user(uid="user-1"):
|
||||
u = MagicMock()
|
||||
u.id = uid
|
||||
u.email = "user@example.com"
|
||||
return u
|
||||
|
||||
|
||||
SAMPLE_CAMPAIGN = {
|
||||
"id": "camp-1",
|
||||
"chatbot_id": "chatbot-1",
|
||||
"title": "Summer Sale",
|
||||
"message": "Big discount today!",
|
||||
"status": "draft",
|
||||
"recipients_count": 10,
|
||||
"sent_count": 0,
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
"sent_at": None,
|
||||
}
|
||||
|
||||
|
||||
def make_supabase(plan="starter", company_id="company-1",
|
||||
campaigns=None, campaign=None,
|
||||
subscribers_count=10):
|
||||
sb = MagicMock()
|
||||
|
||||
def table_side(name):
|
||||
t = MagicMock()
|
||||
for m in ("select", "insert", "update", "delete", "eq",
|
||||
"in_", "order", "range", "limit", "neq"):
|
||||
getattr(t, m).return_value = t
|
||||
t.count = subscribers_count
|
||||
|
||||
if name == "subscriptions":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": plan}]))
|
||||
elif name == "companies":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{"id": company_id}]))
|
||||
elif name == "chatbots":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[
|
||||
{"id": "chatbot-1", "company_id": company_id}
|
||||
]))
|
||||
elif name == "campaigns":
|
||||
if campaign is not None:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[campaign]))
|
||||
else:
|
||||
t.execute = MagicMock(return_value=MagicMock(
|
||||
data=campaigns if campaigns is not None else [SAMPLE_CAMPAIGN]
|
||||
))
|
||||
elif name == "channel_connections":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[
|
||||
{"bot_token": "123456:ABCdef"}
|
||||
]))
|
||||
elif name == "channel_sessions":
|
||||
mock_result = MagicMock()
|
||||
mock_result.data = [
|
||||
{"external_id": "tg:123456:111"},
|
||||
{"external_id": "tg:123456:222"},
|
||||
]
|
||||
mock_result.count = subscribers_count
|
||||
t.execute = MagicMock(return_value=mock_result)
|
||||
else:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
|
||||
sb.table.side_effect = table_side
|
||||
return sb
|
||||
|
||||
|
||||
# ── Tests: list campaigns ──────────────────────────────────────────────────────
|
||||
|
||||
class TestListCampaigns:
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.get("/api/v1/campaigns")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_free_plan_returns_402(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="free")
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/campaigns",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 402
|
||||
|
||||
def test_returns_campaign_list(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/campaigns",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["title"] == "Summer Sale"
|
||||
assert data[0]["status"] == "draft"
|
||||
|
||||
def test_empty_when_no_chatbots(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "chatbots":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/campaigns",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_pagination_accepted(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.get("/api/v1/campaigns?page=2&limit=5",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Tests: create campaign ─────────────────────────────────────────────────────
|
||||
|
||||
class TestCreateCampaign:
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.post("/api/v1/campaigns",
|
||||
json={"chatbot_id": "chatbot-1",
|
||||
"title": "X", "message": "Y"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_creates_campaign_draft(self, client):
|
||||
user = make_user()
|
||||
new_camp = {**SAMPLE_CAMPAIGN, "id": "camp-new"}
|
||||
sb = make_supabase(plan="starter")
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "campaigns":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[new_camp]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns",
|
||||
json={"chatbot_id": "chatbot-1",
|
||||
"title": "Summer Sale",
|
||||
"message": "Big discount today!"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["status"] == "draft"
|
||||
assert data["id"] == "camp-new"
|
||||
|
||||
def test_missing_fields_returns_422(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns",
|
||||
json={"chatbot_id": "chatbot-1"}, # missing title/message
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_404_when_chatbot_not_owned(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "chatbots":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns",
|
||||
json={"chatbot_id": "stranger-bot",
|
||||
"title": "X", "message": "Y"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_recipients_count_uses_subscriber_count(self, client):
|
||||
"""recipients_count should equal the number of Telegram channel_sessions."""
|
||||
user = make_user()
|
||||
inserted = []
|
||||
sb = make_supabase(plan="starter", subscribers_count=42)
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "campaigns":
|
||||
orig_insert = t.insert
|
||||
def track_insert(data):
|
||||
inserted.append(data)
|
||||
return t
|
||||
t.insert = track_insert
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{**SAMPLE_CAMPAIGN, "recipients_count": 42}]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns",
|
||||
json={"chatbot_id": "chatbot-1",
|
||||
"title": "T", "message": "M"},
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["recipients_count"] == 42
|
||||
|
||||
|
||||
# ── Tests: send campaign ───────────────────────────────────────────────────────
|
||||
|
||||
class TestSendCampaign:
|
||||
def _make_owned_campaign(self, company_id="company-1", status="draft"):
|
||||
return {**SAMPLE_CAMPAIGN, "status": status,
|
||||
"chatbots": {"company_id": company_id}}
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.post("/api/v1/campaigns/camp-1/send")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_sends_to_subscribers(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign()
|
||||
sent_camp = {**SAMPLE_CAMPAIGN, "status": "sent", "sent_count": 2}
|
||||
|
||||
call_count = {"n": 0}
|
||||
def table_side(name):
|
||||
t = MagicMock()
|
||||
for m in ("select", "insert", "update", "delete",
|
||||
"eq", "in_", "order", "range", "limit", "neq"):
|
||||
getattr(t, m).return_value = t
|
||||
if name == "subscriptions":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
|
||||
elif name == "companies":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
|
||||
elif name == "campaigns":
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[camp]))
|
||||
else:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[sent_camp]))
|
||||
elif name == "channel_connections":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[
|
||||
{"bot_token": "123456:ABCdef"}
|
||||
]))
|
||||
elif name == "channel_sessions":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[
|
||||
{"external_id": "tg:123456:111"},
|
||||
{"external_id": "tg:123456:222"},
|
||||
]))
|
||||
else:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
|
||||
sb = MagicMock()
|
||||
sb.table.side_effect = table_side
|
||||
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb), \
|
||||
patch("app.services.telegram_service.send_message", new_callable=AsyncMock) as mock_send:
|
||||
mock_send.return_value = None
|
||||
resp = client.post("/api/v1/campaigns/camp-1/send",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "sent"
|
||||
assert mock_send.call_count == 2
|
||||
|
||||
def test_already_sent_returns_400(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign(status="sent")
|
||||
sb = make_supabase(plan="starter", campaign=camp)
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns/camp-1/send",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 400
|
||||
assert "already sent" in resp.json()["detail"].lower()
|
||||
|
||||
def test_400_when_no_telegram_connection(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign()
|
||||
sb = make_supabase(plan="starter", campaign=camp)
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "channel_connections":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns/camp-1/send",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 400
|
||||
assert "telegram" in resp.json()["detail"].lower()
|
||||
|
||||
def test_403_for_other_companys_campaign(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign(company_id="company-OTHER")
|
||||
sb = make_supabase(plan="starter", company_id="company-1", campaign=camp)
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns/camp-1/send",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_404_when_campaign_not_found(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "campaigns":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.post("/api/v1/campaigns/nonexistent/send",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_failures_do_not_crash(self, client):
|
||||
"""If some subscribers fail, the campaign still completes."""
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign()
|
||||
sent_camp = {**SAMPLE_CAMPAIGN, "status": "sent", "sent_count": 1}
|
||||
|
||||
call_count = {"n": 0}
|
||||
def table_side(name):
|
||||
t = MagicMock()
|
||||
for m in ("select", "insert", "update", "delete",
|
||||
"eq", "in_", "order", "range", "limit", "neq"):
|
||||
getattr(t, m).return_value = t
|
||||
if name == "subscriptions":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{"plan": "starter"}]))
|
||||
elif name == "companies":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[{"id": "company-1"}]))
|
||||
elif name == "campaigns":
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[camp]))
|
||||
else:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[sent_camp]))
|
||||
elif name == "channel_connections":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[
|
||||
{"bot_token": "123456:ABCdef"}
|
||||
]))
|
||||
elif name == "channel_sessions":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[
|
||||
{"external_id": "tg:123456:111"},
|
||||
{"external_id": "bad-format"}, # malformed — should be skipped
|
||||
]))
|
||||
else:
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
|
||||
sb = MagicMock()
|
||||
sb.table.side_effect = table_side
|
||||
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb), \
|
||||
patch("app.services.telegram_service.send_message", new_callable=AsyncMock) as mock_send:
|
||||
mock_send.return_value = None
|
||||
resp = client.post("/api/v1/campaigns/camp-1/send",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "sent"
|
||||
|
||||
|
||||
# ── Tests: delete campaign ─────────────────────────────────────────────────────
|
||||
|
||||
class TestDeleteCampaign:
|
||||
def _make_owned_campaign(self, company_id="company-1", status="draft"):
|
||||
return {**SAMPLE_CAMPAIGN, "status": status,
|
||||
"chatbots": {"company_id": company_id}}
|
||||
|
||||
def test_requires_auth(self, client):
|
||||
resp = client.delete("/api/v1/campaigns/camp-1")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_deletes_draft_campaign(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign(status="draft")
|
||||
sb = make_supabase(plan="starter", campaign=camp)
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/campaigns/camp-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_deletes_sent_campaign(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign(status="sent")
|
||||
sb = make_supabase(plan="starter", campaign=camp)
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/campaigns/camp-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_cannot_delete_sending_campaign(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign(status="sending")
|
||||
sb = make_supabase(plan="starter", campaign=camp)
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/campaigns/camp-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_403_for_other_companys_campaign(self, client):
|
||||
user = make_user()
|
||||
camp = self._make_owned_campaign(company_id="company-OTHER")
|
||||
sb = make_supabase(plan="starter", company_id="company-1", campaign=camp)
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/campaigns/camp-1",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_404_when_not_found(self, client):
|
||||
user = make_user()
|
||||
sb = make_supabase(plan="starter")
|
||||
original = sb.table.side_effect
|
||||
def patched(name):
|
||||
t = original(name)
|
||||
if name == "campaigns":
|
||||
t.execute = MagicMock(return_value=MagicMock(data=[]))
|
||||
return t
|
||||
sb.table.side_effect = patched
|
||||
with patch("app.routers.campaigns.get_current_user", return_value=user), \
|
||||
patch("app.routers.campaigns.get_supabase", return_value=sb):
|
||||
resp = client.delete("/api/v1/campaigns/nonexistent",
|
||||
headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
258
tests/test_channels.py
Normal file
258
tests/test_channels.py
Normal 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
289
tests/test_chat.py
Normal 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
90
tests/test_chatbots.py
Normal 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
285
tests/test_config_plans.py
Normal 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
267
tests/test_documents.py
Normal 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
428
tests/test_inbox.py
Normal 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
405
tests/test_leads.py
Normal 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
212
tests/test_marketplace.py
Normal 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
107
tests/test_models.py
Normal 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
118
tests/test_rag.py
Normal 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
125
tests/test_upload.py
Normal 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
151
tests/test_widget.py
Normal 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
|
||||
Reference in New Issue
Block a user