Files
contexta_be/tests/test_chat.py
2026-04-26 18:51:48 +00:00

308 lines
13 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_when_high_confidence_no_keyword(self, client):
# High confidence + no keyword match → no handoff
chatbot = _make_chatbot(handoff_enabled=True, handoff_keywords=["human"])
rag_result = {"response": "We open at 9am.", "sources": [], "confidence_score": 0.85,
"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
def test_handoff_triggered_by_low_confidence(self, client):
# Low confidence (no sources) triggers handoff even without a keyword
chatbot = _make_chatbot(handoff_enabled=True, handoff_keywords=["human"])
rag_result = {"response": "I'm not sure.", "sources": [], "confidence_score": 0.1,
"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": "Tell me about quantum physics", "language": "en"})
assert resp.status_code == 200
assert resp.json()["handoff"] is True
assert resp.json()["low_confidence"] is True
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