feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user