""" Copyright 2024-2026 ChatterMate Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import pytest from datetime import datetime from sqlalchemy import text from app.repositories.chat import ChatRepository from app.models.chat_history import ChatHistory from app.models.customer import Customer from app.models.agent import Agent, AgentType from app.models.session_to_agent import SessionToAgent, SessionStatus from app.models.user import User, UserGroup from uuid import uuid4, UUID from app.channels.interactive import from_options from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, encode_sent_choices from app.models.schemas.chat import ChatDetailResponse @pytest.fixture def chat_repo(db): return ChatRepository(db) @pytest.fixture def test_data(db, test_organization_id): """Create test data including customer, agent, user, and session""" customer = Customer( id=uuid4(), email="customer@test.com", full_name="Test Customer", organization_id=test_organization_id ) db.add(customer) agent = Agent( id=uuid4(), name="test-agent", display_name="Test Agent", organization_id=test_organization_id, description="Test agent description", agent_type=AgentType.CUSTOMER_SUPPORT, instructions=["Be helpful"], is_active=True ) db.add(agent) user = User( id=uuid4(), email="user@test.com", full_name="Test User", hashed_password="dummy_hash", is_active=True, organization_id=test_organization_id ) db.add(user) # Create a test group group = UserGroup( id=uuid4(), name="Test Group", description="Test group description", organization_id=test_organization_id ) db.add(group) db.flush() session_id = uuid4() session = SessionToAgent( session_id=session_id, organization_id=test_organization_id, agent_id=agent.id, user_id=user.id, customer_id=customer.id, status=SessionStatus.OPEN, group_id=group.id ) db.add(session) # Add some chat messages messages = [] for i in range(3): message = ChatHistory( organization_id=test_organization_id, session_id=session_id, customer_id=customer.id, agent_id=agent.id, user_id=user.id, message=f"Test message {i}", message_type="text", attributes={"key": f"value{i}"} ) messages.append(message) db.add(message) db.commit() return { "org_id": test_organization_id, "customer": customer, "agent": agent, "user": user, "session_id": session_id, "messages": messages, "group": group } def test_create_message(chat_repo, test_data): """Test creating a new chat message""" message_data = { "organization_id": test_data["org_id"], "session_id": test_data["session_id"], "customer_id": test_data["customer"].id, "agent_id": test_data["agent"].id, "user_id": test_data["user"].id, "message": "New test message", "message_type": "text", "attributes": {"test": "value"} } message = chat_repo.create_message(message_data) assert message is not None assert message.message == "New test message" assert message.message_type == "text" assert message.attributes == {"test": "value"} @pytest.mark.asyncio async def test_get_session_history(chat_repo, test_data): """Test retrieving chat history for a session""" history = await chat_repo.get_session_history(test_data["session_id"]) assert len(history) == 3 for i, msg in enumerate(history): assert msg.message == f"Test message {i}" assert msg.user is not None assert msg.agent is not None def test_get_user_history(chat_repo, test_data): """Test retrieving chat history for a user""" history = chat_repo.get_user_history(test_data["user"].id) assert len(history) == 3 for msg in history: assert msg.user_id == test_data["user"].id def test_get_recent_chats(chat_repo, test_data): """Test retrieving recent chat overviews""" chats = chat_repo.get_recent_chats( organization_id=test_data["org_id"], user_id=test_data["user"].id, user_groups=[str(test_data["group"].id)] ) assert len(chats) == 1 chat = chats[0] assert chat["customer"]["email"] == "customer@test.com" assert chat["agent"]["name"] == "test-agent" assert chat["message_count"] == 3 assert chat["status"] == SessionStatus.OPEN assert chat["group_id"] == str(test_data["group"].id) def test_get_recent_chats_preview_is_the_latest_message(chat_repo, test_data, db): """The preview is the newest message, decrypted — not the lexicographic max, which is what an aggregate over ciphertext would return.""" db.add(ChatHistory( organization_id=test_data["org_id"], session_id=test_data["session_id"], customer_id=test_data["customer"].id, agent_id=test_data["agent"].id, user_id=test_data["user"].id, # Sorts before every "Test message N" alphabetically, so a max() would miss it. message="A final reply from the agent", message_type="text", )) db.commit() chats = chat_repo.get_recent_chats( organization_id=test_data["org_id"], user_id=test_data["user"].id, user_groups=[str(test_data["group"].id)] ) assert chats[0]["last_message"] == "A final reply from the agent" def test_get_recent_chats_preview_reads_legacy_plaintext(chat_repo, test_data, db): """Rows written before encryption at rest have no prefix and must still show.""" legacy = ChatHistory( organization_id=test_data["org_id"], session_id=test_data["session_id"], customer_id=test_data["customer"].id, agent_id=test_data["agent"].id, user_id=test_data["user"].id, message="placeholder", message_type="text", created_at=datetime(2030, 1, 1), ) db.add(legacy) db.commit() # Overwrite in raw SQL so the value stays plaintext, as it would be in a row # written before encryption at rest. db.execute( text("UPDATE chat_history SET message = :message WHERE id = :id"), {"message": "legacy plaintext message", "id": legacy.id}, ) db.commit() chats = chat_repo.get_recent_chats( organization_id=test_data["org_id"], user_id=test_data["user"].id, user_groups=[str(test_data["group"].id)] ) assert chats[0]["last_message"] == "legacy plaintext message" @pytest.mark.asyncio async def test_check_session_access(chat_repo, test_data): """Test checking session access""" # Test access with user_id has_access = await chat_repo.check_session_access( test_data["session_id"], test_data["user"].id, [str(test_data["group"].id)] ) assert has_access is True # Test access with group has_access = await chat_repo.check_session_access( test_data["session_id"], uuid4(), # Different user [str(test_data["group"].id)] ) assert has_access is True # Test no access has_access = await chat_repo.check_session_access( test_data["session_id"], uuid4(), # Different user [str(uuid4())] # Different group ) assert has_access is False @pytest.mark.asyncio async def test_get_chat_detail(chat_repo, test_data): """Test retrieving detailed chat information""" detail = await chat_repo.get_chat_detail( test_data["session_id"], test_data["org_id"] ) assert detail is not None assert detail["customer"]["email"] == "customer@test.com" assert detail["customer"]["meta_data"] is None assert detail["agent"]["name"] == "test-agent" assert detail["status"] == SessionStatus.OPEN assert detail["group_id"] == str(test_data["group"].id) assert detail["user_id"] == test_data["user"].id assert detail["user_name"] == "Test User" assert len(detail["messages"]) == 3 @pytest.mark.asyncio async def test_get_nonexistent_chat_detail(chat_repo): """Test retrieving detail for nonexistent chat""" detail = await chat_repo.get_chat_detail(uuid4(), uuid4()) assert detail is None @pytest.mark.asyncio async def test_get_chat_detail_includes_customer_meta_data(chat_repo, db, test_data): """meta_data set on the customer (e.g. via /generate-token custom_data) is surfaced in the chat detail response so the inbox can display it.""" test_data["customer"].meta_data = { "student_name": "Aarav Krishnan", "center_name": "Special Academy U12" } db.commit() detail = await chat_repo.get_chat_detail( test_data["session_id"], test_data["org_id"] ) assert detail["customer"]["meta_data"] == { "student_name": "Aarav Krishnan", "center_name": "Special Academy U12" } @pytest.mark.asyncio async def test_get_chat_detail_exposes_channel_delivery_fields(chat_repo, db, test_data): """The inbox matches receipts by id, shows ticks, and reads the sent choices decrypted — and the response schema must not strip any of it.""" sent = chat_repo.create_message({ "message": "Confirm?", "message_type": "bot", "session_id": test_data["session_id"], "organization_id": test_data["org_id"], "external_message_id": "wamid.X", "attributes": {SENT_CHOICES_ATTRIBUTE: encode_sent_choices( from_options("Confirm?", ["Yes", "No"]), as_text=False)}, }) sent.channel_status = "read" db.commit() detail = await chat_repo.get_chat_detail(test_data["session_id"], test_data["org_id"]) message = ChatDetailResponse(**detail).model_dump()["messages"][-1] assert message["id"] == sent.id assert message["external_message_id"] == "wamid.X" assert message["channel_status"] == "read" assert [c["text"] for c in message["attributes"][SENT_CHOICES_ATTRIBUTE]["choices"]] == ["Yes", "No"] # still encrypted at rest db.refresh(sent) assert "Yes" not in str(sent.attributes) def test_app_sent_messages_cost_no_ai_quota(chat_repo, test_data): common = {"session_id": test_data["session_id"], "organization_id": test_data["org_id"], "message_type": "bot"} before = chat_repo.get_message_count_for_period(test_data["org_id"], None, None) chat_repo.create_message({**common, "message": "AI answer", "attributes": {"source": "ai"}}) chat_repo.create_message({**common, "message": "Old reply"}) chat_repo.create_message({**common, "message": "Fee reminder", "attributes": {"source": "app"}}) assert chat_repo.get_message_count_for_period(test_data["org_id"], None, None) == before + 2