mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-13 08:03:54 +00:00
- 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>
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""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
|