"""Tests for Copilot chat, action, history, and voice endpoints with mocked OpenRouter.""" import base64 import json import uuid from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.database import Base, get_db from app.main import app from app.models.copilot import CopilotChat, CopilotRole, CopilotSession from app.models.user import User, UserRole from app.services.auth_service import hash_password @pytest_asyncio.fixture async def sample_vehicle_data(): """Valid vehicle data for creation.""" return { "make": "Mercedes-Benz", "model": "Actros", "fin": "WDB9066351L123456", "year": 2020, "power_kw": 300, "fuel_type": "Diesel", "condition": "used", "availability": "available", "price": 45000.00, "vehicle_type": "lkw", "lkw_type": "sattelzugmaschine", "mileage_km": 120000, } def _mock_chat_json(response_text: str, actions: list | None = None) -> str: """Build a JSON response string as the AI would return.""" return json.dumps({ "response": response_text, "actions": actions or [], }) def _patch_openrouter_chat(content: str): """Patch _call_openrouter_chat to return fixed content. Usage: with _patch_openrouter_chat(content): response = await client.post(...) """ return patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=content, ) class TestCopilotChat: """POST /api/v1/copilot/chat tests.""" @pytest.mark.asyncio async def test_chat_returns_200_with_response_and_actions(self, admin_client): """POST /copilot/chat with valid message returns 200 + response + actions.""" mock_content = _mock_chat_json( "Ich suche nach allen LKWs im Bestand.", [{"type": "search_vehicles", "params": {"type": "lkw"}}], ) with _patch_openrouter_chat(mock_content): response = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Zeige alle LKWs"}, ) assert response.status_code == 200, response.text data = response.json() assert "response" in data assert "actions" in data assert "session_id" in data assert "message_id" in data assert isinstance(data["response"], str) assert len(data["response"]) > 0 assert len(data["actions"]) == 1 assert data["actions"][0]["type"] == "search_vehicles" assert data["actions"][0]["params"]["type"] == "lkw" @pytest.mark.asyncio async def test_chat_empty_message_returns_422(self, admin_client): """POST /copilot/chat with empty message returns 422.""" response = await admin_client.post( "/api/v1/copilot/chat", json={"message": ""}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_chat_no_actions_returns_empty_list(self, admin_client): """POST /copilot/chat with a general question returns empty actions list.""" mock_content = _mock_chat_json("Hallo! Wie kann ich helfen?", []) with _patch_openrouter_chat(mock_content): response = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Hallo"}, ) assert response.status_code == 200 data = response.json() assert data["actions"] == [] @pytest.mark.asyncio async def test_chat_persists_messages_to_db(self, admin_client, db_session): """Chat messages are persisted to the database.""" mock_content = _mock_chat_json("Test response", []) with _patch_openrouter_chat(mock_content): response = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Test message"}, ) assert response.status_code == 200 session_id = response.json()["session_id"] # Verify messages in DB via a fresh query from sqlalchemy import select stmt = select(CopilotChat).where( CopilotChat.session_id == uuid.UUID(session_id) ) result = await db_session.execute(stmt) messages = list(result.scalars().all()) assert len(messages) == 2 # user + assistant roles = [m.role.value for m in messages] assert "user" in roles assert "assistant" in roles @pytest.mark.asyncio async def test_chat_contact_search_action(self, admin_client): """Copilot can propose a contact search action.""" mock_content = _mock_chat_json( "Ich suche nach Kontakten mit dem Namen Müller.", [{"type": "search_contacts", "params": {"search": "Müller"}}], ) with _patch_openrouter_chat(mock_content): response = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Suche Kontakt Müller"}, ) assert response.status_code == 200 data = response.json() assert len(data["actions"]) == 1 assert data["actions"][0]["type"] == "search_contacts" assert data["actions"][0]["params"]["search"] == "Müller" @pytest.mark.asyncio async def test_chat_requires_auth(self, client): """POST /copilot/chat without auth returns 401.""" response = await client.post( "/api/v1/copilot/chat", json={"message": "test"}, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_chat_continues_existing_session(self, admin_client): """POST /copilot/chat with session_id continues the same session.""" mock_content1 = _mock_chat_json("Erste Antwort", []) with _patch_openrouter_chat(mock_content1): resp1 = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Erste Nachricht"}, ) assert resp1.status_code == 200 session_id = resp1.json()["session_id"] mock_content2 = _mock_chat_json("Zweite Antwort", []) with _patch_openrouter_chat(mock_content2): resp2 = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Zweite Nachricht", "session_id": session_id}, ) assert resp2.status_code == 200 assert resp2.json()["session_id"] == session_id class TestCopilotAction: """POST /api/v1/copilot/action tests.""" @pytest.mark.asyncio async def test_action_search_vehicles_returns_200(self, admin_client, sample_vehicle_data): """POST /copilot/action with search_vehicles returns 200 + results.""" # First create a vehicle await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data) response = await admin_client.post( "/api/v1/copilot/action", json={"action": "search_vehicles", "params": {"type": "lkw"}}, ) assert response.status_code == 200, response.text data = response.json() assert data["action"] == "search_vehicles" assert data["success"] is True assert "result" in data assert "items" in data["result"] assert data["result"]["total"] >= 1 @pytest.mark.asyncio async def test_action_search_contacts_returns_200(self, admin_client): """POST /copilot/action with search_contacts returns 200 + results.""" # First create a contact await admin_client.post( "/api/v1/contacts/", json={ "company_name": "Test GmbH", "role": "kaeufer", "address_country": "DE", }, ) response = await admin_client.post( "/api/v1/copilot/action", json={"action": "search_contacts", "params": {"search": "Test"}}, ) assert response.status_code == 200, response.text data = response.json() assert data["action"] == "search_contacts" assert data["success"] is True assert data["result"]["total"] >= 1 @pytest.mark.asyncio async def test_action_invalid_action_returns_400(self, admin_client): """POST /copilot/action with invalid action returns 400.""" response = await admin_client.post( "/api/v1/copilot/action", json={"action": "invalid_action", "params": {}}, ) assert response.status_code == 400 data = response.json() assert "error" in data["detail"] assert data["detail"]["error"]["code"] == "INVALID_ACTION" @pytest.mark.asyncio async def test_action_get_sale_overview_returns_200(self, admin_client): """POST /copilot/action with get_sale_overview returns 200.""" response = await admin_client.post( "/api/v1/copilot/action", json={"action": "get_sale_overview", "params": {}}, ) assert response.status_code == 200, response.text data = response.json() assert data["action"] == "get_sale_overview" assert data["success"] is True assert "items" in data["result"] @pytest.mark.asyncio async def test_action_requires_auth(self, client): """POST /copilot/action without auth returns 401.""" response = await client.post( "/api/v1/copilot/action", json={"action": "search_vehicles", "params": {}}, ) assert response.status_code == 401 class TestCopilotHistory: """GET /api/v1/copilot/history tests.""" @pytest.mark.asyncio async def test_history_returns_200_with_pagination(self, admin_client): """GET /copilot/history returns 200 with paginated history.""" # First create a chat message mock_content = _mock_chat_json("Test", []) with _patch_openrouter_chat(mock_content): await admin_client.post( "/api/v1/copilot/chat", json={"message": "Test message"}, ) response = await admin_client.get("/api/v1/copilot/history?page=1&page_size=20") assert response.status_code == 200, response.text data = response.json() assert "items" in data assert "total" in data assert "page" in data assert "page_size" in data assert data["total"] >= 2 # user + assistant message assert len(data["items"]) >= 2 @pytest.mark.asyncio async def test_history_empty_returns_200(self, admin_client): """GET /copilot/history with no messages returns 200 + empty list.""" response = await admin_client.get("/api/v1/copilot/history") assert response.status_code == 200 data = response.json() assert data["items"] == [] assert data["total"] == 0 @pytest.mark.asyncio async def test_history_requires_auth(self, client): """GET /copilot/history without auth returns 401.""" response = await client.get("/api/v1/copilot/history") assert response.status_code == 401 @pytest.mark.asyncio async def test_history_filtered_by_session(self, admin_client): """GET /copilot/history?session_id=... returns only that session's messages.""" mock_content = _mock_chat_json("Test", []) with _patch_openrouter_chat(mock_content): resp1 = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Session 1 message"}, ) resp2 = await admin_client.post( "/api/v1/copilot/chat", json={"message": "Session 2 message"}, ) session1_id = resp1.json()["session_id"] response = await admin_client.get( f"/api/v1/copilot/history?session_id={session1_id}" ) assert response.status_code == 200 data = response.json() assert data["total"] == 2 # only session 1's user + assistant for item in data["items"]: assert item["session_id"] == session1_id class TestCopilotVoice: """POST /api/v1/copilot/voice tests.""" @pytest.mark.asyncio async def test_voice_returns_200_with_transcription_and_response(self, admin_client): """POST /copilot/voice returns 200 with transcription + chat response.""" mock_content = _mock_chat_json("Ich helfe dir bei der Suche.", []) audio_b64 = base64.b64encode(b"fake-audio-data").decode("utf-8") with patch( "app.services.copilot_service.transcribe_audio", new_callable=AsyncMock, return_value="Zeige alle LKWs", ), _patch_openrouter_chat(mock_content): response = await admin_client.post( "/api/v1/copilot/voice", json={"audio": audio_b64, "mime_type": "audio/webm"}, ) assert response.status_code == 200, response.text data = response.json() assert "transcription" in data assert "response" in data assert "actions" in data assert "session_id" in data assert "message_id" in data assert isinstance(data["transcription"], str) assert len(data["transcription"]) > 0 assert data["transcription"] == "Zeige alle LKWs" @pytest.mark.asyncio async def test_voice_empty_audio_returns_422(self, admin_client): """POST /copilot/voice with empty audio returns 422.""" response = await admin_client.post( "/api/v1/copilot/voice", json={"audio": ""}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_voice_requires_auth(self, client): """POST /copilot/voice without auth returns 401.""" audio_b64 = base64.b64encode(b"fake").decode("utf-8") response = await client.post( "/api/v1/copilot/voice", json={"audio": audio_b64}, ) assert response.status_code == 401 class TestCopilotServiceUnit: """Unit tests for copilot_service internal functions.""" def test_parse_ai_response_valid_json(self): """_parse_ai_response correctly parses valid JSON.""" from app.services.copilot_service import _parse_ai_response raw = json.dumps({ "response": "Ich suche LKWs.", "actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}], }) result = _parse_ai_response(raw) assert result["response"] == "Ich suche LKWs." assert len(result["actions"]) == 1 assert result["actions"][0]["type"] == "search_vehicles" def test_parse_ai_response_with_code_fence(self): """_parse_ai_response handles markdown code fences.""" from app.services.copilot_service import _parse_ai_response raw = "```json\n" + json.dumps({ "response": "Test", "actions": [], }) + "\n```" result = _parse_ai_response(raw) assert result["response"] == "Test" assert result["actions"] == [] def test_parse_ai_response_fallback_to_raw_text(self): """_parse_ai_response falls back to raw text when no JSON found.""" from app.services.copilot_service import _parse_ai_response raw = "Das ist keine JSON-Antwort." result = _parse_ai_response(raw) assert result["response"] == raw assert result["actions"] == [] def test_parse_ai_response_invalid_actions_filtered(self): """_parse_ai_response filters out invalid action structures.""" from app.services.copilot_service import _parse_ai_response raw = json.dumps({ "response": "Test", "actions": [ {"type": "search_vehicles", "params": {}}, {"invalid": "no type"}, "not a dict", ], }) result = _parse_ai_response(raw) assert len(result["actions"]) == 1 assert result["actions"][0]["type"] == "search_vehicles" def test_system_prompt_contains_erp_context(self): """System prompt includes ERP context and available actions.""" from app.utils.copilot_prompt import build_system_prompt prompt = build_system_prompt() assert "ERP" in prompt assert "Fahrzeugtypen" in prompt assert "Kontakttypen" in prompt assert "Verkaufsworkflow" in prompt assert "search_vehicles" in prompt assert "search_contacts" in prompt assert "get_sale_overview" in prompt assert "create_vehicle" in prompt assert "create_contact" in prompt assert "actions" in prompt assert "response" in prompt def test_action_registry_has_all_actions(self): """Action registry contains all 5 defined actions.""" from app.utils.copilot_actions import ACTION_REGISTRY assert len(ACTION_REGISTRY) == 5 assert "search_vehicles" in ACTION_REGISTRY assert "search_contacts" in ACTION_REGISTRY assert "get_sale_overview" in ACTION_REGISTRY assert "create_vehicle" in ACTION_REGISTRY assert "create_contact" in ACTION_REGISTRY @pytest.mark.asyncio async def test_execute_action_unknown_raises_value_error(self, db_session): """execute_action raises ValueError for unknown action type.""" from app.utils.copilot_actions import execute_action with pytest.raises(ValueError, match="Unknown action type"): await execute_action(db_session, "nonexistent_action", {}) @pytest.mark.asyncio async def test_get_or_create_session_creates_new(self, db_session, admin_user): """_get_or_create_session creates a new session when no session_id given.""" from app.services.copilot_service import _get_or_create_session session = await _get_or_create_session( db_session, admin_user.id, first_message="Test message" ) assert session.title == "Test message" assert session.user_id == admin_user.id @pytest.mark.asyncio async def test_get_or_create_session_returns_existing(self, db_session, admin_user): """_get_or_create_session returns existing session when valid session_id given.""" from app.services.copilot_service import _get_or_create_session session1 = await _get_or_create_session( db_session, admin_user.id, first_message="First" ) await db_session.flush() session2 = await _get_or_create_session( db_session, admin_user.id, session_id=str(session1.id) ) assert session2.id == session1.id