348 lines
13 KiB
Python
348 lines
13 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
# Mock psycopg2 before importing main (see test_memory.py for rationale).
|
|
sys.modules["psycopg2"] = MagicMock()
|
|
sys.modules["psycopg2.pool"] = MagicMock()
|
|
sys.modules["psycopg2.extras"] = MagicMock()
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
import pytest
|
|
|
|
import main
|
|
|
|
main.NEXTCLOUD_CALDAV_URL = "https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/"
|
|
main.NEXTCLOUD_USER = "jonny"
|
|
main.NEXTCLOUD_APP_PASSWORD = "test-app-password"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_deck_cache():
|
|
main._deck_cache.clear()
|
|
yield
|
|
main._deck_cache.clear()
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status, payload):
|
|
self.status = status
|
|
self._payload = payload
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def json(self):
|
|
return self._payload
|
|
|
|
async def text(self):
|
|
return json.dumps(self._payload)
|
|
|
|
|
|
class _FakeSession:
|
|
def __init__(self, response):
|
|
self._response = response
|
|
self.request_calls = []
|
|
|
|
def request(self, method, url, **kwargs):
|
|
self.request_calls.append((method, url, kwargs))
|
|
return self._response
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
def test_deck_base_url_derives_host_from_caldav_url():
|
|
assert main._deck_base_url() == "https://cloud.ffw-onza.de/index.php/apps/deck/api/v1.0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deck_request_returns_parsed_json_on_success():
|
|
fake_session = _FakeSession(_FakeResponse(200, {"id": 5}))
|
|
|
|
with patch.object(main.aiohttp, "ClientSession", return_value=fake_session):
|
|
result = await main._deck_request("GET", "/boards")
|
|
|
|
assert result == {"id": 5}
|
|
method, url, kwargs = fake_session.request_calls[0]
|
|
assert method == "GET"
|
|
assert url == "https://cloud.ffw-onza.de/index.php/apps/deck/api/v1.0/boards"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deck_request_raises_on_error_status():
|
|
fake_session = _FakeSession(_FakeResponse(500, {"message": "boom"}))
|
|
|
|
with patch.object(main.aiohttp, "ClientSession", return_value=fake_session):
|
|
with pytest.raises(RuntimeError):
|
|
await main._deck_request("GET", "/boards")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_deck_board_creates_board_and_stacks_when_missing():
|
|
titles_created = []
|
|
|
|
async def fake_deck_request(method, path, json_body=None):
|
|
if (method, path) == ("GET", "/boards"):
|
|
return []
|
|
if (method, path) == ("POST", "/boards"):
|
|
return {"id": 10, "title": "Bestellungen"}
|
|
if (method, path) == ("GET", "/boards/10/stacks"):
|
|
return []
|
|
if (method, path) == ("POST", "/boards/10/stacks"):
|
|
titles_created.append(json_body["title"])
|
|
return {"id": 100 + len(titles_created), "title": json_body["title"]}
|
|
raise AssertionError(f"unexpected call {method} {path}")
|
|
|
|
with patch.object(main, "_deck_request", new=AsyncMock(side_effect=fake_deck_request)):
|
|
board = await main._ensure_deck_board()
|
|
|
|
assert board["board_id"] == 10
|
|
assert titles_created == ["Offen", "In Arbeit", "Erledigt"]
|
|
assert set(board["stacks"].keys()) == {"Offen", "In Arbeit", "Erledigt"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_deck_board_reuses_existing_board_and_stacks():
|
|
async def fake_deck_request(method, path, json_body=None):
|
|
if (method, path) == ("GET", "/boards"):
|
|
return [{"id": 10, "title": "Bestellungen"}]
|
|
if (method, path) == ("GET", "/boards/10/stacks"):
|
|
return [
|
|
{"id": 1, "title": "Offen", "cards": []},
|
|
{"id": 2, "title": "In Arbeit", "cards": []},
|
|
{"id": 3, "title": "Erledigt", "cards": []},
|
|
]
|
|
raise AssertionError(f"unexpected call {method} {path}")
|
|
|
|
with patch.object(main, "_deck_request", new=AsyncMock(side_effect=fake_deck_request)) as mock_req:
|
|
board = await main._ensure_deck_board()
|
|
|
|
assert board == {"board_id": 10, "stacks": {"Offen": 1, "In Arbeit": 2, "Erledigt": 3}}
|
|
assert mock_req.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_deck_board_caches_after_first_lookup():
|
|
main._deck_cache["board_id"] = 10
|
|
main._deck_cache["stacks"] = {"Offen": 1, "In Arbeit": 2, "Erledigt": 3}
|
|
|
|
with patch.object(main, "_deck_request", new=AsyncMock()) as mock_req:
|
|
board = await main._ensure_deck_board()
|
|
|
|
mock_req.assert_not_called()
|
|
assert board["board_id"] == 10
|
|
|
|
|
|
_BOARD = {"board_id": 10, "stacks": {"Offen": 1, "In Arbeit": 2, "Erledigt": 3}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_order_creates_card_in_offen_stack():
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
|
main, "_deck_request", new=AsyncMock(return_value={"id": 55})
|
|
) as mock_req:
|
|
order = await main.create_order("Feuerwehr Onza", "10x Handschuhe Groesse L")
|
|
|
|
assert order == {
|
|
"id": 55,
|
|
"customer": "Feuerwehr Onza",
|
|
"description": "10x Handschuhe Groesse L",
|
|
"status": "Offen",
|
|
"due_date": None,
|
|
}
|
|
method, path, body = mock_req.call_args[0]
|
|
assert method == "POST"
|
|
assert path == "/boards/10/stacks/1/cards"
|
|
assert body["title"] == "Feuerwehr Onza"
|
|
assert body["description"] == "10x Handschuhe Groesse L"
|
|
assert "duedate" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_order_localizes_due_date():
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
|
main, "_deck_request", new=AsyncMock(return_value={"id": 55})
|
|
) as mock_req:
|
|
await main.create_order("Feuerwehr Onza", "Helme", due_date="2026-09-20")
|
|
|
|
_, _, body = mock_req.call_args[0]
|
|
assert body["duedate"] == datetime(2026, 9, 20, tzinfo=main.CALENDAR_TIMEZONE).isoformat()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_orders_flattens_cards_across_stacks():
|
|
stacks_response = [
|
|
{"id": 1, "title": "Offen", "cards": [
|
|
{"id": 55, "title": "Feuerwehr Onza", "description": "Helme", "duedate": None}
|
|
]},
|
|
{"id": 2, "title": "In Arbeit", "cards": []},
|
|
{"id": 3, "title": "Erledigt", "cards": [
|
|
{"id": 56, "title": "Musterfirma", "description": "Stiefel", "duedate": "2026-09-20T00:00:00+02:00"}
|
|
]},
|
|
]
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
|
main, "_deck_request", new=AsyncMock(return_value=stacks_response)
|
|
):
|
|
orders = await main.list_orders()
|
|
|
|
assert len(orders) == 2
|
|
assert orders[0] == {
|
|
"id": 55, "customer": "Feuerwehr Onza", "description": "Helme", "status": "Offen", "due_date": None
|
|
}
|
|
assert orders[1]["status"] == "Erledigt"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_orders_filters_by_status():
|
|
stacks_response = [
|
|
{"id": 1, "title": "Offen", "cards": [{"id": 55, "title": "A", "description": "", "duedate": None}]},
|
|
{"id": 2, "title": "In Arbeit", "cards": []},
|
|
{"id": 3, "title": "Erledigt", "cards": [{"id": 56, "title": "B", "description": "", "duedate": None}]},
|
|
]
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
|
main, "_deck_request", new=AsyncMock(return_value=stacks_response)
|
|
):
|
|
orders = await main.list_orders(status="Erledigt")
|
|
|
|
assert [o["id"] for o in orders] == [56]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_order_status_finds_current_stack_and_reorders():
|
|
stacks_response = [
|
|
{"id": 1, "title": "Offen", "cards": [{"id": 55, "title": "A"}]},
|
|
{"id": 2, "title": "In Arbeit", "cards": []},
|
|
{"id": 3, "title": "Erledigt", "cards": []},
|
|
]
|
|
|
|
async def fake_deck_request(method, path, json_body=None):
|
|
if (method, path) == ("GET", "/boards/10/stacks"):
|
|
return stacks_response
|
|
if method == "PUT":
|
|
return {"id": 55}
|
|
raise AssertionError(f"unexpected call {method} {path}")
|
|
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
|
main, "_deck_request", new=AsyncMock(side_effect=fake_deck_request)
|
|
) as mock_req:
|
|
result = await main.update_order_status(55, "In Arbeit")
|
|
|
|
assert result == {"id": 55, "status": "In Arbeit"}
|
|
put_call = [c for c in mock_req.call_args_list if c.args[0] == "PUT"][0]
|
|
_, path, body = put_call.args
|
|
assert path == "/boards/10/stacks/1/cards/55/reorder"
|
|
assert body == {"stackId": 2, "order": 999}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_order_status_raises_for_unknown_status():
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)):
|
|
with pytest.raises(ValueError):
|
|
await main.update_order_status(55, "Storniert")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_order_status_raises_when_card_not_found():
|
|
stacks_response = [
|
|
{"id": 1, "title": "Offen", "cards": []},
|
|
{"id": 2, "title": "In Arbeit", "cards": []},
|
|
{"id": 3, "title": "Erledigt", "cards": []},
|
|
]
|
|
with patch.object(main, "_ensure_deck_board", new=AsyncMock(return_value=_BOARD)), patch.object(
|
|
main, "_deck_request", new=AsyncMock(return_value=stacks_response)
|
|
):
|
|
with pytest.raises(ValueError):
|
|
await main.update_order_status(999, "Erledigt")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tool_dispatches_order_tools():
|
|
with patch.object(main, "create_order", new=AsyncMock(return_value={"id": 1, "customer": "X"})):
|
|
result = await main.execute_tool(
|
|
"create_order", {"customer": "X", "description": "Y"}
|
|
)
|
|
assert json.loads(result) == {"id": 1, "customer": "X"}
|
|
|
|
with patch.object(main, "list_orders", new=AsyncMock(return_value=[{"id": 1}])):
|
|
result = await main.execute_tool("list_orders", {})
|
|
assert json.loads(result) == [{"id": 1}]
|
|
|
|
with patch.object(main, "update_order_status", new=AsyncMock(return_value={"id": 1, "status": "Erledigt"})):
|
|
result = await main.execute_tool("update_order_status", {"order_id": 1, "status": "Erledigt"})
|
|
assert json.loads(result) == {"id": 1, "status": "Erledigt"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_chat_completion_includes_order_tools_and_instructions():
|
|
completion = MagicMock()
|
|
completion.stop_reason = "end_turn"
|
|
text_block = MagicMock()
|
|
text_block.type = "text"
|
|
text_block.text = "Bestellung angelegt."
|
|
completion.content = [text_block]
|
|
usage = MagicMock()
|
|
usage.input_tokens = 10
|
|
usage.output_tokens = 5
|
|
completion.usage = usage
|
|
|
|
main.claude_client = MagicMock()
|
|
main.claude_client.messages.create.return_value = completion
|
|
|
|
with patch.object(main, "build_memory_context", new=AsyncMock(return_value="")):
|
|
await main.run_chat_completion(
|
|
[{"role": "user", "content": "Neue Bestellung fuer Feuerwehr Onza"}], conversation_id=1
|
|
)
|
|
|
|
_, kwargs = main.claude_client.messages.create.call_args
|
|
assert kwargs["tools"] == main.CALENDAR_TOOLS + main.MEMORY_TOOLS + main.ORDER_TOOLS
|
|
assert "create_order" in kwargs["system"] or "Bestellungen" in kwargs["system"]
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
main.API_KEY_ADMIN = "test-secret"
|
|
HEADERS = {"X-Admin-Key": "test-secret"}
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
with TestClient(main.app) as c:
|
|
yield c
|
|
|
|
|
|
def test_orders_endpoint_returns_list(client):
|
|
orders = [{"id": 55, "customer": "A", "description": "", "status": "Offen", "due_date": None}]
|
|
with patch.object(main, "list_orders", new=AsyncMock(return_value=orders)) as mock_list:
|
|
response = client.get("/api/v1/orders", headers=HEADERS)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"orders": orders}
|
|
mock_list.assert_called_once_with(None)
|
|
|
|
|
|
def test_orders_endpoint_filters_by_status_query_param(client):
|
|
with patch.object(main, "list_orders", new=AsyncMock(return_value=[])) as mock_list:
|
|
response = client.get("/api/v1/orders?status=Erledigt", headers=HEADERS)
|
|
assert response.status_code == 200
|
|
mock_list.assert_called_once_with("Erledigt")
|
|
|
|
|
|
def test_orders_endpoint_requires_admin_key(client):
|
|
response = client.get("/api/v1/orders")
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_orders_endpoint_returns_503_on_deck_error(client):
|
|
with patch.object(main, "list_orders", new=AsyncMock(side_effect=RuntimeError("Deck down"))):
|
|
response = client.get("/api/v1/orders", headers=HEADERS)
|
|
assert response.status_code == 503
|