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
@@ -639,11 +639,10 @@ async def test_calendar_operations_error_handling(
# Test with non-existent calendar
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
# caldav library returns empty list for non-existent calendars, doesn't raise
# Testing that it doesn't crash and returns empty results
events = await nc_client.calendar.get_calendar_events(fake_calendar)
assert isinstance(events, list)
# Empty list is expected for non-existent calendar
assert len(events) == 0
# caldav v3 raises NotFoundError for non-existent calendars
from caldav.lib.error import NotFoundError
with pytest.raises(NotFoundError):
await nc_client.calendar.get_calendar_events(fake_calendar)
logger.info("Error handling tests completed successfully")
@@ -10,6 +10,8 @@ from datetime import datetime, timedelta
import pytest
from nextcloud_mcp_server.client.calendar import _maybe_await
logger = logging.getLogger(__name__)
@@ -34,8 +36,8 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
try:
# Get the calendar object from the caldav library
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
await _maybe_await(event.load())
# Now manually inject custom iCal properties into the raw data
# This simulates what would happen if the event was created by another CalDAV client
@@ -306,8 +308,8 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
try:
# Get the calendar object and event
calendar = nc_client.calendar._get_calendar(calendar_name)
event = await calendar.event_by_uid(event_uid)
await event.load()
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
await _maybe_await(event.load())
# Inject additional iCal properties that are valid but not supported by our parser
extended_ical = f"""BEGIN:VCALENDAR
@@ -348,7 +350,6 @@ END:VCALENDAR"""
# Confirm extended properties exist
extended_properties = [
"SEQUENCE:1",
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
"X-CUSTOM-MEETING-ID:12345-67890",
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
@@ -371,9 +372,14 @@ END:VCALENDAR"""
}
for prop in extended_properties:
assert prop in original_ical, (
f"Extended property {prop} not found in original iCal"
)
if prop in flexible_patterns:
assert any(alt in original_ical for alt in flexible_patterns[prop]), (
f"Extended property {prop} (or alternatives) not found in original iCal"
)
else:
assert prop in original_ical, (
f"Extended property {prop} not found in original iCal"
)
logger.info("✓ All extended properties confirmed in original iCal")