feat: add generic Nextcloud Deck API request helper

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V57jSQPqwkGG8BuAXg59X5
This commit is contained in:
Jonny 2026-09-13 16:15:14 +02:00
parent c879ffc625
commit dfe76db184
2 changed files with 114 additions and 0 deletions

View File

@ -12,6 +12,7 @@ import asyncio
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from urllib.parse import urlparse
import logging import logging
import json import json
@ -459,6 +460,32 @@ async def delete_event(uid: str) -> dict:
return await asyncio.to_thread(_delete_event_sync, uid) return await asyncio.to_thread(_delete_event_sync, uid)
def _deck_base_url() -> str:
host = urlparse(NEXTCLOUD_CALDAV_URL).netloc
return f"https://{host}/index.php/apps/deck/api/v1.0"
async def _deck_request(method: str, path: str, json_body: dict = None):
"""Generic helper for the Nextcloud Deck REST API - all Deck business
logic goes through this single function so it is the only place tests
need to mock aiohttp directly."""
url = f"{_deck_base_url()}{path}"
auth = aiohttp.BasicAuth(NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD)
headers = {"OCS-APIRequest": "true", "Content-Type": "application/json"}
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout, auth=auth, headers=headers) as session:
async with session.request(method, url, json=json_body) as resp:
if resp.status >= 400:
body = await resp.text()
raise RuntimeError(f"Deck API {method} {path} failed ({resp.status}): {body}")
if resp.status == 204:
return None
return await resp.json()
_deck_cache: dict = {}
def _decode_mime_words(value: str) -> str: def _decode_mime_words(value: str) -> str:
parts = decode_header(value) parts = decode_header(value)
decoded = [] decoded = []

View File

@ -0,0 +1,87 @@
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")