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"}