fix(caldav): migrate to upstream caldav v3.0.1 to fix href handling (#629)

When Nextcloud stores CalDAV objects, the server-side filename may differ
from the VTODO/VEVENT UID. The caldav fork constructed object URLs from
the UID instead of the actual <d:href> from REPORT responses, causing
list_todos to return wrong hrefs, delete_todo to silently no-op, and
update_todo to fail.

Upstream caldav v3.0.1 fixes this in _async_request_report_build_resultlist
by passing url=self.url.join(url) when constructing result objects.

Key changes:
- Replace caldav fork with upstream caldav>=3.0.1,<4.0
- Update imports to caldav.aio module
- Add _maybe_await() helper for v3's dual-mode methods that return
  either objects or coroutines depending on async context
- Add _async_object_by_uid() to work around upstream's get_object_by_uid
  not being async-aware (it iterates a coroutine synchronously)
- Adapt save_event/save_todo (no longer return tuples)
- Pass url=calendar.url.join(href) in _search_events_by_date
- Pass include_completed=True in list_todos to match previous behavior
- Add integration test for filename != UID scenario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-15 18:45:24 +01:00
parent 989d3f2857
commit 36a664dda4
6 changed files with 380 additions and 58 deletions
+78
View File
@@ -2,6 +2,7 @@
import json
import logging
import uuid
from datetime import datetime, timedelta
import pytest
@@ -467,3 +468,80 @@ async def test_mcp_todo_categories(
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_todo_href_mismatch(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test that todos with filename != UID are handled correctly (issue #629).
When a CalDAV object is stored with a filename different from its VTODO UID,
the server returns an href based on the filename. list_todos must return the
correct server-assigned href, and delete_todo must actually remove the todo.
"""
calendar_name = temporary_calendar
todo_uid = str(uuid.uuid4())
different_filename = str(uuid.uuid4())
# Build iCal content with a UID that differs from the filename
ical_content = (
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"PRODID:-//Test//Test//EN\r\n"
"BEGIN:VTODO\r\n"
f"UID:{todo_uid}\r\n"
"SUMMARY:Href Mismatch Test\r\n"
"STATUS:NEEDS-ACTION\r\n"
"END:VTODO\r\n"
"END:VCALENDAR\r\n"
)
try:
# PUT the todo with a filename that differs from the UID
calendar = nc_client.calendar._get_calendar(calendar_name)
put_url = f"{calendar.url}{different_filename}.ics"
await calendar.client.put(
put_url,
ical_content,
{"Content-Type": "text/calendar; charset=utf-8"},
)
# list_todos via MCP should return href containing the filename, not the UID
list_result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name},
)
assert list_result.isError is False
list_data = json.loads(list_result.content[0].text)
our_todo = next((t for t in list_data["todos"] if t["uid"] == todo_uid), None)
assert our_todo is not None, f"Todo {todo_uid} not found in list_todos"
assert different_filename in our_todo["href"], (
f"Expected href to contain filename '{different_filename}', "
f"got '{our_todo['href']}'"
)
assert todo_uid not in our_todo["href"], (
f"href should NOT contain the UID '{todo_uid}', got '{our_todo['href']}'"
)
# delete_todo via MCP should actually remove the todo
delete_result = await nc_mcp_client.call_tool(
"nc_calendar_delete_todo",
{"calendar_name": calendar_name, "todo_uid": todo_uid},
)
assert delete_result.isError is False
# Verify it's really gone
todos = await nc_client.calendar.list_todos(calendar_name)
assert not any(t["uid"] == todo_uid for t in todos), (
"Todo should have been deleted but still exists"
)
logger.info("Todo href mismatch test passed")
finally:
# Cleanup in case of failure
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass