"""Additional tests to improve coverage for copilot_service functions.""" import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.models.copilot import CopilotChat, CopilotRole, CopilotSession from app.services import copilot_service class TestCopilotServiceCoverage: """Direct service-level tests for coverage improvement.""" @pytest.mark.asyncio async def test_chat_calls_openrouter_and_persists(self, db_session, admin_user): """chat() calls _call_openrouter_chat and persists both messages.""" mock_content = json.dumps( { "response": "Ich suche LKWs.", "actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}], } ) with patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=mock_content, ) as mock_chat: result = await copilot_service.chat(db_session, admin_user.id, "Zeige LKWs") assert mock_chat.call_count == 1 assert result["response"] == "Ich suche LKWs." assert len(result["actions"]) == 1 assert result["session_id"] is not None assert result["message_id"] is not None @pytest.mark.asyncio async def test_chat_with_existing_session(self, db_session, admin_user): """chat() with session_id reuses existing session.""" session = CopilotSession(user_id=admin_user.id, title="Test") db_session.add(session) await db_session.flush() mock_content = json.dumps({"response": "OK", "actions": []}) with patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=mock_content, ): result = await copilot_service.chat( db_session, admin_user.id, "Follow up", session_id=str(session.id) ) assert result["session_id"] == str(session.id) @pytest.mark.asyncio async def test_chat_with_invalid_session_id_creates_new( self, db_session, admin_user ): """chat() with invalid session_id creates a new session.""" mock_content = json.dumps({"response": "OK", "actions": []}) with patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=mock_content, ): result = await copilot_service.chat( db_session, admin_user.id, "Test", session_id="invalid-uuid" ) assert result["session_id"] != "invalid-uuid" @pytest.mark.asyncio async def test_chat_with_raw_text_response(self, db_session, admin_user): """chat() handles non-JSON AI response gracefully.""" with patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value="Das ist kein JSON.", ): result = await copilot_service.chat(db_session, admin_user.id, "Hallo") assert result["response"] == "Das ist kein JSON." assert result["actions"] == [] @pytest.mark.asyncio async def test_chat_with_code_fence_response(self, db_session, admin_user): """chat() handles markdown code-fenced JSON response.""" content = ( "```json\n" + json.dumps( { "response": "Test", "actions": [ {"type": "search_contacts", "params": {"search": "Mueller"}} ], } ) + "\n```" ) with patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=content, ): result = await copilot_service.chat( db_session, admin_user.id, "Suche Mueller" ) assert result["response"] == "Test" assert len(result["actions"]) == 1 @pytest.mark.asyncio async def test_get_history_returns_user_messages(self, db_session, admin_user): """get_history() returns only messages for the given user.""" session = CopilotSession(user_id=admin_user.id, title="Test") db_session.add(session) await db_session.flush() for i in range(3): msg = CopilotChat( session_id=session.id, user_id=admin_user.id, role=CopilotRole.user if i % 2 == 0 else CopilotRole.assistant, content=f"Message {i}", ) db_session.add(msg) await db_session.flush() messages, total = await copilot_service.get_history( db_session, admin_user.id, page=1, page_size=20 ) assert total == 3 assert len(messages) == 3 @pytest.mark.asyncio async def test_get_history_filtered_by_session(self, db_session, admin_user): """get_history() filters by session_id.""" session1 = CopilotSession(user_id=admin_user.id, title="S1") session2 = CopilotSession(user_id=admin_user.id, title="S2") db_session.add_all([session1, session2]) await db_session.flush() for session in [session1, session2]: for i in range(2): msg = CopilotChat( session_id=session.id, user_id=admin_user.id, role=CopilotRole.user, content=f"Msg {i}", ) db_session.add(msg) await db_session.flush() messages, total = await copilot_service.get_history( db_session, admin_user.id, page=1, page_size=20, session_id=str(session1.id) ) assert total == 2 for msg in messages: assert msg.session_id == session1.id @pytest.mark.asyncio async def test_get_history_with_invalid_session_id_ignores_filter( self, db_session, admin_user ): """get_history() ignores invalid session_id and returns all.""" session = CopilotSession(user_id=admin_user.id, title="Test") db_session.add(session) await db_session.flush() msg = CopilotChat( session_id=session.id, user_id=admin_user.id, role=CopilotRole.user, content="Test", ) db_session.add(msg) await db_session.flush() messages, total = await copilot_service.get_history( db_session, admin_user.id, page=1, page_size=20, session_id="invalid" ) assert total == 1 @pytest.mark.asyncio async def test_get_history_pagination(self, db_session, admin_user): """get_history() respects pagination parameters.""" session = CopilotSession(user_id=admin_user.id, title="Test") db_session.add(session) await db_session.flush() for i in range(5): msg = CopilotChat( session_id=session.id, user_id=admin_user.id, role=CopilotRole.user, content=f"Message {i}", ) db_session.add(msg) await db_session.flush() messages, total = await copilot_service.get_history( db_session, admin_user.id, page=1, page_size=2 ) assert total == 5 assert len(messages) == 2 messages2, _ = await copilot_service.get_history( db_session, admin_user.id, page=2, page_size=2 ) assert len(messages2) == 2 @pytest.mark.asyncio async def test_get_sessions_returns_user_sessions(self, db_session, admin_user): """get_sessions() returns sessions for the given user.""" for i in range(3): session = CopilotSession(user_id=admin_user.id, title=f"Session {i}") db_session.add(session) await db_session.flush() sessions, total = await copilot_service.get_sessions( db_session, admin_user.id, page=1, page_size=20 ) assert total == 3 assert len(sessions) == 3 @pytest.mark.asyncio async def test_get_sessions_pagination(self, db_session, admin_user): """get_sessions() respects pagination.""" for i in range(5): session = CopilotSession(user_id=admin_user.id, title=f"S{i}") db_session.add(session) await db_session.flush() sessions, total = await copilot_service.get_sessions( db_session, admin_user.id, page=1, page_size=2 ) assert total == 5 assert len(sessions) == 2 @pytest.mark.asyncio async def test_execute_confirmed_action_success(self, db_session, admin_user): """execute_confirmed_action returns success result.""" from app.models.vehicle import Vehicle from decimal import Decimal vehicle = Vehicle( make="Test", model="Model", fin="WDB9066351L123456", price=Decimal("10000"), vehicle_type="lkw", ) db_session.add(vehicle) await db_session.flush() result = await copilot_service.execute_confirmed_action( db_session, "search_vehicles", {"type": "lkw"} ) assert result["success"] is True assert result["action"] == "search_vehicles" assert "items" in result["result"] @pytest.mark.asyncio async def test_execute_confirmed_action_unknown_raises(self, db_session): """execute_confirmed_action raises ValueError for unknown action.""" with pytest.raises(ValueError, match="Unknown action type"): await copilot_service.execute_confirmed_action( db_session, "nonexistent", {} ) @pytest.mark.asyncio async def test_execute_confirmed_action_create_vehicle_missing_fields( self, db_session ): """execute_confirmed_action raises ValueError for missing required fields.""" with pytest.raises(ValueError, match="Missing required fields"): await copilot_service.execute_confirmed_action( db_session, "create_vehicle", {"make": "Test"} ) @pytest.mark.asyncio async def test_transcribe_audio_no_api_key_returns_stub(self): """transcribe_audio returns stub message when no API key configured.""" with patch("app.services.copilot_service.settings") as mock_settings: mock_settings.OPENROUTER_API_KEY = "" result = await copilot_service.transcribe_audio(b"fake-audio") assert "nicht verfuegbar" in result or "OPENROUTER_API_KEY" in result @pytest.mark.asyncio async def test_transcribe_audio_with_api_key_calls_openrouter(self): """transcribe_audio calls OpenRouter when API key is set.""" with ( patch("app.services.copilot_service.settings") as mock_settings, patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=" Transkribierter Text ", ) as mock_chat, ): mock_settings.OPENROUTER_API_KEY = "test-key" result = await copilot_service.transcribe_audio(b"fake-audio", "audio/webm") assert result == "Transkribierter Text" assert mock_chat.call_count == 1 @pytest.mark.asyncio async def test_transcribe_audio_api_error_returns_error_message(self): """transcribe_audio returns error message on API failure.""" with ( patch("app.services.copilot_service.settings") as mock_settings, patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, side_effect=Exception("API error"), ), ): mock_settings.OPENROUTER_API_KEY = "test-key" result = await copilot_service.transcribe_audio(b"fake-audio") assert "Transkription fehlgeschlagen" in result @pytest.mark.asyncio async def test_voice_chat_full_flow(self, db_session, admin_user): """voice_chat transcribes and then chats.""" import base64 audio_b64 = base64.b64encode(b"fake-audio").decode("utf-8") mock_content = json.dumps({"response": "Antwort", "actions": []}) with ( patch( "app.services.copilot_service.transcribe_audio", new_callable=AsyncMock, return_value="Transkription", ), patch( "app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=mock_content, ), ): result = await copilot_service.voice_chat( db_session, admin_user.id, audio_b64 ) assert result["transcription"] == "Transkription" assert result["response"] == "Antwort" assert result["actions"] == [] assert "session_id" in result assert "message_id" in result @pytest.mark.asyncio async def test_call_openrouter_chat_no_api_key_raises(self): """_call_openrouter_chat raises ValueError when no API key.""" with patch("app.services.copilot_service.settings") as mock_settings: mock_settings.OPENROUTER_API_KEY = "" with pytest.raises( ValueError, match="OPENROUTER_API_KEY is not configured" ): await copilot_service._call_openrouter_chat([]) @pytest.mark.asyncio async def test_call_openrouter_chat_success(self): """_call_openrouter_chat calls httpx and returns content.""" mock_response = MagicMock() mock_response.json.return_value = { "choices": [{"message": {"content": "Test response"}}] } mock_response.raise_for_status = MagicMock() with ( patch("app.services.copilot_service.settings") as mock_settings, patch("app.services.copilot_service.httpx.AsyncClient") as mock_client_cls, ): mock_settings.OPENROUTER_API_KEY = "test-key" mock_settings.OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=None) mock_client_cls.return_value = mock_client result = await copilot_service._call_openrouter_chat( [{"role": "user", "content": "test"}] ) assert result == "Test response"