Repository: python-caldav/caldav

Issue:
Robur issue with testCreateOverwriteDeleteEvent
`caldav/calendarobjectresource.py:634: KeyError`

href is given as `'/calendars/pythoncaldav-test/20010712T182145Z-123401%40example.com.ics'`

in the dict there is a `'/calendars/pythoncaldav-test/20010712T182145Z-123401@example.com.ics'`

Difference in encoding, `'@'` vs `'%40'`.

Hints from issue comments:
@tobixen: Problem is with `Event.load_by_multiget`.  Shouldn't this be utilizing `Calendar.calendar_multiget` instead?

We get this data from the server:

```
<D:multistatus xmlns:D="DAV:">
  <D:response>
    <D:href>/calendars/pythoncaldav-test/20010712T182145Z-123401%40example.com.ics</D:href>
    <D:status>HTTP/1.1 404 Not Found</D:status>
  </D:response>
</D:multistatus>
```

... but the code does not pick up the 404 properly.

Could it be that the failure to load by URL is due to the `@` in the URL?

I don't have time researching on how to fix this at the moment.@tobixen: Tests seems to work for Robur again, using the #492 code.  I will leave the pull request open for a while, piggy-backing in whatever other fixes is needed for getting tests passing under GMX and Posteo.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc364fd..69cdb5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,25 +8,43 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
 
 ## [2.0.0] - [Unreleased]
 
-In version 2.0, the requests library will be replaced with niquests or httpx.  See https://github.com/python-caldav/caldav/issues/457.  Master branch is currently running niquests.
+In version 2.0, the requests library will be replaced with niquests or httpx.  See https://github.com/python-caldav/caldav/issues/457.  Master branch is currently running niquests.  Work by @ArtemIsmagilov, https://github.com/python-caldav/caldav/pull/455
 
-In version 2.0, support for python 3.7 will be dropped, possibly also python 3.8.  Master branch supports both as for now.
+In version 2.0, support for python 3.7 and python 3.8 will be officially dropped.  Master branch *should* support both as for now, but Python 3.7 is no longer tested.
 
 ## [1.5.0] - [Unreleased]
 
 ### Deprecated
 
-Python 3.7 is no longer tested - but it should work.  Please file a bug report if it doesn't work.  (Note that the caldav library pulls in many dependencies, and not all of them supports dead snakes).
+Python 3.7 is no longer tested (dependency problems) - but it should work.  Please file a bug report if it doesn't work.  (Note that the caldav library pulls in many dependencies, and not all of them supports dead snakes).
 
-### Changed
+### Fixed
 
+* Servers that return a quoted URL in their path will now be parsed correctly by @edel-macias-cubix in https://github.com/python-caldav/caldav/pull/473
+* Compatibility workaround: If `event.load()` fails, it will retry the load by doing a multiget - https://github.com/python-caldav/caldav/pull/460 and  https://github.com/python-caldav/caldav/pull/475 - https://github.com/python-caldav/caldav/issues/459
+* Compatibility workaround: A problem with a wiki calendar fixed by @soundstorm in https://github.com/python-caldav/caldav/pull/469
+* Blank passwords should be acceptable - https://github.com/python-caldav/caldav/pull/481
+* Compatibility workaround: Accept XML content from calendar server even if it's marked up with content-type text/plain by @niccokunzmann in https://github.com/python-caldav/caldav/pull/465
+* Bugfix for saving component failing on multi-component recurrence objects - https://github.com/python-caldav/caldav/pull/467
 * Some exotic servers may return object URLs on search, but it does not work out to fetch the calendar data.  Now it will log an error instead of raising an error in such cases.
+* Some workarounds and fixes for getting tests passing on all the test servers I had at hand in https://github.com/python-caldav/caldav/pull/492
+
+### Changed
+
 * The `tests/compatibility_issues.py` has been moved to `caldav/compatibility_hints.py`, this to make it available for a caldav-server-tester-tool that I'm splitting off to a separate project/repository, and also to make https://github.com/python-caldav/caldav/issues/402 possible.
 
 #### Refactoring
 
 * Minor code cleanups by github user @ArtemIsmagilov in https://github.com/python-caldav/caldav/pull/456
-* The very much overgrown `objects.py`-file has been split into three.
+* The very much overgrown `objects.py`-file has been split into three - https://github.com/python-caldav/caldav/pull/483
+* Refactor compatibility issues by @tobixen in https://github.com/python-caldav/caldav/pull/484
+* Refactoring of `multiget` in https://github.com/python-caldav/caldav/pull/492
+
+### Documentation
+
+* Add more project links to PyPI by @niccokunzmann in https://github.com/python-caldav/caldav/pull/464
+* Document how to use tox for testing by @niccokunzmann in https://github.com/python-caldav/caldav/pull/466
+* Readthedocs integration has been repaired (https://github.com/python-caldav/caldav/pull/453 - but eventually the fix was introduced directly in the master branch)
 
 #### Test framework
 
@@ -39,20 +57,18 @@ Python 3.7 is no longer tested - but it should work.  Please file a bug report i
     * Allows offline testing of my upcoming `check_server_compatibility`-script
   * Also added the possibility to tag test servers with a name
 * Many changes done to the compatibility flag list (due to work on the server-checker project)
+* Functional tests for multiget in https://github.com/python-caldav/caldav/pull/489
 
 ### Added
 
+* support easy search for journals by @tobixen in https://github.com/python-caldav/caldav/pull/486
+* Methods for verifying and adding reverse relations - https://github.com/python-caldav/caldav/pull/336
+* Easy creation of events and tasks with alarms, search for alarms - https://github.com/python-caldav/caldav/pull/221
 * Work in progress: `auto_conn`, `auto_calendar` and `auto_calendars` may read caldav connection and calendar configuration from a config file, environmental variables or other sources.  Currently I've made the minimal possible work to be able to test the caldav-server-tester script.
 * By now `calendar.search(..., sort_keys=("DTSTART")` will work.  Sort keys expects a list or a tuple, but it's easy to send an attribute by mistake.  https://github.com/python-caldav/caldav/issues/448 https://github.com/python-caldav/caldav/pull/449
-* Compatibility workaround: If `event.load()` fails, it will retry the load by doing a multiget - https://github.com/python-caldav/caldav/pull/475 - https://github.com/python-caldav/caldav/issues/459
 * The `class_`-parameter now works when sending data to `save_event()` etc.
 * Search method now takes parameter `journal=True`.  ref https://github.com/python-caldav/caldav/issues/237 and https://github.com/python-caldav/caldav/pull/486
 
-### Fixed
-
-* Bugfix for saving component failing on multi-component recurrence objects - https://github.com/python-caldav/caldav/pull/467
-* Readthedocs integration has been repaired (https://github.com/python-caldav/caldav/pull/453 - but eventually the fix was introduced directly in the master branch)
-
 
 ## [1.4.0] - 2024-11-05
 
@@ -86,8 +102,6 @@ Python 3.7 is no longer tested - but it should work.  Please file a bug report i
 
 ### Added
 
-* Methods for verifying and adding reverse relations - https://github.com/python-caldav/caldav/pull/336
-* Easy creation of events and tasks with alarms, search for alarms - https://github.com/python-caldav/caldav/pull/221
 * Allow to reverse the sorting order on search function  by @twissell- in https://github.com/python-caldav/caldav/pull/433
 * Work on integrating typing information. Details in https://github.com/python-caldav/caldav/pull/358
 * Remove dependency on pytz. Details in https://github.com/python-caldav/caldav/issues/231 and https://github.com/python-caldav/caldav/pull/363
diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py
index 0205576..5a66091 100644
--- a/caldav/calendarobjectresource.py
+++ b/caldav/calendarobjectresource.py
@@ -61,7 +61,9 @@ else:
     from typing import Self
 
 from .davobject import DAVObject
-from .elements import cdav, dav
+from .elements.cdav import CalendarData
+from .elements import cdav
+from .elements import dav
 from .lib import error
 from .lib import vcal
 from .lib.error import errmsg
@@ -396,11 +398,6 @@ class CalendarObjectResource(DAVObject):
         """
         return self._handle_reverse_relations(verify=True, fix=True, pdb=pdb)
 
-    ## TODO: fix this (and consolidate with _handle_relations / set_relation?)
-    # def ensure_reverse_relations(self):
-    #     missing_relations = self.check_reverse_relations()
-    #     ...
-
     def _get_icalendar_component(self, assert_one=False):
         """Returns the icalendar subcomponent - which should be an
         Event, Journal, Todo or FreeBusy from the icalendar class
@@ -606,10 +603,10 @@ class CalendarObjectResource(DAVObject):
 
         try:
             r = self.client.request(str(self.url))
+            if r.status == 404:
+                raise error.NotFoundError(errmsg(r))
         except:
-            return self.load_by_multiget()
-        if r.status == 404:
-            raise error.NotFoundError(errmsg(r))
+            self.load_by_multiget()
         self.data = vcal.fix(r.raw)
         if "Etag" in r.headers:
             self.props[dav.GetEtag.tag] = r.headers["Etag"]
@@ -623,15 +620,17 @@ class CalendarObjectResource(DAVObject):
         with a multiget query
         """
         error.assert_(self.url)
-        href = self.url.path
-        prop = dav.Prop() + CalendarData()
-        root = cdav.CalendarMultiGet() + prop + dav.Href(value=href)
-        response = self.parent._query(root, 1, "report")
-        results = response.expand_simple_props([CalendarData()])
-        error.assert_(len(results) == 1)
-        data = results[href][CalendarData.tag]
-        error.assert_(data)
-        self.data = data
+        mydata = self.parent._multiget(event_urls=[self.url], raise_notfound=True)
+        try:
+            url, self.data = next(mydata)
+        except StopIteration:
+            ## We shouldn't come here.  Something is wrong.
+            ## TODO: research it
+            ## As of 2025-05-20, this code section is used by
+            ## TestForServerECloud::testCreateOverwriteDeleteEvent
+            raise error.NotFoundError(self.url)
+        assert_(self.data)
+        assert_(next(mydata, None) is None)
         return self
 
     ## TODO: self.id should either always be available or never
diff --git a/caldav/collection.py b/caldav/collection.py
index 8d0a7fa..5f2b8cc 100644
--- a/caldav/collection.py
+++ b/caldav/collection.py
@@ -59,7 +59,9 @@ from .calendarobjectresource import FreeBusy
 from .calendarobjectresource import Journal
 from .calendarobjectresource import Todo
 from .davobject import DAVObject
-from .elements import cdav, dav
+from .elements.cdav import CalendarData
+from .elements import cdav
+from .elements import dav
 from .lib import error
 from .lib import vcal
 from .lib.python_utilities import to_wire
@@ -558,15 +560,14 @@ class Calendar(DAVObject):
             self._create(id=self.id, name=self.name, **self.extra_init_options)
         return self
 
-    ## TODO: this is missing test code.
-    ## TODO: needs refactoring:
-    ## Objects found may be Todo and Journal, not only Event.
-    ## Replace the last lines with _request_report_build_resultlist method
-    def calendar_multiget(self, event_urls: Iterable[URL]) -> List[_CC]:
+    # def data2object_class
+
+    def _multiget(
+        self, event_urls: Iterable[URL], raise_notfound: bool = False
+    ) -> Iterable[str]:
         """
-        get multiple events' data
-        @author mtorange@gmail.com
-        @type events list of Event
+        get multiple events' data.
+        TODO: Does it overlap the _request_report_build_resultlist method
         """
         if self.url is None:
             raise ValueError("Unexpected value None for self.url")
@@ -580,16 +581,40 @@ class Calendar(DAVObject):
         )
         response = self._query(root, 1, "report")
         results = response.expand_simple_props([cdav.CalendarData()])
-        rv = [
-            Event(
+        if raise_notfound:
+            for href in response.statuses:
+                status = response.statuses[href]
+                if "404" in status:
+                    raise error.NotFoundError(f"Status {status} in {href}")
+        for r in results:
+            yield (r, results[r][cdav.CalendarData.tag])
+
+    ## Replace the last lines with
+    def multiget(
+        self, event_urls: Iterable[URL], raise_notfound: bool = False
+    ) -> Iterable[_CC]:
+        """
+        get multiple events' data
+        TODO: Does it overlap the _request_report_build_resultlist method?
+        @author mtorange@gmail.com (refactored by Tobias)
+        """
+        results = self._multiget(event_urls, raise_notfound=raise_notfound)
+        for url, data in results:
+            yield self._calendar_comp_class_by_data(data)(
                 self.client,
-                url=self.url.join(r),
-                data=results[r][cdav.CalendarData.tag],
+                url=self.url.join(url),
+                data=data,
                 parent=self,
             )
-            for r in results
-        ]
-        return rv
+
+    def calendar_multiget(self, *largs, **kwargs):
+        """
+        get multiple events' data
+        @author mtorange@gmail.com
+        (refactored by Tobias)
+        This is for backward compatibility.  It may be removed in 3.0 or later release.
+        """
+        return list(self.multiget(*largs, **kwargs))
 
     ## TODO: Upgrade the warning to an error (and perhaps critical) in future
     ## releases, and then finally remove this method completely.
diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py
index 9b427b6..87330ac 100644
--- a/caldav/compatibility_hints.py
+++ b/caldav/compatibility_hints.py
@@ -77,6 +77,9 @@ incompatibility_description = {
     'no_scheduling_mailbox':
         """Parts of RFC6833 is supported, but not the existence of inbox/mailbox""",
 
+    'no_scheduling_calendar_user_address_set':
+        """Parts of RFC6833 is supported, but not getting the calendar users addresses""",
+
     'no_default_calendar':
         """The given user starts without an assigned default calendar """
         """(or without pre-defined calendars at all)""",
@@ -268,6 +271,9 @@ incompatibility_description = {
     'no_search':
         """Apparently the calendar server does not support search at all (this often implies that 'object_by_uid_is_broken' has to be set as well)""",
 
+    'no_search_openended':
+        """An open-ended search will not work""",
+
     'no_events_and_tasks_on_same_calendar':
         """Zimbra has the concept of task lists ... a calendar must either be a calendar with only events, or it can be a task list, but those must never be mixed"""
 }
@@ -419,10 +425,11 @@ davical = [
     'fragile_sync_tokens', ## no issue raised yet
     'vtodo_datesearch_nodtstart_task_is_skipped', ## no issue raised yet
     'broken_expand_on_exceptions', ## no issue raised yet
-    'date_todo_search_ignores_duration'
+    'date_todo_search_ignores_duration',
     'calendar_color',
     'calendar_order',
-    'vtodo_datesearch_notime_task_is_skipped'
+    'vtodo_datesearch_notime_task_is_skipped',
+    "no_alarmsearch",
 ]
 
 google = [
@@ -448,6 +455,9 @@ sogo = [ ## and in addition ... the requests are efficiently rate limited, as it
 
 nextcloud = [
     'date_search_ignores_duration',
+    'unique_calendar_ids',
+    'broken_expand',
+    'no_delete_calendar',
     'sync_breaks_on_delete',
     'no_recurring_todo',
     'combined_search_not_working',
@@ -488,7 +498,8 @@ robur = [
     'text_search_not_working',
     'no_relships',
     'isnotdefined_not_working',
-    'robur_rrule_freq_yearly_expands_monthly'
+    'no_alarmsearch',
+    'broken_expand',
 ]
 
 posteo = [
@@ -498,6 +509,7 @@ posteo = [
     'no_recurring_todo',
     'no_sync_token',
     'combined_search_not_working',
+    'no_alarmsearch',
     'broken_expand',
 ]
 
@@ -527,7 +539,22 @@ purelymail = [
 
     ## Purelymail claims that the search indexes are "lazily" populated,
     ## so search works some minutes after the event was created/edited.
-    'search_delay'
+    'search_delay',
+
+    ## I haven't raised this one with them yet
+    'no_alarmsearch',
+]
+
+gmx = [
+    "no_scheduling_mailbox",
+    "no_mkcalendar",
+    "search_needs_comptype",
+    #"text_search_is_case_insensitive",
+    "no_freebusy_rfc4791",
+    "no_expand",
+    "no_search_openended",
+    "no_sync_token",
+    "no_scheduling_calendar_user_address_set",
 ]
 
 # fmt: on
diff --git a/caldav/davclient.py b/caldav/davclient.py
index 0f5d60b..ef81acb 100644
--- a/caldav/davclient.py
+++ b/caldav/davclient.py
@@ -283,6 +283,7 @@ class DAVResponse:
         self.sync_token will be populated if found, self.objects will be populated.
         """
         self.objects: Dict[str, Dict[str, _Element]] = {}
+        self.statuses: Dict[str, str] = {}
 
         if "Schedule-Tag" in self.headers:
             self.schedule_tag = self.headers["Schedule-Tag"]
@@ -300,6 +301,7 @@ class DAVResponse:
             ## but then there was https://github.com/python-caldav/caldav/issues/136
             if href not in self.objects:
                 self.objects[href] = {}
+                self.statuses[href] = status
 
             ## The properties may be delivered either in one
             ## propstat with multiple props or in multiple
@@ -358,7 +360,7 @@ class DAVResponse:
             error.assert_(len(values) == 1)
             return values[0]
 
-    ## TODO: "expand" does not feel quite right.
+    ## TODO: word "expand" does not feel quite right.
     def expand_simple_props(
         self,
         props: Iterable[BaseElement] = None,
@@ -897,6 +899,7 @@ def auto_conn(
 
             try:
                 idx = int(name)
+                name = None
             except ValueError:
                 idx = None
             try:
diff --git a/caldav/davobject.py b/caldav/davobject.py
index 01cca54..d4e7309 100644
--- a/caldav/davobject.py
+++ b/caldav/davobject.py
@@ -216,11 +216,11 @@ class DAVObject:
             body = to_wire(body)
             if (
                 ret.status == 500
-                and b"getetag" not in body
-                and b"<C:calendar-data/>" in body
+                and b"D:getetag" not in body
+                and b"<C:calendar-data" in body
             ):
                 body = body.replace(
-                    b"<C:calendar-data/>", b"<D:getetag/><C:calendar-data/>"
+                    b"<C:calendar-data", b"<D:getetag/><C:calendar-data"
                 )
                 return self._query(
                     body, depth, query_method, url, expected_return_value
@@ -298,7 +298,7 @@ class DAVObject:
                 ## ... but it gets worse ... when doing a propfind on the
                 ## principal, the href returned may be without the slash.
                 ## Such inconsistency is clearly a bug.
-                log.error(
+                log.warning(
                     "potential path handling problem with ending slashes.  Path given: %s, path found: %s.  %s"
                     % (path, exchange_path, error.ERR_FRAGMENT)
                 )
@@ -323,7 +323,7 @@ class DAVObject:
             ## Ref https://github.com/python-caldav/caldav/issues/191 ...
             ## let's be pragmatic and just accept whatever the server is
             ## throwing at us.  But we'll log an error anyway.
-            log.error(
+            log.warning(
                 "Possibly the server has a path handling problem, possibly the URL configured is wrong.\n"
                 "Path expected: %s, path found: %s %s.\n"
                 "Continuing, probably everything will be fine"
@@ -331,7 +331,7 @@ class DAVObject:
             )
             rc = list(properties.values())[0]
         else:
-            log.error(
+            log.warning(
                 "Possibly the server has a path handling problem.  Path expected: %s, paths found: %s %s"
                 % (path, str(list(properties)), error.ERR_FRAGMENT)
             )
diff --git a/caldav/lib/error.py b/caldav/lib/error.py
index 0fb1d07..b79f31c 100644
--- a/caldav/lib/error.py
+++ b/caldav/lib/error.py
@@ -62,7 +62,7 @@ def assert_(condition: object) -> None:
             raise
 
 
-ERR_FRAGMENT: str = "Please consider raising an issue at https://github.com/python-caldav/caldav/issues or reach out to t-caldav@tobixen.no, include this error and the traceback and tell what server you are using"
+ERR_FRAGMENT: str = "Please consider raising an issue at https://github.com/python-caldav/caldav/issues or reach out to t-caldav@tobixen.no, include this error and the traceback (if any) and tell what server you are using"
 
 
 class DAVError(Exception):


Test patch:
diff --git a/tests/conf.py b/tests/conf.py
index bcfba3a..2204132 100644
--- a/tests/conf.py
+++ b/tests/conf.py
@@ -33,7 +33,13 @@ except ImportError:
 try:
     from .conf_private import caldav_servers
 except ImportError:
-    caldav_servers = []
+    try:
+        from conf_private import caldav_servers
+    except ImportError:
+        try:
+            from tests.conf_private import caldav_servers
+        except ImportError:
+            caldav_servers = []
 try:
     from .conf_private import test_private_test_servers
 
@@ -121,7 +127,7 @@ if test_radicale:
         i = 0
         while True:
             try:
-                niquests.get(self.url)
+                niquests.get(str(self.url))
                 break
             except:
                 time.sleep(0.05)
@@ -201,7 +207,7 @@ if test_xandikos:
         ## ... but the thread may be stuck waiting for a request ...
         def silly_request():
             try:
-                niquests.get(self.url)
+                niquests.get(str(self.url))
             except:
                 pass
 
@@ -241,7 +247,7 @@ def client(
 ):
     kwargs_ = kwargs.copy()
     no_args = not any(x for x in kwargs if kwargs[x] is not None)
-    if idx is None and no_args and caldav_servers:
+    if idx is None and name is None and no_args and caldav_servers:
         ## No parameters given - find the first server in caldav_servers list
         return client(idx=0)
     elif idx is not None and no_args and caldav_servers:
diff --git a/tests/test_caldav.py b/tests/test_caldav.py
index 7605491..54e54c2 100644
--- a/tests/test_caldav.py
+++ b/tests/test_caldav.py
@@ -136,6 +136,8 @@ uids_used = (
     "test4",
     "test5",
     "test6",
+    "c26921f4-0653-11ef-b756-58ce2a14e2e5",
+    "e2a2e13e-34f2-11f0-ae12-1c1bb5134174",
 )
 ## TODO: todo7 is an item without uid.  Should be taken care of somehow.
 
@@ -554,7 +556,7 @@ class TestScheduling:
     ## inbox/outbox?
 
 
-def _delay_decorator(f, t=60):
+def _delay_decorator(f, t=20):
     def foo(*a, **kwa):
         time.sleep(t)
         return f(*a, **kwa)
@@ -751,6 +753,7 @@ class RepeatedFunctionalTestsBaseClass:
 
     def testSchedulingInfo(self):
         self.skip_on_compatibility_flag("no_scheduling")
+        self.skip_on_compatibility_flag("no_scheduling_calendar_user_address_set")
         calendar_user_address_set = self.principal.calendar_user_address_set()
         me_a_participant = self.principal.get_vcal_address()
 
@@ -967,6 +970,8 @@ class RepeatedFunctionalTestsBaseClass:
         c = self._fixCalendar()
 
         existing_events = c.events()
+        existing_urls = {x.url for x in existing_events}
+        cleanse = lambda events: [x for x in events if x.url not in existing_urls]
 
         if not self.check_compatibility_flag("no_mkcalendar"):
             ## we're supposed to be working towards a brand new calendar
@@ -976,13 +981,13 @@ class RepeatedFunctionalTestsBaseClass:
         c.save_event(broken_ev1)
 
         # c.events() should give a full list of events
-        events = c.events()
-        assert len(events) == len(existing_events) + 1
+        events = cleanse(c.events())
+        assert len(events) == 1
 
         # We should be able to access the calender through the URL
         c2 = self.caldav.calendar(url=c.url)
-        events2 = c2.events()
-        assert len(events2) == len(existing_events) + 1
+        events2 = cleanse(c2.events())
+        assert len(events2) == 1
         assert events2[0].url == events[0].url
 
         if not self.check_compatibility_flag(
@@ -993,7 +998,7 @@ class RepeatedFunctionalTestsBaseClass:
             ## may break if we have multiple calendars with the same name
             if not self.check_compatibility_flag("no_delete_calendar"):
                 assert c2.url == c.url
-                events2 = c2.events()
+                events2 = cleanse(c2.events())
                 assert len(events2) == 1
                 assert events2[0].url == events[0].url
 
@@ -1014,6 +1019,7 @@ class RepeatedFunctionalTestsBaseClass:
         ev = c.save_event(
             dtstart=datetime(2015, 10, 10, 8, 0, 0),
             summary="This is a test event",
+            uid="test1",
             dtend=datetime(2016, 10, 10, 9, 0, 0),
             alarm_trigger=timedelta(minutes=-15),
             alarm_action="AUDIO",
@@ -1410,6 +1416,8 @@ class RepeatedFunctionalTestsBaseClass:
         self.skip_on_compatibility_flag("no_search")
         c = self._fixCalendar()
 
+        num_existing = len(c.events())
+
         c.save_event(ev1)
         c.save_event(ev3)
         c.save_event(evr)
@@ -1417,13 +1425,13 @@ class RepeatedFunctionalTestsBaseClass:
         ## Search without any parameters should yield everything on calendar
         all_events = c.search()
         if self.check_compatibility_flag("search_needs_comptype"):
-            assert len(all_events) <= 3
+            assert len(all_events) <= 3 + num_existing
         else:
-            assert len(all_events) == 3
+            assert len(all_events) == 3 + num_existing
 
         ## Search with comp_class set to Event should yield all events on calendar
         all_events = c.search(comp_class=Event)
-        assert len(all_events) == 3
+        assert len(all_events) == 3 + num_existing
 
         ## Search with todo flag set should yield no events
         try:
@@ -1589,6 +1597,11 @@ class RepeatedFunctionalTestsBaseClass:
         self.skip_on_compatibility_flag("no_todo")
         self.skip_on_compatibility_flag("no_search")
         c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
+        pre_todos = c.todos()
+        pre_todo_uid_map = {x.icalendar_component["uid"] for x in pre_todos}
+        cleanse = lambda tasks: [
+            x for x in tasks if x.icalendar_component["uid"] not in pre_todo_uid_map
+        ]
         t1 = c.save_todo(
             summary="1 task overdue",
             due=date(2022, 12, 12),
@@ -1628,14 +1641,22 @@ class RepeatedFunctionalTestsBaseClass:
                 "test" + str(x) for x in order
             ]
 
-        all_tasks = c.search(todo=True, sort_keys=("uid",))
+        all_tasks = cleanse(c.search(todo=True, sort_keys=("uid",)))
         check_order(all_tasks, (1, 2, 3, 4, 6))
 
-        all_tasks = c.search(sort_keys=("summary",))
+        all_tasks = cleanse(c.search(sort_keys=("summary",)))
         check_order(all_tasks, (1, 2, 3, 4, 5, 6))
 
-        all_tasks = c.search(
-            sort_keys=("isnt_overdue", "categories", "dtstart", "priority", "status")
+        all_tasks = cleanse(
+            c.search(
+                sort_keys=(
+                    "isnt_overdue",
+                    "categories",
+                    "dtstart",
+                    "priority",
+                    "status",
+                )
+            )
         )
         ## This is difficult ...
         ## * 1 is the only one that is overdue, and False sorts before True, so 1 comes first
@@ -1651,6 +1672,8 @@ class RepeatedFunctionalTestsBaseClass:
         self.skip_on_compatibility_flag("no_search")
         c = self._fixCalendar(supported_calendar_component_set=["VTODO"])
 
+        pre_cnt = len(c.todos())
+
         t1 = c.save_todo(todo)
         t2 = c.save_todo(todo2)
         t3 = c.save_todo(todo3)
@@ -1661,13 +1684,13 @@ class RepeatedFunctionalTestsBaseClass:
         ## Search without any parameters should yield everything on calendar
         all_todos = c.search()
         if self.check_compatibility_flag("search_needs_comptype"):
-            assert len(all_todos) <= 6
+            assert len(all_todos) <= 6 + pre_cnt
         else:
-            assert len(all_todos) == 6
+            assert len(all_todos) == 6 + pre_cnt
 
         ## Search with comp_class set to Event should yield all events on calendar
         all_todos = c.search(comp_class=Event)
-        assert len(all_todos) == 0
+        assert len(all_todos) == 0 + pre_cnt
 
         ## Search with todo flag set should yield all 6 tasks
         ## (Except, if the calendar server does not support is-not-defined very
@@ -1675,20 +1698,20 @@ class RepeatedFunctionalTestsBaseClass:
         ## https://gitlab.com/davical-project/davical/-/issues/281 )
         all_todos = c.search(todo=True)
         if self.check_compatibility_flag("isnotdefined_not_working"):
-            assert len(all_todos) in (3, 6)
+            assert len(all_todos) - pre_cnt in (3, 6)
         else:
-            assert len(all_todos) == 6
+            assert len(all_todos) == 6 + pre_cnt
 
         ## Search for misc text fields
         ## UID is a special case, supported by almost all servers
         some_todos = c.search(comp_class=Todo, uid="19970901T130000Z-123404@host.com")
         if not self.check_compatibility_flag("text_search_not_working"):
-            assert len(some_todos) == 1
+            assert len(some_todos) == 1 + pre_cnt
 
         ## class ... hm, all 6 example todos are 'CONFIDENTIAL' ...
         some_todos = c.search(comp_class=Todo, class_="CONFIDENTIAL")
         if not self.check_compatibility_flag("text_search_not_working"):
-            assert len(some_todos) == 6
+            assert len(some_todos) == 6 + pre_cnt
 
         ## category
         ## Too much copying of the examples ...
@@ -1696,34 +1719,34 @@ class RepeatedFunctionalTestsBaseClass:
         if not self.check_compatibility_flag(
             "category_search_yields_nothing"
         ) and not self.check_compatibility_flag("text_search_not_working"):
-            assert len(some_todos) == 6
+            assert len(some_todos) == 6 + pre_cnt
         some_todos = c.search(comp_class=Todo, category="finance")
         if not self.check_compatibility_flag(
             "category_search_yields_nothing"
         ) and not self.check_compatibility_flag("text_search_not_working"):
             if self.check_compatibility_flag("text_search_is_case_insensitive"):
-                assert len(some_todos) == 6
+                assert len(some_todos) == 6 + pre_cnt
             else:
-                assert len(some_todos) == 0
+                assert len(some_todos) == 0 + pre_cnt
 
         ## This is not a very useful search, and it's sort of a client side bug that we allow it at all.
         ## It will not match if categories field is set to "PERSONAL,ANNIVERSARY,SPECIAL OCCASION"
         ## It may not match since the above is to be considered equivalent to the raw data entered.
         some_todos = c.search(comp_class=Todo, category="FAMILY,FINANCE")
         if not self.check_compatibility_flag("text_search_not_working"):
-            assert len(some_todos) in (0, 6)
+            assert len(some_todos) - pre_cnt in (0, 6)
         ## TODO: We should consider to do client side filtering to ensure exact
         ## match only on components having MIL as a category (and not FAMILY)
         some_todos = c.search(comp_class=Todo, category="MIL")
         if self.check_compatibility_flag("text_search_is_exact_match_sometimes"):
-            assert len(some_todos) in (0, 6)
+            assert len(some_todos) - pre_cnt in (0, 6)
         elif self.check_compatibility_flag("text_search_is_exact_match_only"):
-            assert len(some_todos) == 0
+            assert len(some_todos) - pre_cnt == 0
         elif not self.check_compatibility_flag(
             "category_search_yields_nothing"
         ) and not self.check_compatibility_flag("text_search_not_working"):
             ## This is the correct thing, according to the letter of the RFC
-            assert len(some_todos) == 6
+            assert len(some_todos) - pre_cnt == 6
 
         ## completing events, and it should not show up anymore
         t3.complete()
@@ -1731,11 +1754,11 @@ class RepeatedFunctionalTestsBaseClass:
         t6.complete()
 
         some_todos = c.search(todo=True)
-        assert len(some_todos) == 3
+        assert len(some_todos) == 3 + pre_cnt
 
         ## unless we specifically ask for completed tasks
         all_todos = c.search(todo=True, include_completed=True)
-        assert len(all_todos) == 6
+        assert len(all_todos) == 6 + pre_cnt
 
     def testWrongPassword(self):
         if (
@@ -1909,6 +1932,8 @@ class RepeatedFunctionalTestsBaseClass:
             child=[some_todo.id],
         )
 
+        assert not parent.check_reverse_relations()
+
         ## The above updates the some_todo object on the server side, but the local object is not
         ## updated ... until we reload it
         some_todo.load()
@@ -2828,6 +2853,7 @@ class RepeatedFunctionalTestsBaseClass:
         c = self._fixCalendar()
 
         # evr2 is a bi-weekly event starting 2024-04-11
+        ## It has an exception, edited summary for recurrence id 20240425T123000Z
         e = c.save_event(evr2)
 
         r = c.search(
diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py
index c45d682..48ff3a6 100755
--- a/tests/test_caldav_unit.py
+++ b/tests/test_caldav_unit.py
@@ -272,6 +272,20 @@ class TestCalDAV:
         assert response.status == 200
         assert response.tree is None
 
+    def testLoadByMultiGet404(self):
+        xml = """
+<D:multistatus xmlns:D="DAV:">
+  <D:response>
+    <D:href>/calendars/pythoncaldav-test/20010712T182145Z-123401%40example.com.ics</D:href>
+    <D:status>HTTP/1.1 404 Not Found</D:status>
+  </D:response>
+</D:multistatus>"""
+        client = MockedDAVClient(xml)
+        calendar = Calendar(client, url="/calendar/issue491/")
+        object = Event(url="/calendar/issue491/notfound.ics", parent=calendar)
+        with pytest.raises(error.NotFoundError):
+            object.load_by_multiget()
+
     @mock.patch("caldav.davclient.niquests.Session.request")
     def testRequestCustomHeaders(self, mocked):
         """