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