Merge pull request #832 from Janhouse/fix/830-external-calendars

feat(calendar): list external read-only (subscribed) calendars
This commit is contained in:
Chris Coutinho
2026-06-15 06:34:16 +02:00
committed by GitHub
5 changed files with 257 additions and 49 deletions
@@ -89,8 +89,18 @@ async def test_list_calendars(nc_client: NextcloudClient):
# Optional fields
assert "description" in calendar
assert "color" in calendar
# External subscription metadata (issue #830): always present, with
# read_only=True / a source URL for subscribed calendars.
assert "read_only" in calendar
assert isinstance(calendar["read_only"], bool)
assert "source" in calendar
logger.info("Calendar: %s - %s", calendar["name"], calendar["display_name"])
logger.info(
"Calendar: %s - %s (read_only=%s)",
calendar["name"],
calendar["display_name"],
calendar["read_only"],
)
async def test_create_and_delete_event(
+135
View File
@@ -142,3 +142,138 @@ def test_auth_username_defaults_to_username(mocker):
CalendarClient("https://cloud.example.org", "alice", password="app-pw")
assert mock_dav_client.call_args.kwargs["username"] == "alice"
def test_webcal_caching_header_enabled_on_client(mocker):
"""The client is constructed with the webcal-caching header turned on.
This is what makes Nextcloud expose external subscriptions as queryable
CachedSubscription calendars, so their events are readable through the
normal event/search tools (issue #830).
"""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
from nextcloud_mcp_server.client.calendar import CalendarClient
CalendarClient("https://cloud.example.org", "alice", password="app-pw")
headers = mock_dav_client.call_args.kwargs["headers"]
assert headers["X-NC-CalDAV-Webcal-Caching"] == "On"
# --- list_calendars: regular + external subscription parsing (issue #830) ---
# A multistatus body with the calendar home, one regular calendar, and one
# external subscription (cs:subscribed) carrying a cs:source href and an
# Apple-namespace color.
_LIST_CALENDARS_MULTISTATUS = """<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ical="http://apple.com/ns/ical/">
<d:response>
<d:href>/remote.php/dav/calendars/alice/</d:href>
<d:propstat>
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/calendars/alice/personal/</d:href>
<d:propstat>
<d:prop>
<d:displayname>Personal</d:displayname>
<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>
<c:calendar-description>My personal calendar</c:calendar-description>
<cs:calendar-color>#FF0000</cs:calendar-color>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/calendars/alice/holidays/</d:href>
<d:propstat>
<d:prop>
<d:displayname>Public Holidays</d:displayname>
<d:resourcetype><d:collection/><cs:subscribed/></d:resourcetype>
<ical:calendar-color>#00FF00</ical:calendar-color>
<cs:source><d:href>https://example.com/holidays.ics</d:href></cs:source>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>"""
def _calendar_client_with_propfind(mocker, raw_xml: str):
"""Build a CalendarClient whose DAV client returns ``raw_xml`` from PROPFIND."""
mock_dav_client = mocker.patch(
"nextcloud_mcp_server.client.calendar.AsyncDAVClient"
)
instance = mock_dav_client.return_value
instance.propfind = mocker.AsyncMock(return_value=mocker.Mock(raw=raw_xml))
from nextcloud_mcp_server.client.calendar import CalendarClient
client = CalendarClient("https://cloud.example.org", "alice", password="app-pw")
return client, instance
async def test_list_calendars_includes_external_subscription(mocker):
"""External subscriptions are returned alongside regular calendars and are
flagged read-only with their source feed URL (issue #830).
"""
client, _ = _calendar_client_with_propfind(mocker, _LIST_CALENDARS_MULTISTATUS)
calendars = await client.list_calendars()
by_name = {cal["name"]: cal for cal in calendars}
# The calendar home (plain collection) is not reported.
assert set(by_name) == {"personal", "holidays"}
personal = by_name["personal"]
assert personal["display_name"] == "Personal"
assert personal["description"] == "My personal calendar"
assert personal["color"] == "#FF0000"
assert personal["read_only"] is False
assert personal["source"] is None
holidays = by_name["holidays"]
assert holidays["display_name"] == "Public Holidays"
assert holidays["read_only"] is True
assert holidays["source"] == "https://example.com/holidays.ics"
# Subscriptions store their color under the Apple iCal namespace.
assert holidays["color"] == "#00FF00"
async def test_list_calendars_disables_webcal_caching_for_propfind(mocker):
"""The listing PROPFIND overrides the client-wide header to "Off" so
subscriptions surface as cs:subscribed (with a source) rather than as
opaque regular calendars.
"""
client, instance = _calendar_client_with_propfind(
mocker, _LIST_CALENDARS_MULTISTATUS
)
await client.list_calendars()
kwargs = instance.propfind.call_args.kwargs
assert kwargs["headers"]["X-NC-CalDAV-Webcal-Caching"] == "Off"
# The custom property XML must travel as ``body`` — caldav's ``props=``
# expects a list of property names and would discard a raw XML string,
# sending an empty <prop/> that returns neither resourcetype nor cs:source.
assert "cs:source" in kwargs["body"]
assert "props" not in kwargs
async def test_list_calendars_model_round_trip(mocker):
"""The dicts returned by list_calendars validate against the Calendar model,
mirroring the server's ``Calendar(**cal_data)`` mapping.
"""
client, _ = _calendar_client_with_propfind(mocker, _LIST_CALENDARS_MULTISTATUS)
from nextcloud_mcp_server.models.calendar import Calendar
calendars = [Calendar(**cal) for cal in await client.list_calendars()]
holidays = next(c for c in calendars if c.name == "holidays")
assert holidays.read_only is True
assert holidays.source == "https://example.com/holidays.ics"