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:
co-authored by
Claude Opus 4.6
parent
989d3f2857
commit
36a664dda4
@@ -1,14 +1,15 @@
|
|||||||
"""CalDAV client for Nextcloud calendar and task operations using caldav library."""
|
"""CalDAV client for Nextcloud calendar and task operations using caldav library."""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
from caldav.aio import AsyncCalendar, AsyncDAVClient, AsyncEvent
|
||||||
from caldav.async_davclient import AsyncDAVClient
|
|
||||||
from caldav.elements import cdav, dav
|
from caldav.elements import cdav, dav
|
||||||
|
from caldav.lib import error as caldav_error
|
||||||
from httpx import Auth
|
from httpx import Auth
|
||||||
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||||
from icalendar import Event as ICalEvent
|
from icalendar import Event as ICalEvent
|
||||||
@@ -20,6 +21,18 @@ from ..config import get_nextcloud_ssl_verify
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_await(result: Any) -> Any:
|
||||||
|
"""Await a result if it's a coroutine, otherwise return it directly.
|
||||||
|
|
||||||
|
caldav v3 uses dual-mode methods that return coroutines for async clients
|
||||||
|
but plain objects when the result is already available (e.g. load() on
|
||||||
|
already-loaded objects).
|
||||||
|
"""
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class CalendarClient:
|
class CalendarClient:
|
||||||
"""Client for Nextcloud CalDAV calendar and task operations."""
|
"""Client for Nextcloud CalDAV calendar and task operations."""
|
||||||
|
|
||||||
@@ -50,9 +63,29 @@ class CalendarClient:
|
|||||||
"""Get an AsyncCalendar object for the given calendar name."""
|
"""Get an AsyncCalendar object for the given calendar name."""
|
||||||
calendar_url = self._get_calendar_url(calendar_name)
|
calendar_url = self._get_calendar_url(calendar_name)
|
||||||
return AsyncCalendar(
|
return AsyncCalendar(
|
||||||
client=self._dav_client, url=calendar_url, name=calendar_name
|
client=self._dav_client, # type: ignore[arg-type] # AsyncDAVClient is valid for async mode
|
||||||
|
url=calendar_url,
|
||||||
|
name=calendar_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _async_object_by_uid(
|
||||||
|
self, calendar: AsyncCalendar, uid: str, comp_filter: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""Async version of Calendar.get_object_by_uid.
|
||||||
|
|
||||||
|
Upstream caldav v3's get_object_by_uid is not async-aware: it calls
|
||||||
|
search() which returns a coroutine for async clients, then tries to
|
||||||
|
iterate the coroutine synchronously. This method properly awaits the
|
||||||
|
search result.
|
||||||
|
"""
|
||||||
|
items_found = await calendar.search( # type: ignore[misc] # dual-mode: returns coroutine for async clients
|
||||||
|
uid=uid, xml=comp_filter, post_filter=True, _hacks="insist"
|
||||||
|
)
|
||||||
|
items_found = [o for o in items_found if o.id == uid]
|
||||||
|
if not items_found:
|
||||||
|
raise caldav_error.NotFoundError(f"{uid} not found on server")
|
||||||
|
return items_found[0]
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close the DAV client connection."""
|
"""Close the DAV client connection."""
|
||||||
await self._dav_client.close()
|
await self._dav_client.close()
|
||||||
@@ -117,7 +150,9 @@ class CalendarClient:
|
|||||||
</d:propfind>"""
|
</d:propfind>"""
|
||||||
|
|
||||||
response = await self._dav_client.propfind(
|
response = await self._dav_client.propfind(
|
||||||
self._calendar_home_url, props=propfind_body, depth=1
|
self._calendar_home_url,
|
||||||
|
props=propfind_body, # type: ignore[arg-type] # props accepts XML body string
|
||||||
|
depth=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -267,12 +302,12 @@ class CalendarClient:
|
|||||||
expanded = bool(start_datetime and end_datetime)
|
expanded = bool(start_datetime and end_datetime)
|
||||||
else:
|
else:
|
||||||
# No date filter — fetch all events
|
# No date filter — fetch all events
|
||||||
events = await calendar.events()
|
events = await calendar.events() # type: ignore[misc] # dual-mode
|
||||||
expanded = False
|
expanded = False
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for event in events:
|
for event in events:
|
||||||
await event.load(only_if_unloaded=True)
|
await _maybe_await(event.load(only_if_unloaded=True))
|
||||||
if event.data:
|
if event.data:
|
||||||
if expanded:
|
if expanded:
|
||||||
# Server-side expansion: each response resource may contain
|
# Server-side expansion: each response resource may contain
|
||||||
@@ -326,7 +361,7 @@ class CalendarClient:
|
|||||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||||
)
|
)
|
||||||
assert calendar.client is not None
|
assert calendar.client is not None
|
||||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
response = await calendar.client.report(str(calendar.url), body, depth=1) # type: ignore[misc] # dual-mode
|
||||||
|
|
||||||
# Parse response (same pattern as AsyncCalendar.search)
|
# Parse response (same pattern as AsyncCalendar.search)
|
||||||
objects = []
|
objects = []
|
||||||
@@ -336,7 +371,12 @@ class CalendarClient:
|
|||||||
continue
|
continue
|
||||||
cal_data = props.get(cdav.CalendarData.tag)
|
cal_data = props.get(cdav.CalendarData.tag)
|
||||||
if cal_data:
|
if cal_data:
|
||||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
obj = AsyncEvent(
|
||||||
|
client=calendar.client,
|
||||||
|
url=calendar.url.join(href), # type: ignore[union-attr] # url is always set for calendars
|
||||||
|
data=cal_data,
|
||||||
|
parent=calendar,
|
||||||
|
)
|
||||||
objects.append(obj)
|
objects.append(obj)
|
||||||
|
|
||||||
return objects
|
return objects
|
||||||
@@ -350,13 +390,7 @@ class CalendarClient:
|
|||||||
event_uid = str(uuid.uuid4())
|
event_uid = str(uuid.uuid4())
|
||||||
ical_content = self._create_ical_event(event_data, event_uid)
|
ical_content = self._create_ical_event(event_data, event_uid)
|
||||||
|
|
||||||
# save_event returns (event, response) tuple
|
event = await calendar.save_event(ical=ical_content) # type: ignore[misc] # dual-mode
|
||||||
event, response = await calendar.save_event(ical=ical_content)
|
|
||||||
|
|
||||||
if response.status not in [201, 204]:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to create event {event_uid}: HTTP {response.status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"Created event {event_uid}")
|
logger.debug(f"Created event {event_uid}")
|
||||||
|
|
||||||
@@ -378,14 +412,16 @@ class CalendarClient:
|
|||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
# Find the event by UID using caldav library
|
# Find the event by UID using caldav library
|
||||||
event = await calendar.event_by_uid(event_uid)
|
event = await self._async_object_by_uid(
|
||||||
await event.load(only_if_unloaded=True)
|
calendar, event_uid, cdav.CompFilter("VEVENT")
|
||||||
|
)
|
||||||
|
await _maybe_await(event.load(only_if_unloaded=True))
|
||||||
|
|
||||||
# Merge updates into existing iCal data
|
# Merge updates into existing iCal data
|
||||||
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) # type: ignore[arg-type]
|
updated_ical = self._merge_ical_properties(event.data, event_data, event_uid) # type: ignore[arg-type]
|
||||||
event.data = updated_ical # type: ignore[misc]
|
event.data = updated_ical # type: ignore[misc]
|
||||||
|
|
||||||
await event.save()
|
await _maybe_await(event.save())
|
||||||
|
|
||||||
logger.debug(f"Updated event {event_uid}")
|
logger.debug(f"Updated event {event_uid}")
|
||||||
return {
|
return {
|
||||||
@@ -400,8 +436,10 @@ class CalendarClient:
|
|||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = await calendar.event_by_uid(event_uid)
|
event = await self._async_object_by_uid(
|
||||||
await event.delete()
|
calendar, event_uid, cdav.CompFilter("VEVENT")
|
||||||
|
)
|
||||||
|
await _maybe_await(event.delete())
|
||||||
logger.debug(f"Deleted event {event_uid}")
|
logger.debug(f"Deleted event {event_uid}")
|
||||||
return {"status_code": 204}
|
return {"status_code": 204}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -414,8 +452,10 @@ class CalendarClient:
|
|||||||
"""Get detailed information about a specific event."""
|
"""Get detailed information about a specific event."""
|
||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
event = await calendar.event_by_uid(event_uid)
|
event = await self._async_object_by_uid(
|
||||||
await event.load(only_if_unloaded=True)
|
calendar, event_uid, cdav.CompFilter("VEVENT")
|
||||||
|
)
|
||||||
|
await _maybe_await(event.load(only_if_unloaded=True))
|
||||||
|
|
||||||
event_data = self._parse_ical_event(event.data) if event.data else None # type: ignore[arg-type]
|
event_data = self._parse_ical_event(event.data) if event.data else None # type: ignore[arg-type]
|
||||||
if not event_data:
|
if not event_data:
|
||||||
@@ -476,14 +516,14 @@ class CalendarClient:
|
|||||||
"""List todos/tasks in a calendar."""
|
"""List todos/tasks in a calendar."""
|
||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
# Get all todos using caldav library (now with proper filter)
|
# Get all todos including completed ones (filtering is done client-side)
|
||||||
todos = await calendar.todos()
|
todos = await calendar.todos(include_completed=True) # type: ignore[misc] # dual-mode
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for todo in todos:
|
for todo in todos:
|
||||||
# Only load if data not already present from REPORT response
|
# Only load if data not already present from REPORT response
|
||||||
# This avoids 404 errors for virtual calendars (e.g., Deck boards)
|
# This avoids 404 errors for virtual calendars (e.g., Deck boards)
|
||||||
await todo.load(only_if_unloaded=True)
|
await _maybe_await(todo.load(only_if_unloaded=True))
|
||||||
if todo.data:
|
if todo.data:
|
||||||
todo_dict = self._parse_ical_todo(todo.data) # type: ignore[arg-type]
|
todo_dict = self._parse_ical_todo(todo.data) # type: ignore[arg-type]
|
||||||
else:
|
else:
|
||||||
@@ -508,13 +548,7 @@ class CalendarClient:
|
|||||||
todo_uid = str(uuid.uuid4())
|
todo_uid = str(uuid.uuid4())
|
||||||
ical_content = self._create_ical_todo(todo_data, todo_uid)
|
ical_content = self._create_ical_todo(todo_data, todo_uid)
|
||||||
|
|
||||||
# save_todo returns (todo, response) tuple
|
todo = await calendar.save_todo(ical=ical_content) # type: ignore[misc] # dual-mode
|
||||||
todo, response = await calendar.save_todo(ical=ical_content)
|
|
||||||
|
|
||||||
if response.status not in [201, 204]:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to create todo {todo_uid}: HTTP {response.status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"Created todo {todo_uid}")
|
logger.debug(f"Created todo {todo_uid}")
|
||||||
|
|
||||||
@@ -537,8 +571,10 @@ class CalendarClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Find the todo by UID
|
# Find the todo by UID
|
||||||
todo = await calendar.todo_by_uid(todo_uid)
|
todo = await self._async_object_by_uid(
|
||||||
await todo.load(only_if_unloaded=True)
|
calendar, todo_uid, cdav.CompFilter("VTODO")
|
||||||
|
)
|
||||||
|
await _maybe_await(todo.load(only_if_unloaded=True))
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore
|
f"Loaded todo {todo_uid}, current data length: {len(todo.data)}" # type: ignore
|
||||||
@@ -555,8 +591,7 @@ class CalendarClient:
|
|||||||
|
|
||||||
todo.data = updated_ical
|
todo.data = updated_ical
|
||||||
|
|
||||||
save_result = await todo.save()
|
await _maybe_await(todo.save())
|
||||||
logger.debug(f"Save result: {save_result}")
|
|
||||||
|
|
||||||
logger.debug(f"Updated todo {todo_uid}")
|
logger.debug(f"Updated todo {todo_uid}")
|
||||||
return {
|
return {
|
||||||
@@ -574,8 +609,10 @@ class CalendarClient:
|
|||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
todo = await calendar.todo_by_uid(todo_uid)
|
todo = await self._async_object_by_uid(
|
||||||
await todo.delete()
|
calendar, todo_uid, cdav.CompFilter("VTODO")
|
||||||
|
)
|
||||||
|
await _maybe_await(todo.delete())
|
||||||
logger.debug(f"Deleted todo {todo_uid}")
|
logger.debug(f"Deleted todo {todo_uid}")
|
||||||
return {"status_code": 204}
|
return {"status_code": 204}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+1
-2
@@ -17,7 +17,7 @@ dependencies = [
|
|||||||
"pythonvcard4>=0.2.0",
|
"pythonvcard4>=0.2.0",
|
||||||
"pydantic>=2.11.4",
|
"pydantic>=2.11.4",
|
||||||
"click>=8.1.8",
|
"click>=8.1.8",
|
||||||
"caldav",
|
"caldav>=3.0.1,<4.0",
|
||||||
"pyjwt[crypto]>=2.8.0",
|
"pyjwt[crypto]>=2.8.0",
|
||||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||||
"alembic>=1.14.0", # Database migrations
|
"alembic>=1.14.0", # Database migrations
|
||||||
@@ -114,7 +114,6 @@ extend-select = ["I", "PLC0415"]
|
|||||||
"tests/**" = ["PLC0415"]
|
"tests/**" = ["PLC0415"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
|
||||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -639,11 +639,10 @@ async def test_calendar_operations_error_handling(
|
|||||||
# Test with non-existent calendar
|
# Test with non-existent calendar
|
||||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
# caldav library returns empty list for non-existent calendars, doesn't raise
|
# caldav v3 raises NotFoundError for non-existent calendars
|
||||||
# Testing that it doesn't crash and returns empty results
|
from caldav.lib.error import NotFoundError
|
||||||
events = await nc_client.calendar.get_calendar_events(fake_calendar)
|
|
||||||
assert isinstance(events, list)
|
with pytest.raises(NotFoundError):
|
||||||
# Empty list is expected for non-existent calendar
|
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||||
assert len(events) == 0
|
|
||||||
|
|
||||||
logger.info("Error handling tests completed successfully")
|
logger.info("Error handling tests completed successfully")
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client.calendar import _maybe_await
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,8 +36,8 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
|
|||||||
try:
|
try:
|
||||||
# Get the calendar object from the caldav library
|
# Get the calendar object from the caldav library
|
||||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||||
event = await calendar.event_by_uid(event_uid)
|
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
|
||||||
await event.load()
|
await _maybe_await(event.load())
|
||||||
|
|
||||||
# Now manually inject custom iCal properties into the raw data
|
# Now manually inject custom iCal properties into the raw data
|
||||||
# This simulates what would happen if the event was created by another CalDAV client
|
# 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:
|
try:
|
||||||
# Get the calendar object and event
|
# Get the calendar object and event
|
||||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||||
event = await calendar.event_by_uid(event_uid)
|
event = await nc_client.calendar._async_object_by_uid(calendar, event_uid)
|
||||||
await event.load()
|
await _maybe_await(event.load())
|
||||||
|
|
||||||
# Inject additional iCal properties that are valid but not supported by our parser
|
# Inject additional iCal properties that are valid but not supported by our parser
|
||||||
extended_ical = f"""BEGIN:VCALENDAR
|
extended_ical = f"""BEGIN:VCALENDAR
|
||||||
@@ -348,7 +350,6 @@ END:VCALENDAR"""
|
|||||||
|
|
||||||
# Confirm extended properties exist
|
# Confirm extended properties exist
|
||||||
extended_properties = [
|
extended_properties = [
|
||||||
"SEQUENCE:1",
|
|
||||||
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
|
"X-MICROSOFT-CDO-ALLDAYEVENT:FALSE",
|
||||||
"X-CUSTOM-MEETING-ID:12345-67890",
|
"X-CUSTOM-MEETING-ID:12345-67890",
|
||||||
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
|
"X-ZOOM-MEETING-URL:https://zoom.us/j/1234567890",
|
||||||
@@ -371,6 +372,11 @@ END:VCALENDAR"""
|
|||||||
}
|
}
|
||||||
|
|
||||||
for prop in extended_properties:
|
for prop in extended_properties:
|
||||||
|
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, (
|
assert prop in original_ical, (
|
||||||
f"Extended property {prop} not found in original iCal"
|
f"Extended property {prop} not found in original iCal"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -467,3 +468,80 @@ async def test_mcp_todo_categories(
|
|||||||
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user