diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0b1136..daa7f34d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 * `search()`'s generator driver now feeds exceptions raised while executing a request back into the search logic, so the server-compatibility fallbacks and per-object load error handling actually take effect (previously dead code). Applies to both the sync and async code paths. * `compatibility_hints`: OX was pinned to `create-calendar.set-displayname: unsupported` (a value masked by a checker bug that verified the feature by display-name lookup, which a leftover/colliding calendar would shadow); OX stores the display name as a property separate from the calendar URL and honours it at creation time, so the expectation is corrected to `full`. * `compatibility_hints`: Stalwart's `search.recurrences.expanded.exception` was inheriting the default `full`, but Stalwart's server-side `CALDAV:expand` only suppresses the exception-overridden occurrence when `SEQUENCE` is absent. With `SEQUENCE` present (as real-world clients always emit) it returns both the original occurrence and the override, so the expectation is corrected to `fragile`. +* `compatibility_hints`: the `synology` profile understated what Synology Calendar can do (it embeds a modified DAViCal, and re-probing a DSM 7 server shows it behaves like DAViCal nearly everywhere). Text search is no longer assumed case-insensitive, `is-not-defined` searches and the `calendar-color`/`calendar-order` properties are now used rather than avoided, and a time-range search without a component type is sent as one query instead of being split per component type. Delivery of iTIP scheduling requests to the attendee inbox is likewise no longer assumed absent. Calendar deletion is still unsupported there - Synology refuses a CalDAV `DELETE` on a calendar collection, so `Calendar.delete()` empties the calendar instead and its `cal_id` cannot be reused. The profile is now expressed as `synology = davical | {deviations}`, so shared DAViCal quirks are stated once. * Config file sections with `features` but no `caldav_url` were rejected, even though the URL can be derived from the `auto-connect.url` compatibility hints. Explicitly passed parameters already worked this way; now `get_davclient(config_section=...)` and friends behave consistently. * `jmap/convert/jscal_to_ical.py`: a `recurrenceOverrides` entry that does not include a `"start"` key (the common case — title-only change, description update, etc.) produced a child `VEVENT` with `DTSTART` copied from the master event's start time rather than from the override key. This effectively relocated every non-rescheduled override to the master's first occurrence, breaking all override display. Default is now the override key itself. * `jmap/convert/jscal_to_ical.py`: `EXDATE` and `RECURRENCE-ID` values were always emitted as floating (timezone-less) `DATE-TIME` regardless of the event's `timeZone` or `showWithoutTime` flag. Per RFC 5545 §3.8.5.1 the value type must match `DTSTART`; a floating `EXDATE` on a `TZID`-anchored event does not match any instance, so excluded occurrences reappear. Override keys are now parsed with the event timezone applied (`TZID`-anchored events) or as `date` objects (all-day events). diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index ea2bd4bf..606119e2 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -1345,21 +1345,6 @@ def compare(self, observed): ## save.duplicate-event is left at the default "full".) } -synology = { - 'principal-search': False, - 'sync-token': 'fragile', - 'delete-calendar': False, - 'search.comp-type.optional': 'fragile', - 'search.is-not-defined': {'support': 'fragile', 'behaviour': 'works for CLASS but not for CATEGORIES'}, - 'search.text.case-sensitive': {'support': 'unsupported'}, - 'search.time-range.alarm': {'support': 'unsupported'}, - ## Synology skips VTODOs without DTSTART in date-range searches. - 'search.time-range.todo.no-dtstart': {'support': 'unsupported'}, - 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, - 'scheduling.schedule-tag': False, - 'scheduling.mailbox.inbox-delivery': False, -} - baikal = { ## version 0.10.1 # Baikal (sabre/dav) delivers iTIP notifications to the attendee inbox AND auto-schedules # into their calendar. @@ -1425,6 +1410,8 @@ def compare(self, observed): # 'get_object_by_uid_is_broken' #] +## See the synology profile above: Synology Calendar ships a modified DAViCal, +## so the two profiles should be kept in sync where the fork has not diverged. davical = { # Disable HTTP/2 multiplexing - davical doesn't support it well and niquests # lazy responses cause MultiplexingError when accessing status_code @@ -1454,6 +1441,29 @@ def compare(self, observed): "calendar-order": {"support": "full"}, } +## Synology Calendar (the DSM package) embeds a modified DAViCal, hence +## deriving from the davical profile above. +## +## Re-probed with caldav-server-tester against a DSM 7 Synology Calendar +## 2026-08-23: every feature the checker could test matched davical except +## calendar deletion, so only the deviations are listed here. +synology = davical | { + ## The one real behavioural divergence of the fork: DSM manages calendars + ## through its own UI/API and refuses a CalDAV DELETE on the collection, + ## where upstream DAViCal allows it. Consequence for callers: Calendar + ## .delete() degrades to wiping the objects, so a cal_id is never freed for + ## reuse and a later MKCALENDAR at the same id gets 405. + 'delete-calendar': False, + ## (scheduling.mailbox.inbox-delivery used to be pinned False here. It was + ## added in a bulk hints commit with no per-server evidence, and it cannot be + ## probed on the configured server - the checker needs a second user account, + ## and every test that reads it skips on the principal count first. So it was + ## an unfalsifiable guess; it now inherits davical, which delivers iTIP + ## requests to the attendee inbox. Pin it again only with a real observation.) + ## Test bookkeeping rather than a server feature. + 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, +} + sogo = { "scheduling.schedule-tag": False, "scheduling.mailbox.inbox-delivery": False, diff --git a/tests/fixture_helpers.py b/tests/fixture_helpers.py index b7bb7b99..62825de3 100644 --- a/tests/fixture_helpers.py +++ b/tests/fixture_helpers.py @@ -119,10 +119,17 @@ async def _get_or_create_impl( ## should use a dedicated calendar. This can be specified in the features ## as for now. test_cal_info = client.features.is_supported("test-calendar", return_type=dict) - if "name" in test_cal_info or "cal_url" in test_cal_info or "cal_id" in test_cal_info: + ## Only the keys principal.calendar() actually accepts may be forwarded - the + ## same dict also carries bookkeeping like "support" and "cleanup-regime". + ## cal_url is a keyword of its own and is *not* interchangeable with cal_id: + ## it is joined against the client URL verbatim, while cal_id is URL-quoted + ## and joined against the calendar home set (a PROPFIND on async clients). + lookup = {k: v for k, v in test_cal_info.items() if k in ("name", "cal_id", "cal_url")} + if lookup: ## TODO: we should consider some better error messages if the configured calendar ## does not exist - return (principal.calendar(**test_cal_info), False) + ## A lookup by name round-trips, so on an async client this is a coroutine. + return (await _maybe_await(principal.calendar(**lookup)), False) # Check if server supports calendar creation via features supports_create = True @@ -224,6 +231,126 @@ async def aget_or_create_test_calendar( ) +def _supports(client: Any, feature: str) -> bool: + """Feature lookup that defaults to True when the client carries no feature set. + + A missing/empty feature set means "nothing is known about this server", and + the fixtures have always treated that as "assume it works". + """ + features = getattr(client, "features", None) + return features.is_supported(feature) if features else True + + +async def atry_principal(client: Any) -> Any: + """Discover the principal, or ``None`` if the server won't tell us. + + Some servers don't support principal discovery at all; the async fixtures + treat that as "no principal" and fall back to URL-based lookups. + """ + from caldav.aio import AsyncPrincipal + from caldav.lib.error import AuthorizationError, NotFoundError + + try: + return await AsyncPrincipal.create(client) + except (NotFoundError, AuthorizationError): + return None + + +async def afix_calendar( + client: Any, + principal: Any, + *, + cal_id: str, + calendar_name: str | None = None, + keep_name: bool = False, + supported_calendar_component_set: list[str] | None = None, +) -> tuple[Any, bool]: + """Async counterpart of ``test_caldav.py``'s ``_fixCalendar``. + + Hands back an *empty* test calendar at ``cal_id`` plus a flag telling + whether it had to be created, taking care of the server-capability + bookkeeping that every async fixture and every async test that wants its + own calendar used to repeat inline: + + * a leftover calendar from an interrupted run is deleted first - but only + on servers where deleting actually frees the URL. On servers where it + does not (``delete-calendar`` unsupported: Synology, Nextcloud), a + ``delete()`` is a no-op wipe, the MKCALENDAR that follows would 405 with + "a collection already exists at that location", and the correct move is + to reuse the calendar instead. + * the display name is dropped on servers that cannot set one, or that move + the calendar to a server-chosen URL when one is set, and on + component-restricted calendars - same three-legged rule as + ``_fixCalendar_``: the fixture is always looked up by ``cal_id``. Pass + ``keep_name=True`` to opt out, for a test whose subject *is* the display + name; that is the async spelling of ``_fixCalendar_``'s "only mangle the + name when the caller did not supply one" (``if "name" not in kwargs``). + * whatever is left inside the calendar is wiped, so the caller can count + objects from zero whether the calendar is brand new or reused. + + Pair every call with :func:`arelease_calendar`. + """ + from caldav.lib import error + + ## A component-restricted fixture (VTODO-only / VJOURNAL-only) is only ever + ## found by cal_id, and naming it can collide on servers enforcing + ## per-principal unique calendar names (SOGo) - so it stays nameless too. + restricted = bool(supported_calendar_component_set) and ( + "VEVENT" not in supported_calendar_component_set + ) + if ( + calendar_name is not None + and not keep_name + and ( + restricted + or not _supports(client, "create-calendar.set-displayname") + or not _supports(client, "create-calendar.stable-url") + ) + ): + calendar_name = None + + if principal is not None and _supports(client, "delete-calendar.free-namespace"): + try: + await adelete_calendar_if_present(principal, cal_id) + except error.DeleteError: + ## The server advertises calendar deletion but refused this one. + ## Not fatal: get_or_create below reuses whatever is there. + pass + + calendar, created = await aget_or_create_test_calendar( + client, + principal, + calendar_name=calendar_name, + cal_id=cal_id, + supported_calendar_component_set=supported_calendar_component_set, + ) + + if calendar is not None: + await cleanup_calendar_objects(calendar) + + return calendar, created + + +async def arelease_calendar(client: Any, calendar: Any, created: bool) -> None: + """Tear down a calendar handed out by :func:`afix_calendar`. + + Deletes it when we created it and deletion frees the URL again; otherwise + just empties it, so that servers moving deleted calendars to a trashbin + (or refusing deletion outright) don't accumulate junk. + """ + if calendar is None: + return + if created and _supports(client, "delete-calendar.free-namespace"): + try: + await _maybe_await(calendar.delete()) + return + except Exception: + ## Best-effort teardown: a calendar that refuses to go must not + ## turn a passing test red. Fall through and at least empty it. + pass + await cleanup_calendar_objects(calendar) + + async def cleanup_calendar_objects(calendar: Any) -> None: """ Remove all objects from a calendar (for test isolation). diff --git a/tests/test_async_integration.py b/tests/test_async_integration.py index 36d8f502..d7ceaab9 100644 --- a/tests/test_async_integration.py +++ b/tests/test_async_integration.py @@ -274,53 +274,48 @@ async def async_principal(self, async_client: Any) -> Any: principal = AsyncPrincipal(client=async_client, url=async_client.url) return principal - @pytest_asyncio.fixture - async def async_calendar(self, async_client: Any) -> Any: - """Create or find a stable test calendar, wiping it before and after use. - - Uses a stable cal_id so the calendar is reused across tests. For servers - where deletion moves calendars to a trashbin (e.g. Nextcloud), we wipe - objects only rather than deleting the calendar, keeping the trashbin empty. + async def _afixture_calendar( + self, + async_client: Any, + *, + cal_id: str, + what: str, + supported_calendar_component_set: list[str] | None = None, + ) -> Any: + """Shared body of the calendar fixtures below. + + An async generator (not a fixture itself) so each fixture can keep its + own name, docstring and cal_id while the create/wipe/teardown logic + lives in exactly one place - see fixture_helpers.afix_calendar. """ - from caldav.aio import AsyncPrincipal - from caldav.lib.error import AuthorizationError, NotFoundError - - from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects - - feats = getattr(async_client, "features", None) + from .fixture_helpers import afix_calendar, arelease_calendar, atry_principal - def _feat(name: str) -> bool: - return feats.is_supported(name) if feats else True - - delete_frees_namespace = _feat("delete-calendar.free-namespace") - - principal = None - try: - principal = await AsyncPrincipal.create(async_client) - except (NotFoundError, AuthorizationError): - pass - - calendar, created = await aget_or_create_test_calendar( + principal = await atry_principal(async_client) + calendar, created = await afix_calendar( async_client, principal, - calendar_name="pythoncaldav-async-test", - cal_id="pythoncaldav-async-test", + cal_id=cal_id, + calendar_name=cal_id, + supported_calendar_component_set=supported_calendar_component_set, ) - if calendar is None: - pytest.skip("Could not create or find a calendar for testing") - - await cleanup_calendar_objects(calendar) + pytest.skip(f"Could not create or find {what}") yield calendar - if delete_frees_namespace and created: - try: - await calendar.delete() - except Exception: - pass - else: - await cleanup_calendar_objects(calendar) + await arelease_calendar(async_client, calendar, created) + + @pytest_asyncio.fixture + async def async_calendar(self, async_client: Any) -> Any: + """Create or find a stable test calendar, wiping it before and after use. + + Uses a stable cal_id so the calendar is reused across tests; afix_calendar + handles the server-capability bookkeeping (see its docstring). + """ + async for calendar in self._afixture_calendar( + async_client, cal_id="pythoncaldav-async-test", what="a calendar for testing" + ): + yield calendar @pytest_asyncio.fixture async def async_task_list(self, async_client: Any) -> Any: @@ -331,142 +326,37 @@ async def async_task_list(self, async_client: Any) -> Any: rather than being deleted and recreated, avoiding trashbin accumulation on servers like Nextcloud. """ - from caldav.aio import AsyncPrincipal - from caldav.lib.error import AuthorizationError, NotFoundError - - from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects - - feats = getattr(async_client, "features", None) - - def _feat(name: str) -> bool: - return feats.is_supported(name) if feats else True - - supports_mixed = _feat("save-load.todo.mixed-calendar") - delete_frees_namespace = _feat("delete-calendar.free-namespace") - - component_set: list[str] | None = ["VTODO"] if not supports_mixed else None - cal_id = "pythoncaldav-async-test-tasks" - supports_displayname = _feat("create-calendar.set-displayname") - calendar_name = cal_id if supports_displayname else None - - principal = None - try: - principal = await AsyncPrincipal.create(async_client) - except (NotFoundError, AuthorizationError): - pass - - calendar, created = await aget_or_create_test_calendar( + ## Servers that can't hold VEVENTs and VTODOs in the same calendar + ## (e.g. Zimbra, OX) need a component-restricted one. + component_set = None if self.is_supported("save-load.todo.mixed-calendar") else ["VTODO"] + async for calendar in self._afixture_calendar( async_client, - principal, - calendar_name=calendar_name, - cal_id=cal_id, + cal_id="pythoncaldav-async-test-tasks", + what="a task list for testing", supported_calendar_component_set=component_set, - ) - - if calendar is None: - pytest.skip("Could not create or find a task list for testing") - - await cleanup_calendar_objects(calendar) - - yield calendar - - if delete_frees_namespace and created: - try: - await calendar.delete() - except Exception: - pass - else: - await cleanup_calendar_objects(calendar) + ): + yield calendar @pytest_asyncio.fixture async def async_calendar2(self, async_client: Any) -> Any: """Create or find a stable second test calendar for tests needing two calendars.""" - from caldav.aio import AsyncPrincipal - from caldav.lib.error import AuthorizationError, NotFoundError - - from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects - - feats = getattr(async_client, "features", None) - - def _feat(name: str) -> bool: - return feats.is_supported(name) if feats else True - - delete_frees_namespace = _feat("delete-calendar.free-namespace") - - principal = None - try: - principal = await AsyncPrincipal.create(async_client) - except (NotFoundError, AuthorizationError): - pass - - calendar, created = await aget_or_create_test_calendar( + async for calendar in self._afixture_calendar( async_client, - principal, - calendar_name="pythoncaldav-async-test-2", cal_id="pythoncaldav-async-test-2", - ) - - if calendar is None: - pytest.skip("Could not create or find a second calendar for testing") - - await cleanup_calendar_objects(calendar) - - yield calendar - - if delete_frees_namespace and created: - try: - await calendar.delete() - except Exception: - pass - else: - await cleanup_calendar_objects(calendar) + what="a second calendar for testing", + ): + yield calendar @pytest_asyncio.fixture async def async_journal_list(self, async_client: Any) -> Any: """Create or find a stable VJOURNAL calendar, wiping it before and after use.""" - from caldav.aio import AsyncPrincipal - from caldav.lib.error import AuthorizationError, NotFoundError - - from .fixture_helpers import aget_or_create_test_calendar, cleanup_calendar_objects - - feats = getattr(async_client, "features", None) - - def _feat(name: str) -> bool: - return feats.is_supported(name) if feats else True - - delete_frees_namespace = _feat("delete-calendar.free-namespace") - supports_displayname = _feat("create-calendar.set-displayname") - cal_id = "pythoncaldav-async-journal" - calendar_name = cal_id if supports_displayname else None - - principal = None - try: - principal = await AsyncPrincipal.create(async_client) - except (NotFoundError, AuthorizationError): - pass - - calendar, created = await aget_or_create_test_calendar( + async for calendar in self._afixture_calendar( async_client, - principal, - calendar_name=calendar_name, - cal_id=cal_id, + cal_id="pythoncaldav-async-journal", + what="a journal list for testing", supported_calendar_component_set=["VJOURNAL"], - ) - - if calendar is None: - pytest.skip("Could not create or find a journal list for testing") - - await cleanup_calendar_objects(calendar) - - yield calendar - - if delete_frees_namespace and created: - try: - await calendar.delete() - except Exception: - pass - else: - await cleanup_calendar_objects(calendar) + ): + yield calendar async def _make_async_client_with_params(self, **overrides: Any) -> Any: """Build a fresh async client from this server's config with kwargs overridden. @@ -515,6 +405,11 @@ async def test_principal_make_calendar(self, async_client: Any) -> None: calendar = None principal = None + # Deliberately NOT using fixture_helpers.afix_calendar here: this test + # exercises calendar creation itself, including the CalendarSet-at-client- + # URL path for servers without principal discovery (Radicale), which the + # create-or-reuse helper does not cover. + # # Try principal-based calendar creation (most servers). Clear any # leftover with this cal_id first, so we exercise real creation and # don't accumulate calendars (some servers enforce a quota). @@ -1998,11 +1893,9 @@ async def test_find_calendar_owner(self, async_calendar: Any, async_client: Any) @pytest.mark.asyncio async def test_set_calendar_properties(self, async_client: Any) -> None: """get_properties/set_properties round-trip for DisplayName.""" - from caldav.aio import AsyncPrincipal from caldav.elements import dav - from caldav.lib.error import AuthorizationError, NotFoundError - from .fixture_helpers import adelete_calendar_if_present + from .fixture_helpers import afix_calendar, arelease_calendar, atry_principal self.skip_unless_support("create-calendar.set-displayname") ## This test expects the display name to round-trip at a stable URL; @@ -2011,22 +1904,29 @@ async def test_set_calendar_properties(self, async_client: Any) -> None: self.skip_unless_support("delete-calendar") self.skip_unless_support("create-calendar") - principal = None - try: - principal = await AsyncPrincipal.create(async_client) - except (NotFoundError, AuthorizationError): + principal = await atry_principal(async_client) + if principal is None: pytest.skip("Cannot discover principal") - cal_id = "pythoncaldav-async-props-test" - await adelete_calendar_if_present(principal, cal_id) - ## Use a distinct display name (not the sync fixture's "Yep") so that an ## interrupted run of this test can never leave behind a second calendar ## named "Yep" that would make the sync suite's principal.calendar(name="Yep") ## lookup ambiguous. This test only checks that the display name round-trips, ## so the actual name is irrelevant. - c = await principal.make_calendar(name="AsyncYep", cal_id=cal_id) + c, created = await afix_calendar( + async_client, + principal, + cal_id="pythoncaldav-async-props-test", + calendar_name="AsyncYep", + ) try: + ## Given the skips above (delete-calendar, create-calendar and + ## set-displayname/stable-url support) a fresh calendar must have been + ## created. If it wasn't, the server regressed on a feature it + ## advertises as supported - that is a failure, not a reason to skip, + ## which is how the pre-consolidation version of this test behaved + ## (make_calendar() simply raised). + assert created, "server advertises delete- and create-calendar, but no fresh calendar" props = await c.get_properties([dav.DisplayName()]) assert "AsyncYep" == props[dav.DisplayName.tag] @@ -2034,7 +1934,7 @@ async def test_set_calendar_properties(self, async_client: Any) -> None: props = await c.get_properties([dav.DisplayName()]) assert props[dav.DisplayName.tag] == "hooray-async" finally: - await c.delete() + await arelease_calendar(async_client, c, created) # ==================== Group F – Regressions ==================== @@ -2348,21 +2248,27 @@ async def test_utf8_event(self, async_client: Any) -> None: self.skip_unless_support("save-load.event") self.skip_unless_support("create-calendar") - from caldav.aio import AsyncPrincipal - from caldav.lib.error import AuthorizationError, NotFoundError + from .fixture_helpers import afix_calendar, arelease_calendar, atry_principal - from .fixture_helpers import adelete_calendar_if_present - - principal = None - try: - principal = await AsyncPrincipal.create(async_client) - except (NotFoundError, AuthorizationError): + principal = await atry_principal(async_client) + if principal is None: pytest.skip("Cannot discover principal") - cal_id = "pythoncaldav-async-utf8-test" - await adelete_calendar_if_present(principal, cal_id) - - c = await principal.make_calendar(name="Yølp", cal_id=cal_id) + ## keep_name=True: the non-ASCII *display name* is half of what this test + ## checks, so it is sent unconditionally - exactly as the sync + ## testUtf8Event does by passing name="Yølp" to _fixCalendar (which only + ## applies its name-dropping rule when the caller supplied no name). + ## Servers that relocate a named calendar (Zimbra, OX) are fine: the + ## library re-points to the canonical URL. + c, created = await afix_calendar( + async_client, + principal, + cal_id="pythoncaldav-async-utf8-test", + calendar_name="Yølp", + keep_name=True, + ) + if c is None: + pytest.skip("Could not create or find a calendar for testing") try: await c.add_event( near_now_ics(ev1_static).replace("Bastille Day Party", "Bringebærsyltetøyfestival") @@ -2371,7 +2277,7 @@ async def test_utf8_event(self, async_client: Any) -> None: if "zimbra" not in str(c.url): assert len(events) == 1 finally: - await c.delete() + await arelease_calendar(async_client, c, created) @pytest.mark.asyncio async def test_create_calendar_and_event_from_vobject(self, async_calendar: Any) -> None: diff --git a/tests/test_compatibility_hints.py b/tests/test_compatibility_hints.py index 79dc0a9d..196c369b 100644 --- a/tests/test_compatibility_hints.py +++ b/tests/test_compatibility_hints.py @@ -565,8 +565,12 @@ def test_base_with_overrides(self) -> None: # Should have the overrides assert result["sync-token"] == "full" assert result["search.text.substring"] == {"support": "unsupported"} - # Should still have base features - assert result["search.text.case-sensitive"] == {"support": "unsupported"} + # Should still have base features. Asserted over the whole base dict + # rather than one hand-picked key, so that re-probing a server and + # dropping a key from its profile cannot break this test again. + for key, value in ch.synology.items(): + if key not in features: + assert result[key] == value # Should not have modified the original synology dict assert ch.synology.get("sync-token") == original_sync_token assert "search.text.substring" not in ch.synology diff --git a/tests/test_fixture_helpers.py b/tests/test_fixture_helpers.py new file mode 100644 index 00000000..d8937b68 --- /dev/null +++ b/tests/test_fixture_helpers.py @@ -0,0 +1,296 @@ +"""Unit tests for the test-suite's own calendar fixture helpers. + +``tests/fixture_helpers.py`` is what every functional test uses to get hold of +a test calendar, and it is full of server-capability branches that only trigger +against particular real servers. These tests drive those branches with fakes so +a regression shows up in a two-second unit run rather than in an integration run +against one specific external server. + +Fakes are fine here: this is a unit test *of the test infrastructure*, not an +integration test. +""" + +from typing import Any + +import pytest + +from caldav.compatibility_hints import FeatureSet +from caldav.lib import error + +from .fixture_helpers import ( + _get_or_create_impl, + afix_calendar, + arelease_calendar, +) + + +class FakeObject: + def __init__(self, calendar: "FakeCalendar") -> None: + self.calendar = calendar + + async def delete(self) -> None: + self.calendar.objects.remove(self) + + +class FakeCalendar: + def __init__( + self, + url: str = "http://dav.example.com/cal/", + n_objects: int = 0, + exists: bool = True, + ) -> None: + self.url = url + self.objects: list[FakeObject] = [FakeObject(self) for _ in range(n_objects)] + self.deleted = False + ## A Calendar object built from a cal_id is just a URL - the collection + ## behind it may not exist at all, and then every request 404s. + self.exists = exists + + async def search(self) -> list[FakeObject]: + if not self.exists: + raise error.NotFoundError(f"no collection at {self.url}") + return list(self.objects) + + async def delete(self, wipe: bool | None = None) -> None: + if not self.exists: + raise error.NotFoundError(f"no collection at {self.url}") + self.deleted = True + + +class FakePrincipal: + """Principal that only lets a calendar be created once, like a real server. + + A second MKCALENDAR at the same cal_id fails with ``MkcalendarError``, which + is what a server whose calendars cannot be deleted (Synology, Nextcloud) + replies with on the second run of a test - 405 "a collection already exists + at that location". + """ + + def __init__(self, existing: dict[str, FakeCalendar] | None = None) -> None: + self.calendars: dict[str, FakeCalendar] = dict(existing or {}) + self.make_calendar_calls: list[dict[str, Any]] = [] + self.calendar_calls: list[dict[str, Any]] = [] + + async def make_calendar(self, **kwargs: Any) -> FakeCalendar: + self.make_calendar_calls.append(kwargs) + cal_id = kwargs.get("cal_id") + if cal_id in self.calendars: + raise error.MkcalendarError("405 Method Not Allowed - a collection already exists") + calendar = FakeCalendar(url=f"http://dav.example.com/{cal_id}/") + self.calendars[cal_id] = calendar + return calendar + + async def calendar( + self, + name: str | None = None, + cal_id: str | None = None, + cal_url: str | None = None, + ) -> FakeCalendar: + ## Same signature as the real Principal.calendar() - cal_url included, + ## which resolves against the *client* URL rather than the calendar + ## home set and therefore must not be folded into cal_id. + self.calendar_calls.append({"name": name, "cal_id": cal_id, "cal_url": cal_url}) + if cal_url is not None: + return FakeCalendar(url=f"http://dav.example.com{cal_url}") + if cal_id is not None: + ## Like the real thing: a cal_id lookup does no I/O, it just builds a + ## URL. Whether anything lives there is discovered on first use. + if cal_id in self.calendars: + return self.calendars[cal_id] + return FakeCalendar(url=f"http://dav.example.com/{cal_id}/", exists=False) + raise error.NotFoundError(f"no such calendar: {name}") + + async def get_calendars(self) -> list[FakeCalendar]: + return list(self.calendars.values()) + + +class FakeClient: + def __init__(self, hints: dict[str, Any] | None = None) -> None: + self.features = FeatureSet(hints or {}) + + +@pytest.mark.asyncio +async def test_afix_calendar_creates_and_names() -> None: + """Happy path: fresh calendar, display name set, deleted again on release.""" + client = FakeClient() + principal = FakePrincipal() + + calendar, created = await afix_calendar( + client, principal, cal_id="testcal", calendar_name="Yep" + ) + + assert created + assert principal.make_calendar_calls == [{"name": "Yep", "cal_id": "testcal"}] + + await arelease_calendar(client, calendar, created) + assert calendar.deleted + + +@pytest.mark.asyncio +async def test_afix_calendar_reuses_and_wipes_when_calendar_cannot_be_deleted() -> None: + """A leftover calendar on a no-delete server is reused and emptied. + + This is the Synology/Nextcloud (and jeanes) case: ``delete()`` degrades to a + no-op wipe, so the leftover calendar survives and the MKCALENDAR that + follows 405s. The helper must hand back that calendar, emptied, rather than + letting the MkcalendarError escape. + """ + leftover = FakeCalendar(url="http://dav.example.com/testcal/", n_objects=3) + client = FakeClient({"delete-calendar": False}) + principal = FakePrincipal({"testcal": leftover}) + + calendar, created = await afix_calendar( + client, principal, cal_id="testcal", calendar_name="Yølp" + ) + + assert calendar is leftover + assert not created + assert calendar.objects == [], "the reused calendar should have been wiped" + assert not calendar.deleted, "a calendar we did not create must not be deleted" + + await arelease_calendar(client, calendar, created) + assert not calendar.deleted + + +@pytest.mark.asyncio +async def test_afix_calendar_deletes_leftover_when_deletion_frees_the_url() -> None: + """On a well-behaved server the leftover is deleted and a fresh one created.""" + leftover = FakeCalendar(url="http://dav.example.com/testcal/", n_objects=2) + client = FakeClient() + principal = FakePrincipal({"testcal": leftover}) + + ## adelete_calendar_if_present() is expected to remove it from the server, + ## which our fake models by dropping it from the dict. + async def delete(wipe: bool | None = None) -> None: + leftover.deleted = True + del principal.calendars["testcal"] + + leftover.delete = delete # type: ignore[method-assign] + + calendar, created = await afix_calendar(client, principal, cal_id="testcal") + + assert leftover.deleted + assert created + assert calendar is not leftover + + +@pytest.mark.parametrize( + "hints", + [ + {"create-calendar.set-displayname": False}, + {"create-calendar.stable-url": False}, + ], + ids=["no-set-displayname", "no-stable-url"], +) +@pytest.mark.asyncio +async def test_afix_calendar_drops_name_when_server_cannot_keep_it(hints: dict) -> None: + """Mirrors _fixCalendar_: nameless fixture unless the name sticks at the cal_id.""" + client = FakeClient(hints) + principal = FakePrincipal() + + await afix_calendar(client, principal, cal_id="testcal", calendar_name="Yep") + + assert principal.make_calendar_calls == [{"cal_id": "testcal"}] + + +@pytest.mark.asyncio +async def test_afix_calendar_returns_none_when_nothing_can_be_had() -> None: + """No creation support and no calendars on the server: the caller must skip.""" + client = FakeClient({"create-calendar": False}) + principal = FakePrincipal() + + calendar, created = await afix_calendar(client, principal, cal_id="testcal") + + assert calendar is None + assert not created + ## arelease_calendar must tolerate that None rather than blowing up in teardown + await arelease_calendar(client, calendar, created) + + +@pytest.mark.asyncio +async def test_configured_test_calendar_is_looked_up_and_awaited() -> None: + """A ``test-calendar`` name/cal_id in the server config short-circuits creation. + + Only the keys ``principal.calendar()`` accepts may be forwarded; the same + config dict also carries ``cleanup-regime`` (and FeatureSet may add + ``support``), and the async lookup returns a coroutine that must be awaited. + """ + configured = FakeCalendar(url="http://dav.example.com/configured/") + client = FakeClient( + {"test-calendar": {"cal_id": "configured", "cleanup-regime": "wipe-calendar"}} + ) + principal = FakePrincipal({"configured": configured}) + + calendar, created = await _get_or_create_impl(client, principal, cal_id="testcal") + + assert calendar is configured + assert not created + assert principal.make_calendar_calls == [] + + +@pytest.mark.asyncio +async def test_configured_cal_url_is_forwarded_as_cal_url() -> None: + """A ``test-calendar`` config carrying ``cal_url`` must not be folded into ``cal_id``. + + ``Principal.calendar()`` accepts all three of ``name``/``cal_id``/``cal_url``, + and they are not interchangeable: ``cal_url`` is joined against the *client* + URL and used verbatim, whereas ``cal_id`` is URL-quoted and joined against + the calendar home set - which on an async client also costs a PROPFIND that + the ``cal_url`` path deliberately avoids. + """ + client = FakeClient( + {"test-calendar": {"cal_url": "/dav/user/calendars/mycal/", "cleanup-regime": "thorough"}} + ) + principal = FakePrincipal() + + calendar, created = await _get_or_create_impl(client, principal, cal_id="testcal") + + assert not created + assert principal.make_calendar_calls == [] + assert principal.calendar_calls == [ + {"name": None, "cal_id": None, "cal_url": "/dav/user/calendars/mycal/"} + ] + assert calendar.url == "http://dav.example.com/dav/user/calendars/mycal/" + + +@pytest.mark.asyncio +async def test_afix_calendar_keeps_a_name_the_caller_says_matters() -> None: + """``keep_name`` mirrors ``_fixCalendar_``'s ``if "name" not in kwargs`` rule. + + The sync fixture only drops the display name when the *caller gave none*; a + test that passes a name explicitly - ``testUtf8Event`` with ``name="Yølp"`` - + always sends it, precisely because the name is what that test is about. + Async callers always pass a name (the fixtures use the cal_id as filler), so + the distinction has to be explicit rather than inferred from ``is None``. + """ + client = FakeClient({"create-calendar.set-displayname": False}) + principal = FakePrincipal() + + await afix_calendar(client, principal, cal_id="testcal", calendar_name="Yølp", keep_name=True) + + assert principal.make_calendar_calls == [{"name": "Yølp", "cal_id": "testcal"}] + + +@pytest.mark.asyncio +async def test_afix_calendar_drops_name_for_component_restricted_calendar() -> None: + """The ``restricted`` leg of ``_fixCalendar_``'s rule: VTODO-only stays nameless. + + A component-restricted fixture is only ever looked up by cal_id, and naming + it can collide on servers enforcing per-principal unique calendar names + (SOGo). The server here is fully capable, so only ``restricted`` can be + what drops the name. + """ + client = FakeClient() + principal = FakePrincipal() + + await afix_calendar( + client, + principal, + cal_id="testcal-tasks", + calendar_name="Tasks", + supported_calendar_component_set=["VTODO"], + ) + + assert principal.make_calendar_calls == [ + {"cal_id": "testcal-tasks", "supported_calendar_component_set": ["VTODO"]} + ]