feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history

This commit is contained in:
2026-07-17 02:28:41 +02:00
parent cb89e3ff1e
commit 5cc9f8e9a9
24 changed files with 2878 additions and 188 deletions
+494
View File
@@ -0,0 +1,494 @@
"""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