396 lines
14 KiB
Python
396 lines
14 KiB
Python
"""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"
|