"""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