Files
erp-nutzfahrzeuge/backend/tests/test_copilot_coverage.py
T

368 lines
14 KiB
Python
Raw Normal View History

"""Additional tests to improve coverage for copilot_service functions."""
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
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"