diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7761fc2..984121f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -40,6 +40,7 @@ Output: - [ ] Operating system: - [ ] Python version: - [ ] calendaring-sync version: +- [ ] Adapter and server, if one is involved: ## Additional context diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1dec88c..54f9733 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -55,6 +55,7 @@ To indicate that you completed an item, place an `x` inside the checkbox, such a - [ ] I followed calendaring-sync's [Artificial intelligence policy](https://calendaring-sync.readthedocs.io/en/latest/contribute.html#artificial-intelligence-policy) and disclosed my AI use in my commit messages and change log entry, if applicable. - [ ] I added or updated tests, if applicable. - [ ] I ran and ensured all tests pass locally, following [Running tests](https://calendaring-sync.readthedocs.io/en/latest/contribute.html#running-tests). +- [ ] If I changed an adapter, I ran its integration tests against every server I could start. - [ ] I added or edited documentation as necessary. ## Additional information diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 3029436..ad2a76f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -36,13 +36,7 @@ jobs: - name: Install dependencies run: pip install -e ".[dev]" + # Radicale and Xandikos run embedded; the servers with Docker fixtures + # are skipped, as nothing starts them here. - name: Run integration tests - run: | - set +e - pytest src/calendaring_sync/tests/test_sync_integration.py - ec=$? - # Exit 5 = no tests collected. Expected until the first adapter lands - # its integration tests; anything else is a genuine failure. - if [ "$ec" -ne 0 ] && [ "$ec" -ne 5 ]; then - exit "$ec" - fi + run: pytest src/calendaring_sync/tests/test_sync_integration.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c84e63c..722470d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -170,7 +170,8 @@ jobs: pytest --cov --cov-branch --cov-report=xml \ --junitxml=junit.xml -o junit_family=legacy \ src/calendaring_sync/tests/test_sync_unit.py \ - src/calendaring_sync/tests/test_classify_unit.py + src/calendaring_sync/tests/test_classify_unit.py \ + src/calendaring_sync/tests/test_caldav_unit.py - name: Upload coverage to Codecov if: ${{ !cancelled() }} diff --git a/REUSE.toml b/REUSE.toml index c8d1967..649ae76 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -38,6 +38,13 @@ precedence = "override" SPDX-FileCopyrightText = "2026 calendaring-sync contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +# Recorded server exchanges the unit tests replay. JSON has no comments. +[[annotations]] +path = "src/calendaring_sync/tests/data/caldav/*.json" +precedence = "override" +SPDX-FileCopyrightText = "2026 calendaring-sync contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + # GitHub issue form templates: YAML frontmatter must stay the first thing # in the file for GitHub to parse it, so a leading comment isn't an option. [[annotations]] diff --git a/docs/contribute.rst b/docs/contribute.rst index 289ee56..b82f5f7 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -26,20 +26,30 @@ Unit tests, no server required: .. code-block:: bash - pytest src/calendaring_sync/tests/test_sync_unit.py src/calendaring_sync/tests/test_classify_unit.py + pytest src/calendaring_sync/tests/test_sync_unit.py src/calendaring_sync/tests/test_classify_unit.py src/calendaring_sync/tests/test_caldav_unit.py -Tests live inside the package, in ``src/calendaring_sync/tests/``. ``test_classify_unit.py`` holds the classifier's tests, over every combination of its inputs, and ``test_sync_unit.py`` holds the rest. Unit tests make no network calls and need no Docker, so they run anywhere. +Tests live inside the package, in ``src/calendaring_sync/tests/``. ``test_classify_unit.py`` holds the classifier's tests, over every combination of its inputs, ``test_caldav_unit.py`` the CalDAV adapter's, and ``test_sync_unit.py`` the rest. Unit tests make no network calls and need no Docker, so they run anywhere. + +The CalDAV adapter's unit tests replay HTTP exchanges recorded from real servers, kept in ``tests/data/caldav/``. Each recording comes from a scenario in ``tests/_caldav_scenarios.py``, which checks what it records. A rule no tested server shows on its own is tested by editing one exchange of a recording in the test itself. To record again, after a python-caldav upgrade or a new scenario, run ``python -m calendaring_sync.tests.record_caldav SERVER [SCENARIO...]``; its docstring says which servers it starts and which you start first. + +Integration tests run the adapter against real servers: + +.. code-block:: bash + + pytest src/calendaring_sync/tests/test_sync_integration.py + +Radicale and Xandikos run embedded, so CI runs them on every release. Cyrus, Stalwart and Baïkal are tested when their Docker fixtures answer and skipped otherwise; one Stalwart test also needs its result limit lowered, as its skip message says. Run the integration tests when you change an adapter, with every server you can start. Checking performance ==================== -If your change touches ingesting, local edits, pushes or a store, run the benchmarks on ``main`` and on your branch, on the same machine, and compare: +If your change touches ingesting, local edits, pushes, a store or a fingerprint, run the benchmarks on ``main`` and on your branch, on the same machine, and compare: .. code-block:: bash python -m calendaring_sync.tests.benchmark -It times a full listing of a large scope, a second listing over local edits and conflicts, a run of single edits, pushes of those edits, and the longest read in another thread while a listing runs, each on a fresh SQLite store. Times differ between machines, so only the comparison means anything. Explain any slowdown beyond run-to-run noise in your pull request. ``--size`` and ``--runs`` change the scope's size and the number of runs. +It times a full listing of a large scope, a second listing over local edits and conflicts, a run of single edits, pushes of those edits, and the longest read in another thread while a listing runs, each on a fresh SQLite store, and the CalDAV adapter's fingerprints of as many events. Times differ between machines, so only the comparison means anything. Explain any slowdown beyond run-to-run noise in your pull request. ``--size`` and ``--runs`` change the scope's size and the number of runs. Previewing docs =============== @@ -126,7 +136,7 @@ No hardcoding A literal that means something (a status value, a format string, a default path) belongs in a named constant defined once, not inlined at every call site. A one-off literal with no reused meaning (a test fixture's arbitrary ID) doesn't need this. Argument types - The public methods of ``Replica``, ``Session`` and the stores, and the value classes an adapter builds, check their argument types at runtime, from their annotations, so give every parameter of a new public method an annotation. A built-in type such as ``str`` or ``int`` has to match exactly, so ``True`` isn't taken as ``1``. The unit tests build every check and fail on a parameter without an annotation, or on one the checks can't read. + The public methods of ``Replica``, ``Session``, the stores and the adapters, and the value classes an adapter builds, check their argument types at runtime, from their annotations, so give every parameter of a new public method an annotation. A built-in type such as ``str`` or ``int`` has to match exactly, so ``True`` isn't taken as ``1``. The unit tests build every check and fail on a parameter without an annotation, or on one the checks can't read. RFC citations The core doesn't cite RFCs; see :ref:`write-about-sync`. In adapter docstrings (not comments, not test files, see below), cite a real, finalized RFC with Sphinx's ``:rfc:`` role, and always merge the section number into the role itself: ``:rfc:`5545#section-3.6.1```, not ``:rfc:`5545` section 3.6.1``. The latter renders as two disconnected pieces, a link to the RFC's front page and plain text next to it, so a reader has to manually find the section after clicking through. Verify the section number against the actual RFC text before citing it; don't guess from a related section or trust an existing comment's number without checking. diff --git a/docs/explanation/adapters.rst b/docs/explanation/adapters.rst index 3dc72f8..c2cf239 100644 --- a/docs/explanation/adapters.rst +++ b/docs/explanation/adapters.rst @@ -44,7 +44,7 @@ Each collection has its own sync token (:rfc:`6578`), so one collection is one s * - Scope - A calendar collection * - Object id - - The member's href, as the server reports it + - The path of the member's href, always spelled one way, since python-caldav can spell one member's address two ways (``%40`` from a sync report, ``@`` from a ``calendar-multiget``, for a path holding an ``@``) * - Version - ``DAV:getetag`` (:rfc:`4918#section-15.6`), a strong entity tag compared character by character (:rfc:`9110#section-8.8.3.2`) * - Sync token @@ -59,7 +59,7 @@ Each collection has its own sync token (:rfc:`6578`), so one collection is one s Why the adapter checks supported reports first ---------------------------------------------- -Before a collection's first sync, the adapter reads ``DAV:supported-report-set`` (:rfc:`3253#section-3.1.5`), the list of reports the collection supports, so it knows whether ``sync-collection`` works before any report fails. A collection without it gets a :term:`full listing` every time, and the adapter fetches only changed members. +Before a collection's first sync, the adapter reads ``DAV:supported-report-set`` (:rfc:`3253#section-3.1.5`), the list of reports the collection supports, so it knows whether ``sync-collection`` works before any report fails. A collection without it gets a :term:`full listing` every time, and the adapter fetches only changed members. So does a collection that names no ``DAV:sync-token``, which one that supports the report must (:rfc:`6578#section-4`), since a report from an empty token would ask for every member. Why a full listing reads the token first ---------------------------------------- @@ -71,9 +71,9 @@ The adapter never lists with a report sent with an empty token, which asks for e Why changes come from python-caldav's sync report ------------------------------------------------- -Changes since a token come from python-caldav's ``get_objects_by_sync_token()``, which sends ``sync-collection`` with ``Depth: 0``, the depth that report requires (:rfc:`6578#section-3.2`). When a server truncates a report with a 507 status and a :term:`checkpoint` token (:rfc:`6578#section-3.6`), python-caldav follows the checkpoints until the report is complete, and the adapter hands the core the whole result. The adapter turns python-caldav's fallback off: it would answer a failed report with a full listing under a client-side ``fake-`` token. +Changes since a token come from python-caldav's ``get_objects_by_sync_token()``. Up to 3.6.0, python-caldav sends ``sync-collection`` with ``Depth: 1``, although the report is only defined for ``Depth: 0`` (:rfc:`6578#section-3.2`); every tested server answers it all the same, and later releases send ``Depth: 0``. When a server truncates a report with a 507 status and a :term:`checkpoint` token (:rfc:`6578#section-3.6`), python-caldav follows the checkpoints, up to 100 reports, and the adapter hands the core the whole result as one change set. If the result is still cut after that, the change set is a :term:`truncated page` that ends at the last checkpoint, nothing is pushed, and the next sync continues from there. The adapter turns python-caldav's fallback off: it would answer a failed report with a full listing under a client-side ``fake-`` token. -A removed member has a 404 status and no ``DAV:propstat``, the element that would carry its properties (:rfc:`6578#section-3.2`), and python-caldav lists it in the result's ``deleted_urls``, which the adapter reads removals from. +A removed member has a 404 status and no ``DAV:propstat``, the element that would carry its properties (:rfc:`6578#section-3.2`), and python-caldav lists it in the result's ``deleted_urls``, which the adapter reads removals from. Some servers report a member deleted and created again at one href only as removed, so the adapter fetches removed members too, in multigets of their own: one that comes back with data is reported changed, the rest removed. A changed member whose reported ETag is the one already stored hasn't changed since the adapter fetched it, so it isn't fetched again. A server can leave the new token out of its answer; the adapter then keeps the scope's token, so nothing in that answer is lost, and the next sync asks again from the same point. The adapter never sends ``DAV:limit`` (:rfc:`6578#section-3.7`), which asks the server to return at most a given number of results: a server can apply it by dropping the remaining changes and returning its newest token. @@ -91,26 +91,30 @@ Why content comes from a multiget, with its own ETag Changed members are fetched with ``calendar-multiget`` (:rfc:`4791#section-7.9`), asking for ``DAV:getetag`` and ``calendar-data`` together, with no ``Depth`` header as that section asks. The stored version is the ETag from this response, not from the sync report: an object can change in between (:rfc:`6578#section-3.1`), and an older ETag would cause a needless fetch on the next sync and a wrong base for the next write. -A member that comes back 404, or is missing from the response although :rfc:`4791#section-7.9` requires an entry for every href, was removed after the report. It's left out of the change set, and the next report carries its removal. +A member that comes back without data, as python-caldav returns a 404 entry, or is missing from the response although :rfc:`4791#section-7.9` requires an entry for every href, was removed after the report. It's left out of the change set, and the next report carries its removal. A server can also answer the whole ``calendar-multiget`` with 404 when one member is gone; the adapter then checks that the calendar is still there and fetches each member on its own. A member that comes back with data but no ETag is kept with no version, so the next listing fetches it again. Why the fingerprint leaves out three properties ----------------------------------------------- -Tested servers rewrite what they store: they add ``DTSTAMP``, reorder properties and parameters, fold lines at other points, and reorder the parts of a recurrence rule, which may come in any order (:rfc:`5545#section-3.3.10`). A server sets ``DTSTAMP`` and ``LAST-MODIFIED`` itself (:rfc:`5545#section-3.8.7.2`, :rfc:`5545#section-3.8.7.3`), and ``PRODID`` names the product that created the object (:rfc:`5545#section-3.7.3`). Leaving those three out and sorting the rest makes a server's copy of your own write fingerprint the same as what you sent, on every tested server. ``UID`` is kept, since it's the logical key, and so is everything else, because any other difference can be a real edit. The adapter reads calendar data as UTF-8 when the server names no charset, since that's the default (:rfc:`5545#section-3.1.4`). +Tested servers rewrite what they store: they add ``DTSTAMP``, reorder properties and parameters, fold lines at other points, and reorder the parts of a recurrence rule, which may come in any order (:rfc:`5545#section-3.3.10`). A server sets ``DTSTAMP`` and ``LAST-MODIFIED`` itself (:rfc:`5545#section-3.8.7.2`, :rfc:`5545#section-3.8.7.3`), and ``PRODID`` names the product that created the object (:rfc:`5545#section-3.7.3`). Leaving those three out and sorting the rest makes a server's copy of your own write fingerprint the same as what you sent, on every tested server. ``UID`` is kept, since it's the logical key, and so is everything else, because any other difference can be a real edit. Calendar data is UTF-8 unless a charset says otherwise (:rfc:`5545#section-3.1.4`): python-caldav hands the adapter what a server sent as UTF-8, and the adapter refuses an edit that isn't. Why writes are conditional -------------------------- -Updates and deletes send ``If-Match`` with the base ETag (:rfc:`9110#section-13.1.1`), so the server applies them only if the member still has that ETag, and creates send ``If-None-Match: *`` (:rfc:`9110#section-13.1.2`), so a create never replaces an existing member. A mismatch gets status 412, and the edit stays pending for the next :term:`ingest`. Updates use python-caldav's ``save(increase_seqno=False)``, because ``save()`` increments the event's ``SEQUENCE`` revision number by default and the server wouldn't receive the edit you recorded. Deletes use ``delete(if_match=...)``. +Updates and deletes send ``If-Match`` with the base ETag (:rfc:`9110#section-13.1.1`), so the server applies them only if the member still has that ETag, and creates send ``If-None-Match: *`` (:rfc:`9110#section-13.1.2`), so a create never replaces an existing member. A mismatch gets status 412, and the edit stays pending for the next :term:`ingest`. ``If-Match`` compares ETags strongly, so a weak one (``W/``) never matches; an edit to a member whose stored ETag is weak waits, without being sent, until a sync stores a strong one, and the adapter's ``waiting()`` lists such edits. The ETag comes from the ``DAV:getetag`` property of the server's answer, not from an HTTP header a proxy can weaken, so edits wait only on a server that reports a weak ETag itself. -A create uses an href of unreserved characters only (:rfc:`3986#section-2.3`), a UUID and ``.ics``, so it can't come back spelled differently. A server that already holds the UID answers with an error element, ``CALDAV:no-uid-conflict`` (:rfc:`4791#section-5.3.2.1`) or ``CALDAV:unique-scheduling-object-resource`` (:rfc:`6638#section-3.2.4.1`); python-caldav raises ``UidConflictError`` for either, whatever the status, so the adapter never mistakes it for a plain precondition failure. +Creates and updates use python-caldav's ``save()`` with ``raw_data``, which sends the bytes you recorded unchanged, and ``precondition="etag"``, which sends the stored ETag. Updates also pass ``increase_seqno=False``, because ``save()`` otherwise increments the event's ``SEQUENCE`` revision number and the server wouldn't receive the edit you recorded. Creates pass ``no_overwrite=True``, which sends ``If-None-Match: *``. Deletes use ``delete(if_match=True, precondition="etag")``. A 404 on an update or a delete means the member is gone; a tested server answers a delete of a gone member with 412 instead, which leaves the delete pending until the next ingest reports the removal. + +A create uses an href of unreserved characters only (:rfc:`3986#section-2.3`), a UUID and ``.ics``, so it can't come back spelled differently. A server that already holds the UID answers with an error element, ``CALDAV:no-uid-conflict`` (:rfc:`4791#section-5.3.2.1`) or ``CALDAV:unique-scheduling-object-resource`` (:rfc:`6638#section-3.2.4.1`); python-caldav raises ``UidConflictError`` for either, whatever the status, so the adapter never mistakes it for a plain precondition failure. For a create or an update, that's a :term:`conflict` with the object that holds the UID, named when the error element holds its ``DAV:href`` and that href is in the calendar; otherwise the core finds the holder by its UID. + +Unless the client is configured with the server's profile, python-caldav searches the calendar for the UID of a create before sending it, which costs a request per create and refuses a UID that's taken even on a server that would accept it. Configuring the client's ``features`` with the server's name lets the server check instead. A server MUST NOT return a strong ETag for data it stored differently (:rfc:`4791#section-5.3.4`), but tested servers do, so an applied push doesn't keep the ETag it got back. -Why the adapter needs python-caldav 3.5.0 or later +Why the adapter needs python-caldav 3.6.0 or later -------------------------------------------------- -The adapter needs python-caldav 3.5.0 or later, which has six changes it relies on: following truncated sync reports, listing removed members in ``deleted_urls``, keeping preconditions on every error, ``UidConflictError``, honoring ``increase_seqno`` on every save, and ``delete(if_match=...)``. +The adapter needs python-caldav 3.6.0 or later. It relies on following truncated sync reports, listing removed members in ``deleted_urls``, keeping preconditions on every error, ``UidConflictError``, honoring ``increase_seqno`` on every save, ``raw_data`` holding calendar data as bytes, ``save(raw_data=...)`` sending bytes unchanged, ``precondition="etag"``, and ``If-None-Match: *`` on ``save(no_overwrite=True)``. JMAP ==== diff --git a/docs/explanation/design.rst b/docs/explanation/design.rst index b573581..b7c7bec 100644 --- a/docs/explanation/design.rst +++ b/docs/explanation/design.rst @@ -264,11 +264,11 @@ Each :term:`conflict record` has one of five kinds: - the server removed the object, and you edited it - you deleted the object, and the server edited it - the server has a new object at the id your create chose, and your create hasn't been sent yet -- the server refused your create because another object holds its UID +- the server refused your create or change because another object holds its UID Later remote changes update the record's remote side. The conflict resolves itself when the server reaches your edit, goes back to the synced content, or removes an object you deleted too. -The core stores conflicts instead of raising them: an exception for one object would abort every other change in the sync, and a :term:`resolver` called during a sync would run again on every retry. You resolve a record with your edit (``KeepEdit``), the server's version (``TakeServer``), merged content (``Merge``), or a delete (``Delete``), and the core pushes the result as a change to the server's current version. You can't push a create the server refused for its UID again unchanged, so you can discard it (``TakeServer`` or ``Delete``), push it as an update to the object that holds the UID (``UpdateHolder``), or replace it with merged content, possibly with a new UID (``Merge``). +The core stores conflicts instead of raising them: an exception for one object would abort every other change in the sync, and a :term:`resolver` called during a sync would run again on every retry. You resolve a record with your edit (``KeepEdit``), the server's version (``TakeServer``), merged content (``Merge``), or a delete (``Delete``), and the core pushes the result as a change to the server's current version. You can't push an edit the server refused for its UID again unchanged. You can drop it (``TakeServer``), push it as an update to the object that holds the UID (``UpdateHolder``), or replace it with merged content, possibly with a new UID (``Merge``). ``Delete`` discards a refused create, and deletes the object a refused change was for. ``resolve(record, resolution)`` resolves one record you read, and refuses if its conflict changed since, so a decision is never applied to news you haven't seen. ``resolve_conflicts(resolver)`` passes each open record to your resolver, never during an ingest, and resolves each answer the same way. Each resolution is its own transaction: ``None`` leaves a record open, a record that changed while your resolver ran waits for the next call, and an exception stops the loop and keeps the resolutions before it. diff --git a/docs/how-to/install.rst b/docs/how-to/install.rst index a0ad267..c1906b6 100644 --- a/docs/how-to/install.rst +++ b/docs/how-to/install.rst @@ -11,8 +11,14 @@ calendaring-sync has no release on PyPI yet, so install it from GitHub: pip install git+https://github.com/pycalendar/calendaring-sync +The CalDAV adapter needs python-caldav and icalendar, which the ``caldav`` extra installs: + +.. code-block:: bash + + pip install "calendaring-sync[caldav] @ git+https://github.com/pycalendar/calendaring-sync" + To work on calendaring-sync itself, see :doc:`../contribute` for the development setup instead. -It has no runtime dependencies beyond the Python standard library, and needs Python 3.11 or newer. +Without an extra, it has no runtime dependencies beyond the Python standard library. It needs Python 3.11 or newer. The package ships inline type hints and a ``py.typed`` marker (:pep:`561`), so a type checker reads them directly without a separate stub package. diff --git a/docs/reference/adapters.rst b/docs/reference/adapters.rst new file mode 100644 index 0000000..506123a --- /dev/null +++ b/docs/reference/adapters.rst @@ -0,0 +1,15 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +:mod:`adapters.caldav`: Syncing a CalDAV calendar +================================================= + +.. meta:: + :description: CalDAVAdapter and AsyncCalDAVAdapter, which sync one CalDAV calendar collection with a replica through python-caldav. + +An :term:`adapter` connects a :class:`~calendaring_sync.replica.Replica` to one kind of server. :class:`~calendaring_sync.adapters.caldav.CalDAVAdapter` syncs one CalDAV calendar collection through a python-caldav client, and :class:`~calendaring_sync.adapters.caldav.AsyncCalDAVAdapter` does the same through an asynchronous one. Install them with the ``caldav`` extra. :doc:`../explanation/adapters` explains every rule they follow, and :doc:`server-compatibility` how each tested server behaves. + +.. automodule:: calendaring_sync.adapters.caldav + :members: + :inherited-members: + :no-show-inheritance: diff --git a/docs/reference/glossary.rst b/docs/reference/glossary.rst index 1fd9d2f..83deb47 100644 --- a/docs/reference/glossary.rst +++ b/docs/reference/glossary.rst @@ -29,7 +29,7 @@ These are the sync terms calendaring-sync's pages use. :doc:`../explanation/desi To mark a :term:`pending edit` :term:`in flight` just before pushing it, in one step that fails if another process got there first. conflict - An object where your :term:`pending edit` and the server disagree: both sides changed it, one side deleted it while the other edited it, or the server refused your create because another object holds its :term:`UID`. calendaring-sync reports it and leaves the decision to you. + An object where your :term:`pending edit` and the server disagree: both sides changed it, one side deleted it while the other edited it, or the server refused your create or change because another object holds its :term:`UID`. calendaring-sync reports it and leaves the decision to you. conflict record What the core stores for one :term:`conflict`: the :term:`synced content`, your edit, the server's side, and the kind of conflict. It stays open until you resolve it or it resolves itself. diff --git a/docs/reference/index.rst b/docs/reference/index.rst index c30c807..f27792f 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -17,6 +17,7 @@ objects errors constants + adapters glossary server-compatibility ../changelog diff --git a/docs/reference/server-compatibility.rst b/docs/reference/server-compatibility.rst index 8fe6b8a..f38ef00 100644 --- a/docs/reference/server-compatibility.rst +++ b/docs/reference/server-compatibility.rst @@ -8,7 +8,7 @@ Server compatibility .. meta:: :description: How each tested server behaves where sync depends on it, and what calendaring-sync's adapters do about each difference. -Each table lists what the tested servers did where sync depends on it, and what the adapter does about it. The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.13.7, Stalwart 0.16.21 and Baïkal 0.10.1, and the JMAP adapter against Cyrus 3.13.7 and Stalwart 0.16.21, between 2026-10-10 and 2026-10-12. A later release may behave differently. The probes and logs behind every row are on the `evidence branch `_, with the steps to reproduce them. :doc:`../explanation/adapters` explains each rule, and the :doc:`glossary` defines the sync terms in the Adapter column. +Each table lists what the tested servers did where sync depends on it, and what the adapter does about it. The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.13.7, Stalwart 0.16.21 and Baïkal 0.10.1, and the JMAP adapter against Cyrus 3.13.7 and Stalwart 0.16.21, between 2026-10-10 and 2026-10-12. The CalDAV adapter's integration tests ran against all five on 2026-10-23, with python-caldav 3.6.0 and Xandikos 0.4.10, and the rows they added are marked with that date. Entries marked (tester) come from `caldav-server-tester `_, which ran these checks against 22 servers in October 2026; python-caldav publishes its results as compatibility hints (`python-caldav/caldav#822 `_). A later release may behave differently. The probes and logs behind every row are on the `evidence branch `_, with the steps to reproduce them. :doc:`../explanation/adapters` explains each rule, and the :doc:`glossary` defines the sync terms in the Adapter column. CalDAV ====== @@ -29,21 +29,29 @@ CalDAV * - Report with an empty token, after deletions (:rfc:`6578#section-3.4`: no removed members) - - Radicale: lists removed members as 404 - Stalwart: also lists the collection itself + - CCS: also lists the collection itself (tester) - Others: as specified - Lists with ``PROPFIND``, never an empty token * - Member created and deleted between two reports (:rfc:`6578#section-3.5.2`: reported as removed) - - - Xandikos, Stalwart: not reported + - - Xandikos: not reported + - Stalwart 0.16.11: reported only when a member that existed at the earlier token also changed in between (tester) + - Bedework 5: not reported (tester) - Others: reported as removed - A create of yours that's never mentioned stays :term:`pending ` * - Member deleted and re-created at one href (:rfc:`6578#section-3.5.1`, :rfc:`6578#section-3.2`: reported once, as changed) - - Stalwart: twice, removed and then changed + - Nextcloud, ecloud, Synology: only as removed, though the member is there (tester) - Others: once, as changed - - The later entry counts, and the fetch decides + - Fetches removed members too: one that comes back with data counts as changed + * - ``sync-collection`` sent with ``Depth: 1``, as python-caldav up to 3.6.0 sends it (:rfc:`6578#section-3.2`: defined for ``Depth: 0`` only; 2026-10-23) + - All: answered as with ``Depth: 0`` + - Relies on python-caldav's report * - Token the server never issued (:rfc:`6578#section-3.2`: ``valid-sync-token``) - - - Radicale, Cyrus, Baïkal: 403 with ``valid-sync-token`` + - - Radicale, Cyrus: 403 with ``valid-sync-token`` - Xandikos: 412 with ``valid-sync-token`` for a well-formed token, 500 otherwise (`xandikos#743 `_) - Stalwart: 207 with a full listing and no error - - :term:`Resets ` the scope when the error names ``valid-sync-token``; flags Stalwart for regular full listings + - Baïkal: 403 with ``valid-sync-token`` for a token that doesn't start with its own prefix; one that does, even past its newest token, gets no changes and comes back as the new token (2026-10-23) + - :term:`Resets ` the scope when the error names ``valid-sync-token``; flags Stalwart for regular full listings. Baïkal's answer can't be told from an ordinary one, so after restoring its database from a backup, run a full listing * - Token whose history the server has discarded - - Radicale, Cyrus: 403 with ``valid-sync-token`` - Stalwart: 207 with only the changes still in its history; earlier ones aren't reported @@ -57,14 +65,16 @@ CalDAV - Baïkal: 507 with a checkpoint; ignores the limit on an empty token - Never sends ``DAV:limit``; follows a 507 the server applies itself * - Full listing of a calendar larger than the server's result limit - - - Stalwart: ``PROPFIND`` and an empty-token report both stop at its ``maxResults`` setting (2000 by default) with a 507; the report also hands back its newest token + - - Stalwart: ``PROPFIND`` and an empty-token report both stop at its ``maxResults`` setting (2000 by default) with a 507; the report also hands back its newest token. A ``calendar-multiget`` gets at most that many members and a 507 for the collection, even when it asked for no more (2026-10-23) - Others: not tested - - Lists with ``PROPFIND``, and raises an error instead of ingesting a listing cut short + - python-caldav's own Stalwart test container sets the limit to 50 (tester) + - Lists with ``PROPFIND``, and raises an error instead of ingesting a listing cut short; fetches at most 100 members per ``calendar-multiget``, so a setting above 100 never cuts a fetch * - PUT the server stores differently (:rfc:`4791#section-5.3.4`: no strong ETag in the answer) - - Radicale: adds ``DTSTAMP``, reorders properties, returns an ETag - Xandikos: reorders properties, returns an ETag - Baïkal: adds ``DTSTAMP``, returns no ETag - Cyrus, Stalwart: store what was sent + - CCS, Infomaniak: reorder properties; SOGo adds ``CLASS``; Zimbra and Bedework 5 add properties; all return a strong ETag (tester) - Never keeps the ETag a write returns; its :term:`fingerprint` ignores the rewrite * - Character set of calendar data (:rfc:`5545#section-3.1.4`: UTF-8 by default) - - Xandikos: ``text/calendar`` with no charset @@ -76,17 +86,28 @@ CalDAV * - Create with a UID already in the calendar (:rfc:`4791#section-5.3.2.1`) - - Radicale: 409 with ``no-uid-conflict`` - Xandikos, Stalwart: 412 with ``no-uid-conflict`` - - Cyrus: 403 with ``unique-scheduling-object-resource`` + - Cyrus: 403 with ``unique-scheduling-object-resource``, also for a UID that another of the user's calendars holds (2026-10-23) - Baïkal: accepted, so two resources share the UID - - Recognizes both elements; finding objects by UID returns every match + - Recognizes both elements; finding objects by UID returns every match. Without the server's profile on the client, python-caldav's search for the UID refuses the create first, on Baïkal too + * - ``DELETE`` with an ``If-Match`` that no longer matches (:rfc:`9110#section-13.1.1`: 412) + - - Bedework 3 and 5, Zimbra, Posteo, SOGo: deleted anyway (tester) + - Others: 412 + - Nothing it can do: there, a delete can remove a change made since your last sync * - ``DELETE`` with ``If-Match`` on a resource that's gone - - Baïkal: 412 + - Nextcloud, Davis, CCS, OX, Purelymail, Robur, Infomaniak: 412 (tester) - Others: 404 - The next :term:`ingest` settles it * - ``calendar-multiget`` for a member deleted since the report (:rfc:`4791#section-7.9`: an entry for every href) - - Baïkal: leaves the href out - - Others: a 404 entry - - A missing or 404 member waits for the next report + - Nextcloud, DAViCal, Davis, Infomaniak: leave the href out (tester) + - Posteo: 404 for the whole ``calendar-multiget`` (tester) + - Others: a 404 entry, which python-caldav returns as an object without data + - A missing member, or one without data, waits for the next report; after a 404 for the whole request, checks that the calendar is there and fetches each member on its own + * - Removed member in a sync report + - - Infomaniak: adds an empty ``propstat`` with 418, on which python-caldav 3.6.0 raises; fixed for its next release (`python-caldav/caldav#821 `_) (tester) + - Others: as specified + - Syncs Infomaniak from python-caldav's next release Conditional writes behaved as :rfc:`9110#section-13.1.1` and :rfc:`9110#section-13.1.2` specify on every server in the table: a wrong ``If-Match`` or an ``If-None-Match: *`` on an existing resource gets 412. diff --git a/news/10.feature.1.rst b/news/10.feature.1.rst new file mode 100644 index 0000000..b951444 --- /dev/null +++ b/news/10.feature.1.rst @@ -0,0 +1 @@ +Added the CalDAV adapter: ``CalDAVAdapter`` and ``AsyncCalDAVAdapter`` sync one calendar collection through python-caldav 3.6.0 or later, installed with the ``caldav`` extra, and their ``create`` and ``change`` record your edits with the fingerprint and UID a sync would give them, while ``waiting()`` lists the edits they can't send yet. A calendar the server no longer has raises the new ``ScopeGoneError``, and a full listing the server cuts short raises ``IncompleteListingError``. diff --git a/news/10.feature.2.rst b/news/10.feature.2.rst new file mode 100644 index 0000000..bf4c91f --- /dev/null +++ b/news/10.feature.2.rst @@ -0,0 +1 @@ +A change the server refuses because another object holds its logical key becomes a ``LOGICAL_KEY_TAKEN`` conflict, resolved as a refused create is, except that dropping it keeps the object as it was synced. diff --git a/news/10.feature.3.rst b/news/10.feature.3.rst new file mode 100644 index 0000000..c0b6bea --- /dev/null +++ b/news/10.feature.3.rst @@ -0,0 +1 @@ +``Replica.records()`` takes ``states``, to read only the records in those states. diff --git a/pyproject.toml b/pyproject.toml index 41f35d2..92212d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,16 @@ dev = [ "reuse==6.2.0", "towncrier==26.9.0", "pre-commit==4.6.2", + "caldav==3.6.0", + "icalendar==7.3.0", + "lxml==6.1.3", + "radicale==3.8.3", + "xandikos==0.4.10", +] +caldav = [ + "caldav>=3.6.0", + "icalendar>=7.3.0", + "lxml>=6.1.3", ] docs = [ "sphinx==9.1.0", @@ -54,6 +64,7 @@ docs = [ "sphinx-notfound-page==1.1.0", "sphinxcontrib-mermaid==2.1.1", "sphinxext-opengraph==0.13.0", + "caldav==3.6.0", ] [project.urls] diff --git a/src/calendaring_sync/__init__.py b/src/calendaring_sync/__init__.py index 4d86ea9..3caee53 100644 --- a/src/calendaring_sync/__init__.py +++ b/src/calendaring_sync/__init__.py @@ -14,11 +14,13 @@ CalendaringSyncError, DuplicateObjectError, EditRefusedError, + IncompleteListingError, OpenConflictError, PendingEditError, PushInFlightError, RecordValueError, RemovedRecordError, + ScopeGoneError, ScopeMismatchError, SessionError, StaleChangeSetError, @@ -80,6 +82,7 @@ "FeedEntry", "FeedEntryKind", "Gone", + "IncompleteListingError", "KeepEdit", "LocalEdit", "LogicalKeyTaken", @@ -99,6 +102,7 @@ "Resolution", "SQLiteStore", "Scope", + "ScopeGoneError", "ScopeMismatchError", "Session", "SessionError", diff --git a/src/calendaring_sync/adapters/__init__.py b/src/calendaring_sync/adapters/__init__.py new file mode 100644 index 0000000..7d73fe5 --- /dev/null +++ b/src/calendaring_sync/adapters/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Adapters, one per protocol, each in its own module behind its own extra. + +Importing this package imports no protocol library; import an adapter's +module, such as :mod:`calendaring_sync.adapters.caldav`, to use it. +""" diff --git a/src/calendaring_sync/adapters/_flow.py b/src/calendaring_sync/adapters/_flow.py new file mode 100644 index 0000000..974c50a --- /dev/null +++ b/src/calendaring_sync/adapters/_flow.py @@ -0,0 +1,307 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""One sync of one scope, written once for every protocol and for sync and async callers. + +A sync is a generator. Each step yields what a client call returned, which +is the answer itself for a synchronous client and a coroutine for an +asynchronous one, or an :class:`_InStore` for a call into the +:term:`store`. :func:`_run` drives the generator for synchronous callers; +:func:`_arun` awaits the coroutines and runs each store call in a worker +thread, so a long ingest doesn't block the event loop. An adapter +subclasses :class:`_Flow` and writes its protocol's steps as generators +too; the order of a sync, the session and the pushes live here. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextvars +import enum +import inspect +from abc import ABC, abstractmethod +from collections.abc import Callable, Generator, Mapping +from dataclasses import dataclass, field +from typing import Any, TypedDict, TypeVar + +from calendaring_sync.objects.push import PushOutcome +from calendaring_sync.objects.record import Record, RecordState +from calendaring_sync.objects.scope import Scope +from calendaring_sync.replica import Replica, Session + +_T = TypeVar("_T") + +# The records whose edit is waiting to be sent. +_PENDING = frozenset({RecordState.PENDING}) + +#: The steps of a sync: they yield client answers or :class:`_InStore` +#: calls, and are sent back what each one returned. +Steps = Generator[Any, Any, _T] + + +@dataclass(frozen=True) +class _InStore: + """A call into the store, which an asynchronous driver runs in a worker thread.""" + + call: Callable[..., Any] + args: tuple[Any, ...] = () + kwargs: Mapping[str, Any] = field(default_factory=dict) + + def run(self) -> Any: + return self.call(*self.args, **self.kwargs) + + +def _run(steps: Steps[_T]) -> _T: + """Drive the steps of a sync for a synchronous client.""" + sent: Any = None + failure: BaseException | None = None + while True: + try: + step = steps.send(sent) if failure is None else steps.throw(failure) + except StopIteration as done: + return done.value # type: ignore[no-any-return] + sent, failure = None, None + try: + sent = step.run() if isinstance(step, _InStore) else step + except BaseException as exc: # noqa: BLE001, thrown back into the steps + failure = exc + + +async def _arun(steps: Steps[_T]) -> _T: + """Drive the steps of a sync for an asynchronous client. + + A store call runs to its end in its worker thread before the steps hear + of a cancellation or of the driver being closed, so a session never ends + while one of its calls is still running. + """ + sent: Any = None + failure: BaseException | None = None + while True: + try: + step = steps.send(sent) if failure is None else steps.throw(failure) + except StopIteration as done: + return done.value # type: ignore[no-any-return] + sent, failure = None, None + call: concurrent.futures.Future[Any] | None = None + try: + if isinstance(step, _InStore): + call = _in_thread(step) + running = asyncio.wrap_future(call) + try: + sent = await asyncio.shield(running) + except asyncio.CancelledError: + await asyncio.wait([running]) + raise + else: + sent = await step + except GeneratorExit: + # Nothing can be awaited while closing, so the call is waited for here. + if call is not None: + _hand_over(steps, call) + steps.close() + raise + except BaseException as exc: # noqa: BLE001, thrown back into the steps + failure = exc + + +def _in_thread(step: _InStore) -> concurrent.futures.Future[Any]: + """Start a store call in a worker thread of the running loop.""" + result: concurrent.futures.Future[Any] = concurrent.futures.Future() + context = contextvars.copy_context() + + def run() -> None: + try: + result.set_result(context.run(step.run)) + except BaseException as exc: # noqa: BLE001, kept for the driver + result.set_exception(exc) + + asyncio.get_running_loop().run_in_executor(None, run) + return result + + +def _hand_over(steps: Steps[Any], call: concurrent.futures.Future[Any]) -> None: + """Give the steps what a store call they were waiting for ended with, when closing. + + The steps then know whether, for example, a session was entered and has + to end. Whatever they do next goes nowhere, since the driver is closing. + """ + try: + try: + outcome = call.result() + except BaseException as exc: # noqa: BLE001, thrown into the steps + following = steps.throw(exc) + else: + following = steps.send(outcome) + except BaseException: # noqa: BLE001, nobody is left to receive it + return + if inspect.iscoroutine(following): + following.close() + + +def _end(session_manager: Any, exc_info: tuple[Any, Any, Any]) -> Steps[None]: + """End a session as a store step, or right away when the driver closes first. + + A closing driver drops the step that follows the call it waited for, and + that step can be this one. + """ + try: + yield _InStore(session_manager.__exit__, exc_info) + except GeneratorExit: + session_manager.__exit__(*exc_info) + raise + + +class _EditFields(TypedDict): + """What an edit records beside its content, read from the content as a sync reads it.""" + + fingerprint: str + containers: frozenset[str] + logical_key: str | None + + +class _Read(enum.Enum): + """How the read part of a sync ended.""" + + #: Every change was ingested, so the pushes can go out. + COMPLETE = "complete" + #: The server cut the changes short; the next sync continues from the checkpoint. + TRUNCATED = "truncated" + #: The server refused the scope's token, so the scope needs a full listing. + INVALID_TOKEN = "invalid token" + + +class _Flow(ABC): + """The order of one sync, the same for every protocol. + + A sync opens a :term:`session`, reads what changed and ingests it, then + pushes the pending edits. When the server refuses the scope's token, + the scope is :term:`reset` and listed in a new session. Recording an + edit reads its fields from the content as a sync would. A subclass + sets the session options and writes :meth:`_read`, :meth:`_send`, + :meth:`_waits`, :meth:`_new_object_id` and :meth:`_edit_fields` for + its protocol. + """ + + #: The name of the adapter's fingerprint scheme. + _fingerprint_scheme: str + #: The media type of the scope's content. + _media_type: str + #: Whether a push only applies if the object still has the version it was based on. + _guarded_by_version: bool + #: Whether no two objects in the scope can share a logical key. + _unique_logical_key: bool + + def __init__(self, replica: Replica, scope_id: str) -> None: + self._replica = replica + self._scope_id = scope_id + + @property + def scope_id(self) -> str: + """The :term:`scope` this adapter syncs.""" + return self._scope_id + + def _create(self, content: bytes) -> Steps[int]: + """Record a create of an object with this content, and return its local key.""" + fields = self._edit_fields(content, create=True) + key: int = yield _InStore( + self._replica.create, + (self._scope_id, content), + {**fields, "object_id": self._new_object_id()}, + ) + return key + + def _change(self, local_key: int, content: bytes) -> Steps[None]: + """Record a change of a record in the scope to this content.""" + fields = self._edit_fields(content, create=False) + # A record never changes scope, so this holds when the change runs. + record: Record | None = yield _InStore(self._replica.record, (local_key,)) + if record is not None and record.scope != self._scope_id: + raise ValueError(f"Record {local_key} is in scope {record.scope!r}, not this one") + yield _InStore(self._replica.change, (local_key, content), fields) + + def _waiting(self) -> Steps[list[Record]]: + """The pending edits the adapter won't send yet.""" + pending: list[Record] = yield _InStore( + self._replica.records, (self._scope_id,), {"states": _PENDING} + ) + return [r for r in pending if self._waits(r)] + + def _sync(self, *, full_listing: bool) -> Steps[None]: + """One sync: read and ingest, then push; a refused token resets and lists.""" + read = yield from self._in_session( + lambda session: self._sync_session(session, full_listing) + ) + if read is _Read.INVALID_TOKEN: + yield _InStore(self._replica.reset, (self._scope_id,)) + yield from self._in_session(lambda session: self._sync_session(session, True)) + + def _sync_session(self, session: Session, full_listing: bool) -> Steps[_Read]: + scope: Scope = yield _InStore(self._replica.scope, (self._scope_id,)) + read = yield from self._read(session, scope, full_listing=full_listing) + if read is _Read.COMPLETE: + yield from self._push(session) + return read + + def _in_session(self, steps: Callable[[Session], Steps[_T]]) -> Steps[_T]: + """Run ``steps`` inside a session of the scope, which ends whatever happens.""" + session_manager = self._replica.session( + self._scope_id, + fingerprint_scheme=self._fingerprint_scheme, + media_type=self._media_type, + guarded_by_version=self._guarded_by_version, + unique_logical_key=self._unique_logical_key, + ) + session: Session = yield _InStore(session_manager.__enter__) + try: + result = yield from steps(session) + except GeneratorExit: + # A closing driver runs no more steps, so the session ends here. + session_manager.__exit__(None, None, None) + raise + except BaseException as exc: + yield from _end(session_manager, (type(exc), exc, exc.__traceback__)) + raise + yield from _end(session_manager, (None, None, None)) + return result + + def _push(self, session: Session) -> Steps[None]: + """Push every pending edit, each claimed, sent and settled on its own.""" + records: list[Record] = yield _InStore(session.to_push) + for record in records: + if self._waits(record): + continue + object_id = self._new_object_id() if record.object_id is None else None + claimed: Record | None = yield _InStore( + session.claim, (record.local_key,), {"object_id": object_id} + ) + if claimed is None: + continue + # A send that raises leaves the push in flight, since the request + # may have reached the server; the next complete ingest settles it. + outcome = yield from self._send(claimed) + yield _InStore(session.settle, (claimed, outcome)) + + @abstractmethod + def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[_Read]: + """Fetch what changed in the scope and ingest it, as one change set or listing pages.""" + + @abstractmethod + def _send(self, claimed: Record) -> Steps[PushOutcome]: + """Send one claimed edit and return how it ended.""" + + @abstractmethod + def _waits(self, record: Record) -> bool: + """Whether a pending edit can't be sent yet and waits for a later sync.""" + + @abstractmethod + def _new_object_id(self) -> str | None: + """An object id for a create that has none, or ``None`` where the server picks it.""" + + @abstractmethod + def _edit_fields(self, content: bytes, *, create: bool) -> _EditFields: + """The fields of an edit to this content, a create or a change. + + Raises: + ValueError: If the content can't be an object of the scope. + """ diff --git a/src/calendaring_sync/adapters/caldav.py b/src/calendaring_sync/adapters/caldav.py new file mode 100644 index 0000000..c0e0ef1 --- /dev/null +++ b/src/calendaring_sync/adapters/caldav.py @@ -0,0 +1,613 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""The CalDAV :term:`adapter`, which syncs one calendar collection through python-caldav. + +Install it with the ``caldav`` extra: ``pip install 'calendaring-sync[caldav]'``. +:doc:`/explanation/adapters` explains every rule it follows, and +:doc:`/reference/server-compatibility` how each tested server behaves. +""" + +from __future__ import annotations + +import hashlib +import uuid +from collections.abc import Iterable, Iterator, Sequence +from dataclasses import dataclass +from typing import Any, cast +from urllib.parse import urlsplit, urlunsplit + +try: + import lxml.etree as etree + from caldav.calendarobjectresource import CalendarObjectResource + from caldav.collection import Calendar + from caldav.davclient import DAVClient + from caldav.elements import dav + from caldav.lib import error as dav_error + from caldav.lib.url import URL + from icalendar.parser import Contentline, Contentlines + from icalendar.prop import vText +except ImportError as exc: + raise ImportError( + "The CalDAV adapter needs the caldav extra: pip install 'calendaring-sync[caldav]'" + ) from exc + +from calendaring_sync._checks import check_argument_types +from calendaring_sync.adapters._flow import ( + Steps, + _arun, + _EditFields, + _Flow, + _InStore, + _Read, + _run, +) +from calendaring_sync.error import IncompleteListingError, ScopeGoneError +from calendaring_sync.objects.change import Change, Changed, Removed, Unchanged +from calendaring_sync.objects.change_set import ChangeSet +from calendaring_sync.objects.edit import EditKind, LocalEdit +from calendaring_sync.objects.push import Applied, Gone, LogicalKeyTaken, PushOutcome, Rejected +from calendaring_sync.objects.record import Record +from calendaring_sync.objects.scope import Scope +from calendaring_sync.replica import Replica, Session + +#: The name of the canonical fingerprint this adapter computes. A change to +#: how it's computed gets a new name, so every scope is listed again. +FINGERPRINT_SCHEME = "caldav-canonical-1" +#: The media type of a CalDAV scope's content: iCalendar data. +MEDIA_TYPE = "text/calendar" +#: How many objects one ``calendar-multiget`` fetches, and one page of a +#: :term:`full listing` holds. +MULTIGET_SIZE = 100 + +# Properties a server sets on its own (RFC 5545 sections 3.8.7.2, 3.8.7.3 and +# 3.7.3), left out of the fingerprint so a server's copy of your own write +# fingerprints the same. +_SERVER_SET = frozenset({"DTSTAMP", "LAST-MODIFIED", "PRODID"}) +# Recurrence rules, whose parts may come in any order (RFC 5545 section 3.3.10). +_RECURRENCE_RULES = frozenset({"RRULE", "EXRULE"}) +# What the canonical form's outermost level is called; calendar data has +# no component of that name. +_ROOT = "" +# Components that are no object of their own, so their UID isn't the logical key. +_NOT_AN_OBJECT = frozenset({_ROOT, "VCALENDAR", "VTIMEZONE"}) +# Where an object's component sits: in a calendar, or outermost. +_HOLDS_OBJECTS = frozenset({_ROOT, "VCALENDAR"}) +_SYNC_COLLECTION = "{DAV:}sync-collection" +_VALID_SYNC_TOKEN = "{DAV:}valid-sync-token" +_HREF = "{DAV:}href" +# How python-caldav's parse_preconditions reads an error body: no entities +# expanded, nothing fetched. +_ERROR_PARSER = {"resolve_entities": False, "no_network": True} +# The prefix of a weak entity tag (RFC 9110 section 8.8.3). +_WEAK = "W/" +# python-caldav's feature saying how a server refuses a sync token it won't take. +_TOKEN_REFUSAL_FEATURE = "sync-token.invalid-token" +# The status a server gives the collection when it cuts a listing short. +_TRUNCATED = 507 +_NOT_FOUND = 404 +# How python-caldav's server profiles describe a server whose refusal of a +# token can't be relied on, so its scopes need regular full listings. +_UNRELIABLE_TOKEN_REFUSAL = frozenset({"broken", "ungraceful"}) + + +@dataclass(frozen=True) +class _Fields: + """What the adapter reads from an object's content for the core.""" + + fingerprint: str + logical_key: str | None + + +def _fields(content: bytes) -> _Fields: + """Read the fingerprint and the logical key from iCalendar data. + + The fingerprint is SHA-256 of a canonical form: content lines unfolded, + components sorted, properties and parameters sorted within each, the + parts of each recurrence rule sorted, the properties a server sets left + out, and every other value as sent. The logical key is the ``UID`` of + the first object: a component in the calendar other than a time zone, + so not one inside an object, such as an alarm. + + Raises: + ValueError: If the content isn't UTF-8 or isn't iCalendar data. + """ + try: + text = content.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"Calendar data must be UTF-8: {exc}") from exc + # Each open component: its name, its properties and its finished children. + stack: list[tuple[str, list[tuple[str, tuple[tuple[str, str], ...], str]], list[str]]] = [ + (_ROOT, [], []) + ] + uid: str | None = None + for line in Contentlines.from_ical(text): + if not line: + continue + name, params, value = Contentline(line).parts() + name = name.upper() + if name == "BEGIN": + stack.append((value.upper(), [], [])) + elif name == "END": + if len(stack) == 1: + raise ValueError(f"Calendar data ends {value} that never began") + component, properties, children = stack.pop() + if value.upper() != component: + raise ValueError(f"Calendar data ends {value} inside {component}") + stack[-1][2].append(repr((component, sorted(properties), sorted(children)))) + elif name not in _SERVER_SET: + in_object = stack[-1][0] not in _NOT_AN_OBJECT and stack[-2][0] in _HOLDS_OBJECTS + if name == "UID" and uid is None and in_object: + uid = str(vText.from_ical(value)) + if name in _RECURRENCE_RULES: + value = ";".join(sorted(value.split(";"))) + parameters = tuple(sorted((key.upper(), str(item)) for key, item in params.items())) + stack[-1][1].append((name, parameters, value)) + if len(stack) != 1 or not stack[0][2]: + raise ValueError("Calendar data must hold whole components") + canonical = repr(sorted(stack[0][2])) + return _Fields(hashlib.sha256(canonical.encode()).hexdigest(), uid) + + +def _server_fields(content: bytes) -> _Fields: + """The fields of content the server sent, which a malformed object mustn't stop. + + Data the canonical form can't read is fingerprinted as it came, so it + still syncs, and only differs from what you sent where a rewrite would. + """ + try: + return _fields(content) + except ValueError: + return _Fields(hashlib.sha256(content).hexdigest(), None) + + +def _chunks(items: Sequence[str], size: int) -> Iterator[Sequence[str]]: + for start in range(0, len(items), size): + yield items[start : start + size] + + +class _CalDAVFlow(_Flow): + """Everything the sync and async CalDAV adapters share.""" + + _fingerprint_scheme = FINGERPRINT_SCHEME + _media_type = MEDIA_TYPE + # A tested server accepts two objects with one UID, and every write is + # guarded by the object's ETag. + _guarded_by_version = True + _unique_logical_key = False + # Whether the adapter takes a calendar on an asynchronous client. + _asynchronous: bool + + def __init__(self, replica: Replica, calendar: Calendar, scope_id: str | None) -> None: + if calendar.url is None or calendar.client is None: + raise ValueError("The calendar has no URL or no client") + if calendar.is_async_client is not self._asynchronous: + wanted = "an asynchronous" if self._asynchronous else "a synchronous" + raise TypeError(f"{type(self).__name__} takes a calendar on {wanted} client") + self._calendar = calendar + self._url: URL = calendar.url + self._client: DAVClient = calendar.client + super().__init__(replica, self._calendar_id() if scope_id is None else scope_id) + self._collection = self._object_id(calendar.url) + self._containers = frozenset({self.scope_id}) + # Whether the collection supports sync-collection; read before its first sync. + self._reports_changes: bool | None = None + + @property + def needs_full_listings(self) -> bool: + """Whether the server may leave changes out without refusing the scope's token. + + Read from the server profile configured on the client, python-caldav's + ``sync-token.invalid-token`` feature. When it's true, schedule regular + full listings with ``sync(full_listing=True)``: a listing only fetches + objects whose ETag changed, so it costs little more than a sync. + """ + features = self._client.features + if features is None: + return False + # python-caldav leaves return_type unannotated, so its default types it as bool. + support = features.is_supported( + _TOKEN_REFUSAL_FEATURE, + return_type=str, # pyright: ignore[reportArgumentType] + ) + return support in _UNRELIABLE_TOKEN_REFUSAL + + def _calendar_id(self) -> str: + """The calendar's URL in one spelling, without a login, as the default scope id.""" + parts = urlsplit(str(self._url.canonical())) + # Scheme and host are case-insensitive (RFC 3986 section 6.2.2.1). + return urlunsplit(parts._replace(scheme=parts.scheme.lower(), netloc=parts.netloc.lower())) + + def _object_id(self, href: Any) -> str: + """The object id of an href, in one spelling however the server or client wrote it.""" + return str(self._url.join(href).canonical().path) + + def _new_object_id(self) -> str: + # Only unreserved characters (RFC 3986 section 2.3), so it can't come + # back spelled another way. + return self._object_id(f"{uuid.uuid4()}.ics") + + def _edit_fields(self, content: bytes, *, create: bool) -> _EditFields: + fields = _fields(content) + if create and fields.logical_key is None: + raise ValueError("Calendar data must hold an object with a UID") + return { + "fingerprint": fields.fingerprint, + "containers": self._containers, + "logical_key": fields.logical_key, + } + + def _ask(self, request: Any) -> Steps[Any]: + """Send one request about the collection; a collection that's gone raises. + + ``request`` is a function making the call, so a synchronous client's + error is raised here too. + """ + try: + return (yield request()) + except dav_error.NotFoundError as exc: + raise ScopeGoneError(f"The server has no calendar at {self._url}") from exc + + def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[_Read]: + # A collection that names no token (one that supports the report + # must, RFC 6578 section 4) is listed every time too. + if full_listing or not scope.token or not (yield from self._supports_changes()): + yield from self._list(session, scope) + return _Read.COMPLETE + try: + found = yield from self._ask( + lambda: self._calendar.get_objects_by_sync_token(scope.token, disable_fallback=True) + ) + except dav_error.DAVError as exc: + if _VALID_SYNC_TOKEN in exc.preconditions: + return _Read.INVALID_TOKEN + raise + removed = {self._object_id(url) for url in found.deleted_urls} + known: dict[str, str] = yield _InStore(self._replica.known_versions, (self.scope_id,)) + # A member reported with the ETag already stored hasn't changed since, + # such as one this sync already fetched or that came back twice. + reported = ((self._object_id(obj.url), obj.etag) for obj in found.objects) + changed = [ + object_id + for object_id, version in reported + if object_id not in removed and (version is None or known.get(object_id) != version) + ] + fetched = yield from self._fetch(changed) + # Nextcloud and Synology report a member deleted and re-created at one + # href only as removed, so removals are fetched too: one that comes back + # with data is still there. They go in multigets of their own, so a + # server that refuses a whole multiget for a gone member (Posteo) only + # costs a request per removal. + fetched |= yield from self._fetch(sorted(removed)) + changes: dict[str, Change] = {**dict.fromkeys(removed, Removed()), **fetched} + # A reply without a token keeps the scope's, so nothing in it is lost: + # the next sync asks again from where this one started. + token = scope.token if found.sync_token is None else found.sync_token + change_set = ChangeSet( + self.scope_id, scope.token, token, changes, truncated=found.truncated + ) + yield _InStore(session.ingest, (change_set,)) + return _Read.TRUNCATED if found.truncated else _Read.COMPLETE + + def _supports_changes(self) -> Steps[bool]: + """Whether the collection lists ``sync-collection`` in its supported reports.""" + if self._reports_changes is None: + props = yield from self._ask( + lambda: self._calendar.get_properties([dav.SupportedReportSet()]) + ) + self._reports_changes = _SYNC_COLLECTION in ( + props.get(dav.SupportedReportSet.tag) or () + ) + return self._reports_changes + + def _list(self, session: Session, scope: Scope) -> Steps[None]: + """A full listing: the token first, then every member and its ETag.""" + props = yield from self._ask(lambda: self._calendar.get_properties([dav.SyncToken()])) + token = props.get(dav.SyncToken.tag) or "" + try: + response = yield from self._ask( + lambda: self._calendar.get_properties( + [dav.GetEtag()], depth=1, parse_response_xml=False + ) + ) + except dav_error.ResponseError as exc: + # python-caldav refuses a multistatus that marks the cut with a 507. + # Up to 3.6.0 it gives the status line; later releases set the status. + if exc.status != _TRUNCATED and f" {_TRUNCATED} " not in f" {exc.url} ": + raise + raise IncompleteListingError( + f"The server cut the listing of {self._url} short" + ) from exc + versions = { + self._object_id(entry.href): entry.properties.get(dav.GetEtag.tag) + for entry in response.parse_propfind() + } + known: dict[str, str] = yield _InStore(self._replica.known_versions, (self.scope_id,)) + pages = list(_chunks(list(versions), MULTIGET_SIZE)) or [()] + for number, page in enumerate(pages, start=1): + changes: dict[str, Change] = { + i: Unchanged() + for i in page + if versions[i] is not None and known.get(i) == versions[i] + } + changes.update((yield from self._fetch(i for i in page if i not in changes))) + change_set = ChangeSet( + self.scope_id, + scope.token, + token, + changes, + full_listing=True, + truncated=number < len(pages), + ) + yield _InStore(session.ingest, (change_set,)) + + def _fetch(self, object_ids: Iterable[str]) -> Steps[dict[str, Changed]]: + """Fetch objects' content and ETags together, a multiget at a time. + + An object missing from the answer, or answered without data, was + removed after it was reported; the next report carries its removal. + One answered without an ETag is kept with no version, so the next + listing fetches it again. + """ + wanted = [i for i in object_ids if i != self._collection] + fetched: dict[str, Changed] = {} + for chunk in _chunks(wanted, MULTIGET_SIZE): + urls = [self._url.join(i) for i in chunk] + found = yield from self._multiget(urls) + for obj in found: + content = obj.raw_data + if content is None: + continue + fields = _server_fields(content) + fetched[self._object_id(obj.url)] = Changed( + obj.etag, fields.fingerprint, content, self._containers, fields.logical_key + ) + return fetched + + def _multiget(self, urls: list[URL]) -> Steps[list[Any]]: + """Fetch members with one ``calendar-multiget``. + + Posteo answers a whole multiget with 404 when one member is gone, so + then, once the calendar is known to be there, each member is fetched + on its own, and one that's gone is left out. + """ + try: + found: list[Any] = yield self._calendar.multiget(urls) + except dav_error.NotFoundError: + # Raises ScopeGoneError when the calendar itself is gone. + yield from self._ask(lambda: self._calendar.get_properties([dav.SyncToken()])) + found = [] + for url in urls: + try: + found += yield self._calendar.multiget([url]) + except dav_error.NotFoundError: + continue + return found + + def _waits(self, record: Record) -> bool: + # If-Match compares strongly, so a weak ETag never matches (RFC 9110 + # section 13.1.1); python-caldav would send the write unconditionally. + return not record.is_create and (record.version or "").startswith(_WEAK) + + def _send(self, claimed: Record) -> Steps[PushOutcome]: + # A claimed record holds its edit, and a claim gives every create its href. + edit = cast(LocalEdit, claimed.edit) + props = {dav.GetEtag.tag: claimed.version} if claimed.version else {} + resource = CalendarObjectResource( + client=self._client, + url=self._url.join(cast(str, claimed.object_id)), + parent=self._calendar, + props=props, + ) + if edit.kind is EditKind.CREATE: + try: + yield resource.save(no_overwrite=True, raw_data=edit.content, precondition="etag") + except dav_error.UidConflictError as exc: + return LogicalKeyTaken(self._holder(exc)) + except dav_error.ConsistencyError: + return Rejected() + return Applied() + if edit.kind is EditKind.DELETE: + try: + yield resource.delete(if_match=True, precondition="etag") + except dav_error.NotFoundError: + return Gone() + except dav_error.ETagMismatchError: + return Rejected() + return Applied() + try: + yield resource.save(raw_data=edit.content, precondition="etag", increase_seqno=False) + except dav_error.UidConflictError as exc: + return LogicalKeyTaken(self._holder(exc)) + except (dav_error.ETagMismatchError, dav_error.ScheduleTagMismatchError): + return Rejected() + except dav_error.PutError as exc: + if exc.status == _NOT_FOUND: + return Gone() + raise + return Applied() + + def _holder(self, refusal: Any) -> str | None: + """The object id of the object holding a refused create's UID, if the server named it. + + The precondition element may hold its ``DAV:href`` (:rfc:`4791#section-5.3.2.1`), + read with the parser settings python-caldav found the precondition + with, so it parses. Naming the holder is a help, never a reason to + fail the push: + a body or href this calendar can't place, such as one in another + calendar or on another host, names no holder, and the core finds it + by the UID instead. + """ + body = refusal.body.encode() if isinstance(refusal.body, str) else refusal.body + if not body: + return None + root = etree.fromstring(body, parser=etree.XMLParser(**_ERROR_PARSER)) + for tag in dav_error.UID_CONFLICT_PRECONDITIONS: + for element in root.iter(tag): + href = element.find(_HREF) + if href is not None and href.text: + try: + holder = self._object_id(href.text.strip()) + except ValueError: # on another host than the calendar + return None + in_calendar = holder.rpartition("/")[0] == self._collection.rstrip("/") + return holder if in_calendar else None + return None + + +@check_argument_types +class CalDAVAdapter(_CalDAVFlow): + """Syncs one calendar collection with a :class:`~calendaring_sync.replica.Replica`. + + The calendar is a python-caldav ``Calendar`` on a synchronous client, + and it's one :term:`scope`. Your application calls :meth:`sync` when it + decides to sync, and records its edits with :meth:`create` and + :meth:`change`, which build an object's fingerprint and logical key the + same way a sync does. Deletes and discards need nothing from the + content, so they go to :meth:`~calendaring_sync.replica.Replica.delete` + and :meth:`~calendaring_sync.replica.Replica.discard`. + + With no server profile on the client, python-caldav searches the + calendar for a create's ``UID`` before sending it. Configuring the + client's ``features`` (``features="radicale"``, for example) lets the + server check instead, in the same request. + + A change or delete of an object whose stored ETag is weak (``W/``) + waits, unsent, until a sync stores a strong one, since ``If-Match`` + never matches a weak ETag; :meth:`waiting` lists such edits. + + Every method also raises the store's errors, as + :class:`~calendaring_sync.replica.Replica` describes. + + Args: + replica: Where the sync state is kept. + calendar: The calendar collection to sync. + scope_id: The scope's id. Defaults to the calendar's URL, without + a login and spelled one way, so calendars on two servers never + share a scope and one calendar always gets the same one. + + Raises: + TypeError: If the calendar is on an asynchronous client, or an + argument has the wrong type. + ValueError: If the calendar has no URL or no client. + + Examples: + A sync, an edit, and the sync that pushes it:: + + adapter = CalDAVAdapter(replica, calendar) + adapter.sync() + key = adapter.create(ics_bytes) + adapter.sync() + """ + + _asynchronous = False + + def __init__( + self, replica: Replica, calendar: Calendar, *, scope_id: str | None = None + ) -> None: + super().__init__(replica, calendar, scope_id) + + def sync(self, *, full_listing: bool = False) -> None: + """Sync the calendar: ingest what changed on the server, then push your edits. + + Changes since the scope's token come from a ``sync-collection`` + report, and a calendar that has none, or a scope that has no token, + is listed in full instead. When the server refuses the token, the + scope is :term:`reset` and listed in full. Your edits are pushed only + once everything the server reported was ingested. Each step runs in + a store transaction of its own. + + Args: + full_listing: List every object instead of asking for changes. + Members whose ETag didn't change aren't fetched. + + Raises: + ScopeGoneError: If the server has no calendar at its URL. + IncompleteListingError: If the server cut a full listing short. + StaleChangeSetError: If another process ingested the scope first. + SessionError: If this replica already has a session open for the scope. + ScopeMismatchError: If the scope's content has another media type. + TransactionError: If a store transaction is open in this thread. + caldav.lib.error.DAVError: For any other failure python-caldav + reports, such as a refused login, unchanged. A push it + interrupted is settled by the next sync. + """ + _run(self._sync(full_listing=full_listing)) + + def create(self, content: bytes) -> int: + """Record a create of an object with this iCalendar data, and return its local key. + + The object gets an href of its own; the next :meth:`sync` sends it. + Runs in a write transaction. + + Raises: + ValueError: If the content isn't UTF-8 iCalendar data with a ``UID``. + UnknownScopeError: If the calendar was never synced. + TransactionError: If a store transaction is open in this thread. + """ + return _run(self._create(content)) + + def change(self, local_key: int, content: bytes) -> None: + """Record a change of an object to this iCalendar data; the next :meth:`sync` sends it. + + Reads the record, then writes the change, each in a transaction of its own. + + Raises: + ValueError: If the content isn't UTF-8 iCalendar data, or the + record is in another scope. + UnknownRecordError: If no record has this local key. + EditRefusedError: If the record can't take an edit, such as one + with an open conflict. + TransactionError: If a store transaction is open in this thread. + """ + _run(self._change(local_key, content)) + + def waiting(self) -> list[Record]: + """Return the pending edits the next :meth:`sync` won't send, and can't yet. + + That's a change or delete of an object whose stored ETag is weak. + Runs in a read transaction. + + Raises: + UnknownScopeError: If the calendar was never synced. + TransactionError: If a store transaction is open in this thread. + """ + return _run(self._waiting()) + + +@check_argument_types +class AsyncCalDAVAdapter(_CalDAVFlow): + """:class:`CalDAVAdapter` for a calendar on an asynchronous client. + + :meth:`sync`, :meth:`create` and :meth:`change` are coroutines; each + store call runs in a worker thread, so a long ingest doesn't block the + event loop. Everything else is as :class:`CalDAVAdapter` describes. + + Raises: + TypeError: If the calendar is on a synchronous client, or an + argument has the wrong type. + ValueError: If the calendar has no URL or no client. + """ + + _asynchronous = True + + def __init__( + self, replica: Replica, calendar: Calendar, *, scope_id: str | None = None + ) -> None: + super().__init__(replica, calendar, scope_id) + + async def sync(self, *, full_listing: bool = False) -> None: + """See :meth:`CalDAVAdapter.sync`.""" + await _arun(self._sync(full_listing=full_listing)) + + async def create(self, content: bytes) -> int: + """See :meth:`CalDAVAdapter.create`.""" + return await _arun(self._create(content)) + + async def change(self, local_key: int, content: bytes) -> None: + """See :meth:`CalDAVAdapter.change`.""" + await _arun(self._change(local_key, content)) + + async def waiting(self) -> list[Record]: + """See :meth:`CalDAVAdapter.waiting`.""" + return await _arun(self._waiting()) diff --git a/src/calendaring_sync/error.py b/src/calendaring_sync/error.py index 3a7c8b6..b6bfdf9 100644 --- a/src/calendaring_sync/error.py +++ b/src/calendaring_sync/error.py @@ -109,6 +109,26 @@ class ScopeMismatchError(CalendaringSyncError): """ +class ScopeGoneError(CalendaringSyncError): + """The server no longer has the :term:`scope`, such as a calendar that was deleted. + + Nothing was ingested, so the :term:`store` is as it was. A server that + lost the scope only for a moment has it back at the next sync. If it's + gone for good, :meth:`~calendaring_sync.replica.Replica.drop_scope` + returns the pending edits and open conflicts it discards. + """ + + +class IncompleteListingError(CalendaringSyncError): + """The server cut a :term:`full listing` short, so it wasn't ingested. + + A listing that leaves objects out can't tell them from objects the + server removed, so the :term:`store` is left as it was. Raising the + server's limit on how many results it lists lets the next listing + through. + """ + + class SessionError(CalendaringSyncError): """A :term:`session` was used out of order. diff --git a/src/calendaring_sync/objects/conflict.py b/src/calendaring_sync/objects/conflict.py index 46fe863..1045ba2 100644 --- a/src/calendaring_sync/objects/conflict.py +++ b/src/calendaring_sync/objects/conflict.py @@ -25,8 +25,8 @@ class ConflictKind(StrEnum): DELETED_LOCALLY = "deleted_locally" #: The server has a new object at the id your unsent create chose. BOTH_CREATED = "both_created" - #: The server refused your create because another object holds its - #: :term:`logical key`. + #: The server refused your create, or your change, because another + #: object holds its :term:`logical key`. LOGICAL_KEY_TAKEN = "logical_key_taken" diff --git a/src/calendaring_sync/objects/push.py b/src/calendaring_sync/objects/push.py index 2ed59c3..ef56dec 100644 --- a/src/calendaring_sync/objects/push.py +++ b/src/calendaring_sync/objects/push.py @@ -60,7 +60,7 @@ class Gone: @checked_when_built @dataclass(frozen=True) class LogicalKeyTaken: - """The server refused a create because another object holds its :term:`logical key`. + """The server refused a create or a change because another object holds its :term:`logical key`. Attributes: holder: The :term:`object id` of the object that holds the key, when diff --git a/src/calendaring_sync/objects/record.py b/src/calendaring_sync/objects/record.py index f7e300f..ae08297 100644 --- a/src/calendaring_sync/objects/record.py +++ b/src/calendaring_sync/objects/record.py @@ -9,7 +9,7 @@ from datetime import datetime from enum import StrEnum -from calendaring_sync.objects.conflict import Conflict +from calendaring_sync.objects.conflict import Conflict, ConflictKind from calendaring_sync.objects.edit import EditKind, LocalEdit @@ -92,15 +92,29 @@ def is_create(self) -> bool: """Whether the record is an object you created that the server doesn't have yet.""" return self.edit is not None and self.edit.kind is EditKind.CREATE + @property + def is_refused_change(self) -> bool: + """Whether the server refused this record's change for its :term:`logical key`. + + The server then still holds the synced copy, and the conflict holds + no copy of its own. + """ + return ( + self.conflict is not None + and self.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN + and not self.is_create + ) + @property def server_version(self) -> str | None: """The :term:`version` of the server's copy, ``None`` if the store knows none. - That's the conflict's while the record is in conflict, and ``None`` - once the server removed the object. + That's the conflict's while the record is in conflict, but the synced + one for a change the server refused, and ``None`` once the server + removed the object. """ if self.state is RecordState.REMOVED: return None - if self.conflict is not None: + if self.conflict is not None and not self.is_refused_change: return self.conflict.remote_version return self.version diff --git a/src/calendaring_sync/objects/resolution.py b/src/calendaring_sync/objects/resolution.py index 1cb17f1..c0b0bd8 100644 --- a/src/calendaring_sync/objects/resolution.py +++ b/src/calendaring_sync/objects/resolution.py @@ -16,7 +16,7 @@ class TakeServer: For a create the server refused because another object holds its :term:`logical key`, the server has nothing of yours, so the create is - discarded. + discarded; for a refused change, the object stays as it was synced. """ @@ -73,10 +73,11 @@ class Delete: @dataclass(frozen=True) class UpdateHolder: - """Push a create the server refused as a change to the object that holds its logical key. + """Push a refused edit as a change to the object that holds its logical key. - Only for a create refused because another object holds its - :term:`logical key`. + Only for a create or a change refused because another object holds its + :term:`logical key`. A refused create then leaves nothing, and the object + a refused change was for stays as it was synced. """ diff --git a/src/calendaring_sync/replica.py b/src/calendaring_sync/replica.py index 132adda..2f6fe95 100644 --- a/src/calendaring_sync/replica.py +++ b/src/calendaring_sync/replica.py @@ -152,11 +152,11 @@ def _check_resolution(record: Record, resolution: Resolution) -> None: """Raise ``ValueError`` unless ``record`` is in conflict and ``resolution`` fits its kind.""" if record.state is not RecordState.CONFLICT or record.conflict is None: raise ValueError("resolve() takes a record in conflict") - refused_create = record.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN - if isinstance(resolution, KeepEdit) and refused_create: - raise ValueError("A create refused for its logical key can't be pushed again unchanged") - if isinstance(resolution, UpdateHolder) and not refused_create: - raise ValueError("Only a create refused for its logical key goes to the key's holder") + refused = record.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN + if isinstance(resolution, KeepEdit) and refused: + raise ValueError("An edit refused for its logical key can't be pushed again unchanged") + if isinstance(resolution, UpdateHolder) and not refused: + raise ValueError("Only an edit refused for its logical key goes to the key's holder") def _same_decision(stored: Record | None, record: Record) -> bool: @@ -177,11 +177,15 @@ def _resolved(record: Record, resolution: Resolution, now: datetime) -> Record | next goes out against the server's current version. """ conflict, edit = cast(Conflict, record.conflict), cast(LocalEdit, record.edit) - on_server = conflict.remote_content is not None + on_server = conflict.remote_content is not None or record.is_refused_change if not on_server and isinstance(resolution, (TakeServer, Delete)): # The server doesn't have the object: a create it never had leaves nothing. return None if record.is_create else _removed(record, now) - base = _synced(record, conflict, conflict.remote_version, now, prefix=REMOTE) + if record.is_refused_change: + # The server kept the synced copy. + base = replace(record, state=RecordState.SYNCED, edit=None, conflict=None) + else: + base = _synced(record, conflict, conflict.remote_version, now, prefix=REMOTE) # Over a copy the server has, an edit is a change; with none, a create. kind = EditKind.CHANGE if on_server else EditKind.CREATE match resolution: @@ -233,8 +237,8 @@ def _check_outcome(claimed_keys: set[int], claimed: Record, outcome: PushOutcome match outcome: case Gone() if kind is EditKind.CREATE: raise ValueError("A create can't be gone: the server never had the object") - case LogicalKeyTaken() if kind is not EditKind.CREATE: - raise ValueError(f"Only a create can be refused for its logical key, not a {kind}") + case LogicalKeyTaken() if kind is EditKind.DELETE: + raise ValueError("A delete can't be refused for its logical key") case Applied(object_id=str()) if kind is EditKind.DELETE: raise ValueError("An applied delete has no object id to report") case Applied(object_id=None) if kind is EditKind.CREATE and claimed.object_id is None: @@ -465,7 +469,7 @@ def settle(self, claimed: Record, outcome: PushOutcome) -> bool: ValueError: If ``claimed`` isn't a record this session's :meth:`claim` returned, or the outcome is one no push of its edit can have: ``Gone`` for a create, ``LogicalKeyTaken`` for - anything but a create, ``Applied`` with an id for a delete, or + a delete, ``Applied`` with an id for a delete, or ``Applied`` without one for a create that has none. Examples: @@ -739,7 +743,14 @@ def _apply( record = claimed.pop(change.logical_key) in_flight = record is not None and record.state is RecordState.IN_FLIGHT live = None if record is None or record.state is RecordState.REMOVED else record - if live is not None and live.conflict is not None and same_remote(live.conflict, change): + # A refused change holds no copy of the server's object, so whatever the + # server reports about it is news, a removal included. + if ( + live is not None + and live.conflict is not None + and not live.is_refused_change + and same_remote(live.conflict, change) + ): if isinstance(change, Changed): conflict = Conflict.from_change(live.conflict.kind, change, live.conflict.opened_at) self._store.update_record(replace(live, conflict=conflict)) @@ -1256,11 +1267,17 @@ def _editable(self, local_key: int) -> Record: raise PushInFlightError(f"Record {local_key} has a push in flight") return record - def records(self, scope_id: str) -> list[Record]: - """Return every record in a scope, removed ones included, ordered by local key. + def records( + self, scope_id: str, *, states: frozenset[RecordState] | None = None + ) -> list[Record]: + """Return the records in a scope, removed ones included, ordered by local key. Runs in a read transaction of its own. + Args: + scope_id: The scope to read. + states: Only the records in one of these states; ``None`` for every record. + Raises: TransactionError: If a store transaction is already open in this thread. UnknownScopeError: If there's no scope with this id. @@ -1268,8 +1285,12 @@ def records(self, scope_id: str) -> list[Record]: Examples: >>> [record.object_id for record in replica.records("work")] ['a'] + >>> replica.records("work", states=frozenset({RecordState.PENDING})) + [] """ - return self._read_in_scope(scope_id, lambda: self._store.scope_records(scope_id)) + return self._read_in_scope( + scope_id, lambda: self._store.scope_records(scope_id, states=states) + ) def known_versions(self, scope_id: str) -> dict[str, str]: """Return the version of the server's copy of every object the store knows one for. @@ -1369,13 +1390,13 @@ def resolve(self, record: Record, resolution: Resolution) -> None: move of the server's version alone doesn't count. UnknownHolderError: For ``UpdateHolder``, if the server named no record that's still there, and no single record holds the - refused create's logical key, or it was sent without one. + refused edit's logical key, or it was sent without one. OpenConflictError: For ``UpdateHolder``, if the holder is in conflict. PushInFlightError: For ``UpdateHolder``, if the holder's push is in flight. PendingEditError: For ``UpdateHolder``, if the holder holds a pending edit of its own, which this would replace. ValueError: If ``record`` isn't in conflict, or the resolution - doesn't fit its kind: ``KeepEdit`` for a create the server + doesn't fit its kind: ``KeepEdit`` for an edit the server refused for its logical key, or ``UpdateHolder`` for any other. Examples: @@ -1409,7 +1430,8 @@ def resolve(self, record: Record, resolution: Resolution) -> None: ) edit = cast(LocalEdit, stored.edit) self._store.update_record(_changed_to(holder, replace(edit, recorded_at=now))) - resolved = None + # A refused create leaves nothing; a refused change, the synced copy. + resolved = None if stored.is_create else _resolved(stored, TakeServer(), now) else: resolved = _resolved(stored, resolution, now) if resolved is None: diff --git a/src/calendaring_sync/store/base.py b/src/calendaring_sync/store/base.py index c8cd474..97b58c7 100644 --- a/src/calendaring_sync/store/base.py +++ b/src/calendaring_sync/store/base.py @@ -42,15 +42,15 @@ # The states in which a record holds a pending edit. _EDIT_STATES = frozenset({RecordState.PENDING, RecordState.IN_FLIGHT, RecordState.CONFLICT}) -# The edit each kind of conflict is about, and whether the conflict holds a -# copy of the server's object. A refused create holds none: the server sent +# The edits each kind of conflict is about, and whether the conflict holds a +# copy of the server's object. A refused edit holds none: the server sent # nothing back but the refusal. -_CONFLICT_SIDES: dict[ConflictKind, tuple[EditKind, bool]] = { - ConflictKind.BOTH_CHANGED: (EditKind.CHANGE, True), - ConflictKind.REMOVED_REMOTELY: (EditKind.CHANGE, False), - ConflictKind.DELETED_LOCALLY: (EditKind.DELETE, True), - ConflictKind.BOTH_CREATED: (EditKind.CREATE, True), - ConflictKind.LOGICAL_KEY_TAKEN: (EditKind.CREATE, False), +_CONFLICT_SIDES: dict[ConflictKind, tuple[frozenset[EditKind], bool]] = { + ConflictKind.BOTH_CHANGED: (frozenset({EditKind.CHANGE}), True), + ConflictKind.REMOVED_REMOTELY: (frozenset({EditKind.CHANGE}), False), + ConflictKind.DELETED_LOCALLY: (frozenset({EditKind.DELETE}), True), + ConflictKind.BOTH_CREATED: (frozenset({EditKind.CREATE}), True), + ConflictKind.LOGICAL_KEY_TAKEN: (frozenset({EditKind.CREATE, EditKind.CHANGE}), False), } @@ -76,10 +76,11 @@ def _check_record(record: Record) -> None: raise RecordValueError(f"A {edit.kind} edit needs the object's content") if conflict is None: return - # Each kind of conflict is about one kind of edit. - edit_kind, server_has_it = _CONFLICT_SIDES[conflict.kind] - if edit is None or edit.kind is not edit_kind: - raise RecordValueError(f"A {conflict.kind} conflict needs a {edit_kind} edit") + # Each kind of conflict is about some kinds of edit. + edit_kinds, server_has_it = _CONFLICT_SIDES[conflict.kind] + if edit is None or edit.kind not in edit_kinds: + kinds = " or ".join(sorted(edit_kinds)) + raise RecordValueError(f"A {conflict.kind} conflict needs a {kinds} edit") # The server's side holds a copy exactly when the server still has the object. removed = data(conflict, REMOTE) == NO_DATA and conflict.remote_version is None if conflict.remote_content is None and not removed: @@ -87,7 +88,7 @@ def _check_record(record: Record) -> None: if server_has_it == removed: need = "needs" if server_has_it else "can't hold" raise RecordValueError(f"A {conflict.kind} conflict {need} the server's copy") - # Only a refused create names the object holding its logical key. + # Only a refused edit names the object holding its logical key. if conflict.holder is not None and conflict.kind is not ConflictKind.LOGICAL_KEY_TAKEN: raise RecordValueError(f"A {conflict.kind} conflict can't name a holder") diff --git a/src/calendaring_sync/tests/_caldav_scenarios.py b/src/calendaring_sync/tests/_caldav_scenarios.py new file mode 100644 index 0000000..ac37901 --- /dev/null +++ b/src/calendaring_sync/tests/_caldav_scenarios.py @@ -0,0 +1,476 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""What the CalDAV adapter does against a server, written once for every way it runs. + +Each scenario syncs a calendar with the adapter while a second client +changes the server behind its back, and asserts what the store holds. +``record_caldav`` runs them against real servers and saves each exchange, +the unit tests replay the recordings, and the integration tests run them +against running servers; all three use the synchronous and the +asynchronous adapter, except recording, which uses the synchronous one. +""" + +from __future__ import annotations + +import asyncio +import itertools +import uuid +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, replace +from typing import Any +from unittest import mock +from urllib.parse import urlsplit + +import caldav.aio +import pytest +from caldav.calendarobjectresource import CalendarObjectResource +from caldav.collection import Calendar +from caldav.davclient import DAVClient +from caldav.testing import RadicaleServer, XandikosServer + +from calendaring_sync import MemoryStore, Record, RecordState, Replica, StateStore +from calendaring_sync.adapters import caldav as adapter_module +from calendaring_sync.adapters.caldav import AsyncCalDAVAdapter, CalDAVAdapter +from calendaring_sync.error import IncompleteListingError, ScopeGoneError +from calendaring_sync.objects.conflict import ConflictKind +from calendaring_sync.tests._caldav_tape import Tape + +#: The host a replay talks to; recordings keep only paths. +REPLAY_URL = "http://recorded.invalid" +#: The login a replay sends where a recording asked for one; the tape never checks it. +REPLAY_LOGIN = "recorded" + + +#: A server's base URL, user name and password. +Login = tuple[str, str | None, str | None] +#: What a replay logs in to. +REPLAY: Login = (REPLAY_URL, REPLAY_LOGIN, REPLAY_LOGIN) + + +@dataclass(frozen=True) +class Server: + """A tested server: where it runs, and what differs about it.""" + + name: str + #: The path of the user's calendar home, where each scenario makes its calendar. + home: str + #: A token the server never issued and refuses with ``valid-sync-token``. + unknown_token: str + #: Whether deleting an object that's gone, with ``If-Match``, gets 404 (else 412). + delete_gone_is_404: bool = True + #: Where the server listens when started as its Docker fixture's README + #: says, and the account the fixture creates; ``None`` for a server that + #: runs embedded. + login: Login | None = None + #: Whether the server refuses a wrong password; embedded ones and + #: Cyrus' fixture let anyone in. + checks_passwords: bool = False + + +SERVERS = { + server.name: server + for server in ( + Server("radicale", "/user1/", "http://example.invalid/never-issued"), + # Xandikos answers a token that isn't hex with a 500 and no precondition. + Server("xandikos", "/sometestuser/calendars/", "0" * 40), + Server( + "cyrus", + "/dav/calendars/user/user1/", + "data:,0-0", + login=("http://localhost:8802", "user1", "x"), + ), + Server( + "stalwart", + "/dav/cal/testuser%40example.org/", + "http://example.invalid/never-issued", + login=("http://localhost:8809", "testuser@example.org", "testcaldav"), + checks_passwords=True, + ), + # Baïkal takes a well-formed token past its newest as valid, and + # answers it with no changes; only another form is refused. + Server( + "baikal", + "/dav.php/calendars/test/", + "http://example.invalid/never-issued", + delete_gone_is_404=False, + login=("http://127.0.0.1:8811", "test", "test"), + checks_passwords=True, + ), + ) +} +_EMBEDDED = {"radicale": RadicaleServer, "xandikos": XandikosServer} + + +@contextmanager +def started(server: Server, config: dict[str, Any] | None = None) -> Iterator[Login]: + """The login of ``server``: its Docker fixture's, or an embedded one's, started for the block. + + ``config`` configures an embedded server, as ``caldav.testing`` takes it. + """ + if server.login is not None: + yield server.login + return + embedded = _EMBEDDED[server.name](config) + embedded.start() + try: + parts = urlsplit(embedded.url) + yield f"{parts.scheme}://{parts.netloc}", embedded.username, embedded.password + finally: + embedded.stop() + + +def ics(uid: str, summary: str) -> bytes: + """A small event in iCalendar form.""" + return ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\n" + f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\n" + f"DTSTART:20261101T100000Z\r\nSUMMARY:{summary}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + ).encode() + + +@dataclass +class Env: + """One scenario's adapter, store, second client and tape.""" + + server: Server + scenario: str + tape: Tape + calendar: Calendar + other: Calendar + adapter: CalDAVAdapter | AsyncCalDAVAdapter + run: Callable[[Any], Any] + + @property + def replica(self) -> Replica: + return self.adapter._replica + + def sync(self, *, full_listing: bool = False) -> None: + self.run(self.adapter.sync(full_listing=full_listing)) + + def create(self, content: bytes) -> int: + key: int = self.run(self.adapter.create(content)) + return key + + def change(self, local_key: int, content: bytes) -> None: + self.run(self.adapter.change(local_key, content)) + + def ics(self, name: str, summary: str) -> bytes: + """An event whose UID is ``name`` within this scenario. + + UIDs carry the scenario's name, since Cyrus refuses a UID that + another of the user's calendars holds. + """ + return ics(f"{self.scenario}-{name}", summary) + + def _member(self, uid: str) -> CalendarObjectResource: + """``.ics`` in the calendar, through the second client.""" + path = f"{calendar_path(self.server, self.scenario)}{uid}.ics" + return CalendarObjectResource(client=self.other.client, url=path, parent=self.other) + + def put(self, uid: str, summary: str) -> None: + """Store an object on the server from the second client, at ``.ics``.""" + self._member(uid).save(raw_data=self.ics(uid, summary)) + + def remove(self, uid: str) -> None: + """Delete ``.ics`` from the server from the second client.""" + self._member(uid).delete() + + def never(self, method: str, marker: str, what: str) -> None: + """Fail with ``what`` if a ``method`` request's body ever holds ``marker``.""" + + def refuse() -> None: + raise AssertionError(what) + + self.tape.before(method, marker, refuse) + + def synced(self, uid: str, summary: str = "one") -> int: + """Put ``.ics`` on the server from the second client, sync, and return its key.""" + self.put(uid, summary) + self.sync() + return self.record(uid).local_key + + def at(self, local_key: int) -> Record: + """The record with this local key.""" + record = self.replica.record(local_key) + assert record is not None + return record + + def record(self, uid: str) -> Record: + """The one record whose logical key is ``uid``.""" + (found,) = self.replica.find_by_logical_key(self.adapter.scope_id, f"{self.scenario}-{uid}") + return found + + def forget_token(self) -> None: + """Give the scope a token the server never issued, as an expired one looks.""" + store = self.replica._store + with store.transaction(): + scope = store.get_scope(self.adapter.scope_id) + assert scope is not None + store.put_scope(replace(scope, token=self.server.unknown_token)) + + +def reading(env: Env) -> None: + """A first sync lists the calendar; later syncs ingest changes and removals.""" + env.sync() + assert env.replica.records(env.adapter.scope_id) == [] + sync_a_and_b(env) + env.put("a", "second a") + env.remove("b") + env.sync() + assert b"second a" in (env.record("a").content or b"") + assert env.record("b").state is RecordState.REMOVED + + +def put_a_and_b(env: Env) -> None: + """Put ``a`` and ``b`` on the server from the second client.""" + env.put("a", "first a") + env.put("b", "first b") + + +def sync_a_and_b(env: Env) -> None: + """Put ``a`` and ``b`` on the server from the second client, sync, and check both came in.""" + put_a_and_b(env) + env.sync() + for uid in ("a", "b"): + record = env.record(uid) + assert record.state is RecordState.SYNCED and record.version is not None + + +def create_c(env: Env) -> int: + """Sync the empty calendar, record a create of ``c`` here, and return its local key.""" + env.sync() + return env.create(env.ics("c", "made here")) + + +def pushing(env: Env) -> None: + """A create, a change and a delete each go out and are applied.""" + key = create_c(env) + env.sync() + assert env.at(key).state is RecordState.SYNCED + assert env.at(key).version is None + env.sync() + assert env.at(key).version is not None + env.change(key, env.ics("c", "changed here")) + env.sync() + env.sync() + assert b"changed here" in (env.at(key).content or b"") + env.replica.delete(key) + env.sync() + assert env.at(key).state is RecordState.REMOVED + + +def edit_a(env: Env) -> int: + """Sync ``a`` from the server, change it here, and return its local key.""" + key = env.synced("a") + env.change(key, env.ics("a", "mine")) + return key + + +def edit_a_changed_behind(env: Env) -> int: + """:func:`edit_a`, with the second client changing ``a`` right before the push.""" + key = edit_a(env) + env.tape.before("PUT", "SUMMARY:mine", lambda: env.put("a", "theirs")) + return key + + +def rejected(env: Env) -> None: + """A change whose object changed since the ingest is rejected, then becomes a conflict.""" + key = edit_a_changed_behind(env) + env.sync() + assert env.at(key).state is RecordState.PENDING + env.sync() + conflict = env.at(key).conflict + assert conflict is not None and conflict.kind is ConflictKind.BOTH_CHANGED + + +def delete_gone(env: Env) -> None: + """A delete of an object someone else deleted ends with the object removed.""" + key = env.synced("a") + env.replica.delete(key) + env.tape.before("DELETE", "", lambda: env.remove("a")) + env.sync() + expected = RecordState.REMOVED if env.server.delete_gone_is_404 else RecordState.PENDING + assert env.at(key).state is expected + env.sync() + assert env.at(key).state is RecordState.REMOVED + + +def uid_taken(env: Env) -> None: + """A create whose UID another object took meanwhile becomes a conflict.""" + env.sync() + key = env.create(env.ics("dup", "mine")) + env.tape.before("REPORT", "calendar-query", lambda: env.put("dup", "theirs")) + env.sync() + conflict = env.at(key).conflict + assert conflict is not None and conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN + + +def invalid_token(env: Env) -> None: + """A refused token resets the scope and lists it; a pending edit survives and goes out.""" + key = edit_a(env) + env.forget_token() + env.sync() + assert env.replica.scope(env.adapter.scope_id).token not in (None, env.server.unknown_token) + assert env.at(key).state is RecordState.SYNCED + + +def scope_gone(env: Env) -> None: + """A calendar deleted on the server raises, and the store keeps what it had.""" + env.synced("a") + env.other.delete() + with pytest.raises(ScopeGoneError): + env.sync() + assert env.record("a").state is RecordState.SYNCED + + +def listing(env: Env) -> None: + """A full listing fetches only the objects whose ETag changed, and never the calendar.""" + env.never( + "REPORT", + f"{calendar_path(env.server, env.scenario)}<", + "A multiget asked for the calendar itself", + ) + env.put("a", "one") + env.put("b", "one") + env.sync() + env.put("b", "two") + env.sync(full_listing=True) + assert b"two" in (env.record("b").content or b"") + assert env.record("a").state is RecordState.SYNCED + + +def listing_cut_short(env: Env) -> None: + """A listing the server cuts short raises, and nothing is ingested.""" + for uid in ("a", "b", "c"): + env.put(uid, "one") + with pytest.raises(IncompleteListingError): + env.sync() + assert env.replica.records(env.adapter.scope_id) == [] + + +def fetch_after_delete(env: Env) -> None: + """An object deleted between the report and the fetch waits for the next report.""" + env.synced("a") + env.put("a", "two") + env.tape.before("REPORT", "calendar-multiget", lambda: env.remove("a")) + env.sync() + assert b"one" in (env.record("a").content or b"") + env.sync() + assert env.record("a").state is RecordState.REMOVED + + +SCENARIOS: dict[str, Callable[[Env], None]] = { + scenario.__name__: scenario + for scenario in ( + reading, + pushing, + rejected, + delete_gone, + uid_taken, + invalid_token, + scope_gone, + listing, + listing_cut_short, + fetch_after_delete, + ) +} + +#: Each recording: the server and the scenario. +RECORDINGS = [ + ("radicale", "reading"), + ("radicale", "pushing"), + ("radicale", "rejected"), + ("radicale", "delete_gone"), + ("radicale", "uid_taken"), + ("radicale", "invalid_token"), + ("radicale", "scope_gone"), + ("radicale", "listing"), + ("xandikos", "reading"), + ("xandikos", "invalid_token"), + ("xandikos", "fetch_after_delete"), + ("cyrus", "reading"), + ("cyrus", "invalid_token"), + ("stalwart", "reading"), + ("stalwart", "listing_cut_short"), + ("baikal", "delete_gone"), + ("baikal", "fetch_after_delete"), +] + + +@contextmanager +def _fixed_hrefs() -> Iterator[None]: + """Give creates the same hrefs in a recording and its replays.""" + numbers = itertools.count(1) + with mock.patch.object(adapter_module.uuid, "uuid4", lambda: uuid.UUID(int=next(numbers))): + yield + + +def caldav_client(asynchronous: bool, **settings: Any) -> Any: + """A python-caldav client made with ``settings``, asynchronous or not.""" + return (caldav.aio.AsyncDAVClient if asynchronous else DAVClient)(**settings) + + +def adapter_class(asynchronous: bool) -> type[CalDAVAdapter] | type[AsyncCalDAVAdapter]: + """The CalDAV adapter for an asynchronous client, or for a synchronous one.""" + return AsyncCalDAVAdapter if asynchronous else CalDAVAdapter + + +def calendar_path(server: Server, scenario: str) -> str: + return f"{server.home}calendaring-sync-{scenario.replace('_', '-')}/" + + +def run( + server: Server, + scenario: str, + tape: Tape, + *, + login: Login = REPLAY, + asynchronous: bool = False, + store: StateStore | None = None, +) -> None: + """Run one scenario through ``tape``, recording from the server at ``login`` or replaying.""" + with environment( + server, scenario, tape, login=login, asynchronous=asynchronous, store=store + ) as env: + SCENARIOS[scenario](env) + + +@contextmanager +def environment( + server: Server, + scenario: str, + tape: Tape, + *, + login: Login = REPLAY, + asynchronous: bool = False, + store: StateStore | None = None, +) -> Iterator[Env]: + """The adapter and second client of ``scenario``, on an empty calendar, through ``tape``. + + The adapter keeps its state in ``store``, a new ``MemoryStore`` by default. + """ + base_url, username, password = login + url = f"{base_url.rstrip('/')}{calendar_path(server, scenario)}" + other_client = DAVClient(url=base_url, username=username, password=password) + tape.attach(other_client) + other = Calendar(client=other_client, url=url) + # Start from an empty calendar; one left from an earlier recording goes. + other_client.request(url, "DELETE") + other_client.request(url, "MKCALENDAR") + replica = Replica(MemoryStore() if store is None else store) + client = caldav_client(asynchronous, url=base_url, username=username, password=password) + tape.attach(client, asynchronous=asynchronous) + calendar = Calendar(client=client, url=url) + adapter = adapter_class(asynchronous)(replica, calendar) + loop = asyncio.new_event_loop() + # A step of the asynchronous adapter is a coroutine to run. + run_step: Callable[[Any], Any] = loop.run_until_complete if asynchronous else lambda step: step + try: + with _fixed_hrefs(): + yield Env(server, scenario, tape, calendar, other, adapter, run_step) + finally: + run_step(client.close()) + other_client.close() + loop.close() diff --git a/src/calendaring_sync/tests/_caldav_tape.py b/src/calendaring_sync/tests/_caldav_tape.py new file mode 100644 index 0000000..3023020 --- /dev/null +++ b/src/calendaring_sync/tests/_caldav_tape.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""HTTP exchanges with a CalDAV server, recorded once and replayed by the unit tests. + +The integration tests run through a recording tape too, to change the +server between two of the adapter's requests and to read what it sent. + +A :class:`Tape` takes the place of a python-caldav client's HTTP session. +Recording, it passes every request to the real session and keeps the +exchange; replaying, it answers each request with the next exchange and +checks that the request is the one recorded. Everything above the session, +python-caldav's parsing and errors included, runs as it does against a +server. Several clients can share one tape, so the order of their requests +is kept. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from niquests.structures import CaseInsensitiveDict + +#: Where the recordings live. +RECORDINGS = Path(__file__).parent / "data" / "caldav" +# Response headers worth keeping; the rest (dates, server names, lengths) +# only change between recordings. +_KEPT_HEADERS = frozenset( + {"content-type", "etag", "schedule-tag", "location", "dav", "www-authenticate"} +) + + +@dataclass(frozen=True) +class Exchange: + """One request and the server's answer to it.""" + + method: str + path: str + status: int + headers: dict[str, str] + body: str + + +class _Reply: + """An HTTP response, as python-caldav reads one.""" + + def __init__(self, exchange: Exchange) -> None: + self.status_code = exchange.status + self.headers = CaseInsensitiveDict(exchange.headers) + self.content = exchange.body.encode("utf-8") + self.text = exchange.body + self.reason = self.reason_phrase = "" + + +@dataclass +class _Hook: + method: str + marker: str + callback: Callable[[], object] + + +class Tape: + """Recorded exchanges, played back in order or recorded from a real session. + + Args: + exchanges: The exchanges to replay; ``None`` to record. + name: The recording the exchanges come from, ``-``. + """ + + def __init__(self, exchanges: list[Exchange] | None = None, name: str | None = None) -> None: + self.name = name + self.recording = exchanges is None + self.exchanges: list[Exchange] = [] if exchanges is None else list(exchanges) + self._played = 0 + self._hooks: list[_Hook] = [] + + @classmethod + def load(cls, name: str) -> Tape: + """Load the recording ``name`` for replay.""" + data = json.loads((RECORDINGS / f"{name}.json").read_text(encoding="utf-8")) + return cls([Exchange(**exchange) for exchange in data["exchanges"]], name) + + def save(self, name: str, about: dict[str, str]) -> None: + """Write the recording, with ``about`` saying where it came from.""" + RECORDINGS.mkdir(parents=True, exist_ok=True) + data = {**about, "exchanges": [asdict(exchange) for exchange in self.exchanges]} + text = json.dumps(data, indent=1, ensure_ascii=False) + "\n" + (RECORDINGS / f"{name}.json").write_text(text, encoding="utf-8", newline="\n") + + def attach(self, client: Any, *, asynchronous: bool = False) -> None: + """Route a python-caldav client's requests through the tape.""" + client.session = (_AsyncSession if asynchronous else _Session)(self, client.session) + + def before(self, method: str, marker: str, callback: Callable[[], object]) -> None: + """Run ``callback`` once, before the next ``method`` request whose body holds ``marker``. + + A test uses it to change the server while the adapter is between + two requests; the callback's own requests go on the tape too. + """ + self._hooks.append(_Hook(method, marker, callback)) + + @property + def finished(self) -> bool: + """Whether every recorded exchange was played.""" + return self._played == len(self.exchanges) + + def _fire(self, method: str, body: bytes | str | None) -> None: + text = body.decode("utf-8", "replace") if isinstance(body, bytes) else body or "" + for hook in self._hooks: + if hook.method == method and hook.marker in text: + self._hooks.remove(hook) + hook.callback() + return + + def _play(self, method: str, url: str) -> _Reply: + path = urlsplit(url).path + if self._played == len(self.exchanges): + raise AssertionError(f"Unrecorded request: {method} {path}") + exchange = self.exchanges[self._played] + if (exchange.method, exchange.path) != (method, path): + raise AssertionError( + f"Request {self._played}: {method} {path}, " + f"recorded {exchange.method} {exchange.path}" + ) + self._played += 1 + return _Reply(exchange) + + def _keep(self, method: str, url: str, response: Any) -> None: + headers = {k.lower(): v for k, v in response.headers.items() if k.lower() in _KEPT_HEADERS} + body = response.content.decode("utf-8") if response.content else "" + self.exchanges.append( + Exchange(method, urlsplit(url).path, response.status_code, headers, body) + ) + + +def _method_and_url(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, str]: + """The method and URL of a session request, given by position or by keyword.""" + method = kwargs["method"] if "method" in kwargs else args[0] + url = kwargs["url"] if "url" in kwargs else args[1] + return str(method).upper(), str(url) + + +class _Session: + """A synchronous client's HTTP session, through a tape.""" + + def __init__(self, tape: Tape, real: Any) -> None: + self._tape = tape + self._real = real + + def _start(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[str, str]: + """Fire the hooks a request is due, and return its method and URL.""" + method, url = _method_and_url(args, kwargs) + self._tape._fire(method, kwargs.get("data") or kwargs.get("content")) + return method, url + + def request(self, *args: Any, **kwargs: Any) -> Any: + method, url = self._start(args, kwargs) + if not self._tape.recording: + return self._tape._play(method, url) + response = self._real.request(*args, **kwargs) + self._tape._keep(method, url, response) + return response + + def close(self) -> None: + if self._tape.recording: + self._real.close() + + +class _AsyncSession(_Session): + """An asynchronous client's HTTP session, through a tape.""" + + async def request(self, *args: Any, **kwargs: Any) -> Any: # type: ignore[override] + method, url = self._start(args, kwargs) + if not self._tape.recording: + return self._tape._play(method, url) + response = await self._real.request(*args, **kwargs) + self._tape._keep(method, url, response) + return response + + async def close(self) -> None: # type: ignore[override] + await self._close("close") + + async def aclose(self) -> None: + """Close the session, as an httpx client does.""" + await self._close("aclose") + + async def _close(self, name: str) -> None: + if self._tape.recording: + await getattr(self._real, name)() diff --git a/src/calendaring_sync/tests/benchmark.py b/src/calendaring_sync/tests/benchmark.py index 71031fe..0678ff0 100644 --- a/src/calendaring_sync/tests/benchmark.py +++ b/src/calendaring_sync/tests/benchmark.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """ -Benchmarks for the work a sync does most: full listings, local edits, pushes, and reads during them. +Benchmarks for the work a sync does most: full listings, local edits, pushes, reads during them, +and an adapter's fingerprints. Run ``python -m calendaring_sync.tests.benchmark`` on ``main`` and on your branch, on the same machine, and compare the two. Times differ between @@ -37,6 +38,7 @@ Session, SQLiteStore, ) +from calendaring_sync.adapters.caldav import _server_fields #: The number of objects in the scope: a large calendar. DEFAULT_SIZE = 20_000 @@ -181,6 +183,23 @@ def read() -> float: return reader.result() +def caldav_fingerprints(size: int) -> float: + """The CalDAV adapter's fingerprints of ``size`` events, as a full listing computes them.""" + events = [ + ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//benchmark//EN\r\nBEGIN:VEVENT\r\n" + f"UID:event-{i}\r\nDTSTAMP:20261001T000000Z\r\nDTSTART:20261101T100000Z\r\n" + "RRULE:FREQ=WEEKLY;COUNT=10\r\nSUMMARY:Weekly planning\r\n" + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + ).encode() + for i in range(size) + ] + started = time.perf_counter() + for event in events: + _server_fields(event) + return time.perf_counter() - started + + def states_over_edits(size: int) -> Counter[RecordState]: """How many records end in each state in ``listing_over_edits``, to check what it measures.""" with _replica() as replica: @@ -204,6 +223,7 @@ def entries_after_pushes(size: int) -> Counter[FeedEntryKind]: f"single edits to one in {EDITED_SHARE} objects": single_edits, f"pushes of one in {EDITED_SHARE} objects": pushes, "longest read during a full listing": read_during_listing, + "CalDAV fingerprints of every event": caldav_fingerprints, } diff --git a/src/calendaring_sync/tests/conftest.py b/src/calendaring_sync/tests/conftest.py new file mode 100644 index 0000000..e9b9484 --- /dev/null +++ b/src/calendaring_sync/tests/conftest.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""What every test file runs on: both stores, and the sync and async adapters.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from pathlib import Path + +import pytest + +from calendaring_sync import MemoryStore, SQLiteStore, StateStore + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + """Where a test's SQLite store keeps its file.""" + return tmp_path / "s.db" + + +@pytest.fixture(params=[MemoryStore, SQLiteStore], ids=lambda cls: cls.__name__) +def make_store(request: pytest.FixtureRequest, db_path: Path) -> Iterator[Callable[[], StateStore]]: + """Open handles on one store, as separate processes would: one shared + ``MemoryStore``, or a new connection to one SQLite file each time.""" + opened: list[StateStore] = [] + memory = MemoryStore() + + def make() -> StateStore: + store: StateStore = memory if request.param is MemoryStore else SQLiteStore(db_path) + opened.append(store) + return store + + yield make + for store in opened: + store.close() + + +@pytest.fixture +def store(make_store: Callable[[], StateStore]) -> StateStore: + return make_store() + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def asynchronous(request: pytest.FixtureRequest) -> bool: + """Whether a test uses an adapter's asynchronous variant.""" + return bool(request.param) diff --git a/src/calendaring_sync/tests/data/caldav/baikal-delete_gone.json b/src/calendaring_sync/tests/data/caldav/baikal-delete_gone.json new file mode 100644 index 0000000..79593bc --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/baikal-delete_gone.json @@ -0,0 +1,141 @@ +{ + "server": "baikal", + "scenario": "delete_gone", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 401, + "headers": { + "content-type": "application/xml; charset=utf-8", + "www-authenticate": "Basic realm=\"sabre/dav\", charset=\"UTF-8\"" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\NotAuthenticated\n No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured. Login was needed for privilege: {DAV:}unbind on calendars/test\n\n" + }, + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 404, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\NotFound\n Node with name 'calendaring-sync-delete-gone' could not be found\n\n" + }, + { + "method": "MKCALENDAR", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 201, + "headers": { + "content-type": "text/html; charset=UTF-8" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/a.ics", + "status": 201, + "headers": { + "content-type": "text/html; charset=UTF-8", + "etag": "\"e0b51f1694a2d34e2dbe74279f3c1128\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 401, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook", + "www-authenticate": "Basic realm=\"sabre/dav\", charset=\"UTF-8\"" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\NotAuthenticated\n No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured. Login was needed for privilege: {DAV:}read on calendars/test/calendaring-sync-delete-gone\n\n" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-delete-gone/http://sabre.io/ns/sync/2HTTP/1.1 200 OK\n" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-delete-gone/HTTP/1.1 404 Not Found/dav.php/calendars/test/calendaring-sync-delete-gone/a.ics"e0b51f1694a2d34e2dbe74279f3c1128"HTTP/1.1 200 OK\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-delete-gone/a.icsBEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//calendaring-sync tests//EN\nBEGIN:VEVENT\nUID:delete_gone-a\nDTSTAMP:20261001T000000Z\nDTSTART:20261101T100000Z\nSUMMARY:one\nEND:VEVENT\nEND:VCALENDAR\n"e0b51f1694a2d34e2dbe74279f3c1128"HTTP/1.1 200 OKHTTP/1.1 404 Not Found\n" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-delete-gone/HTTP/1.1 200 OK\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n http://sabre.io/ns/sync/2\n\n" + }, + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/a.ics", + "status": 204, + "headers": { + "content-type": "text/html; charset=UTF-8" + }, + "body": "" + }, + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/a.ics", + "status": 412, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\PreconditionFailed\n An If-Match header was specified and the resource did not exist\n If-Match\n\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav.php/calendars/test/calendaring-sync-delete-gone/a.ics\n HTTP/1.1 404 Not Found\n \n http://sabre.io/ns/sync/3\n\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/baikal-fetch_after_delete.json b/src/calendaring_sync/tests/data/caldav/baikal-fetch_after_delete.json new file mode 100644 index 0000000..fd947d4 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/baikal-fetch_after_delete.json @@ -0,0 +1,151 @@ +{ + "server": "baikal", + "scenario": "fetch_after_delete", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 401, + "headers": { + "content-type": "application/xml; charset=utf-8", + "www-authenticate": "Basic realm=\"sabre/dav\", charset=\"UTF-8\"" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\NotAuthenticated\n No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured. Login was needed for privilege: {DAV:}unbind on calendars/test\n\n" + }, + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 404, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\NotFound\n Node with name 'calendaring-sync-fetch-after-delete' could not be found\n\n" + }, + { + "method": "MKCALENDAR", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 201, + "headers": { + "content-type": "text/html; charset=UTF-8" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.ics", + "status": 201, + "headers": { + "content-type": "text/html; charset=UTF-8", + "etag": "\"8e7de0480c42c5e9a8e063d1f03cfb1e\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 401, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook", + "www-authenticate": "Basic realm=\"sabre/dav\", charset=\"UTF-8\"" + }, + "body": "\n\n 4.7.0\n Sabre\\DAV\\Exception\\NotAuthenticated\n No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured. Login was needed for privilege: {DAV:}read on calendars/test/calendaring-sync-fetch-after-delete\n\n" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-fetch-after-delete/http://sabre.io/ns/sync/2HTTP/1.1 200 OK\n" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-fetch-after-delete/HTTP/1.1 404 Not Found/dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.ics"8e7de0480c42c5e9a8e063d1f03cfb1e"HTTP/1.1 200 OK\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.icsBEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//calendaring-sync tests//EN\nBEGIN:VEVENT\nUID:fetch_after_delete-a\nDTSTAMP:20261001T000000Z\nDTSTART:20261101T100000Z\nSUMMARY:one\nEND:VEVENT\nEND:VCALENDAR\n"8e7de0480c42c5e9a8e063d1f03cfb1e"HTTP/1.1 200 OKHTTP/1.1 404 Not Found\n" + }, + { + "method": "PUT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.ics", + "status": 204, + "headers": { + "content-type": "text/html; charset=UTF-8", + "etag": "\"26214ec531777096353d5eade398318e\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8", + "dav": "1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, resource-sharing, calendarserver-sharing, addressbook" + }, + "body": "\n/dav.php/calendars/test/calendaring-sync-fetch-after-delete/HTTP/1.1 200 OK\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.ics\n \n \n "26214ec531777096353d5eade398318e"\n \n HTTP/1.1 200 OK\n \n \n http://sabre.io/ns/sync/3\n\n" + }, + { + "method": "DELETE", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.ics", + "status": 204, + "headers": { + "content-type": "text/html; charset=UTF-8" + }, + "body": "" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav.php/calendars/test/calendaring-sync-fetch-after-delete/a.ics\n HTTP/1.1 404 Not Found\n \n http://sabre.io/ns/sync/4\n\n" + }, + { + "method": "REPORT", + "path": "/dav.php/calendars/test/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/cyrus-invalid_token.json b/src/calendaring_sync/tests/data/caldav/cyrus-invalid_token.json new file mode 100644 index 0000000..1861b10 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/cyrus-invalid_token.json @@ -0,0 +1,123 @@ +{ + "server": "cyrus", + "scenario": "invalid_token", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 401, + "headers": { + "www-authenticate": "Basic realm=\"test\"", + "content-type": "text/html; charset=utf-8" + }, + "body": "\n\n \n 401 Unauthorized\n \n \n

Unauthorized

\n

Must authenticate to access the specified target

\n
\n
Cyrus-HTTP/3.13.7-545-gc04cece19 Cyrus-SASL/2.1.28 LibXML/2.9.14 Jansson/2.14 OpenSSL/3.0.20 Nghttp2/1.52.0 Wslay/1.0.1-DEV Zlib/1.2.13 Xapian/2.0.0 LibiCal/4.0 ICU4C/74.2 SQLite/3.40.1 Server at localhost Port 8802
\n \n\n" + }, + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 204, + "headers": {}, + "body": "" + }, + { + "method": "MKCALENDAR", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/a.ics", + "status": 201, + "headers": { + "etag": "\"aa7c43ef518a3753576b84513f6972d3e4c8b3cb\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 401, + "headers": { + "www-authenticate": "Basic realm=\"test\"", + "content-type": "text/html; charset=utf-8" + }, + "body": "\n\n \n 401 Unauthorized\n \n \n

Unauthorized

\n

Must authenticate to access the specified target

\n
\n
Cyrus-HTTP/3.13.7-545-gc04cece19 Cyrus-SASL/2.1.28 LibXML/2.9.14 Jansson/2.14 OpenSSL/3.0.20 Nghttp2/1.52.0 Wslay/1.0.1-DEV Zlib/1.2.13 Xapian/2.0.0 LibiCal/4.0 ICU4C/74.2 SQLite/3.40.1 Server at localhost Port 8802
\n \n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/\n \n \n data:,1792757603-86\n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/\n \n \n \"1792757603-1-1\"\n \n HTTP/1.1 200 OK\n \n \n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/a.ics\n \n \n \"aa7c43ef518a3753576b84513f6972d3e4c8b3cb\"\n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/a.ics\n \n \n \n \"aa7c43ef518a3753576b84513f6972d3e4c8b3cb\"\n \n HTTP/1.1 200 OK\n \n \n \n \n \n HTTP/1.1 404 Not Found\n \n \n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 403, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n Invalid sync-token\n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/\n \n \n data:,1792757603-86\n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/\n \n \n \"1792757603-1-1\"\n \n HTTP/1.1 200 OK\n \n \n \n /dav/calendars/user/user1/calendaring-sync-invalid-token/a.ics\n \n \n \"aa7c43ef518a3753576b84513f6972d3e4c8b3cb\"\n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-invalid-token/a.ics", + "status": 204, + "headers": { + "etag": "\"25f6d8b37384da45efe193a8ec1a8b8ca9da76b0\"" + }, + "body": "" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/cyrus-reading.json b/src/calendaring_sync/tests/data/caldav/cyrus-reading.json new file mode 100644 index 0000000..7f8ba89 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/cyrus-reading.json @@ -0,0 +1,150 @@ +{ + "server": "cyrus", + "scenario": "reading", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 401, + "headers": { + "www-authenticate": "Basic realm=\"test\"", + "content-type": "text/html; charset=utf-8" + }, + "body": "\n\n \n 401 Unauthorized\n \n \n

Unauthorized

\n

Must authenticate to access the specified target

\n
\n
Cyrus-HTTP/3.13.7-545-gc04cece19 Cyrus-SASL/2.1.28 LibXML/2.9.14 Jansson/2.14 OpenSSL/3.0.20 Nghttp2/1.52.0 Wslay/1.0.1-DEV Zlib/1.2.13 Xapian/2.0.0 LibiCal/4.0 ICU4C/74.2 SQLite/3.40.1 Server at localhost Port 8802
\n \n\n" + }, + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 404, + "headers": { + "content-type": "text/html; charset=utf-8" + }, + "body": "\n\n \n 404 Not Found\n \n \n

Not Found

\n

Mailbox does not exist

\n
\n
Cyrus-HTTP/3.13.7-545-gc04cece19 Cyrus-SASL/2.1.28 LibXML/2.9.14 Jansson/2.14 OpenSSL/3.0.20 Nghttp2/1.52.0 Wslay/1.0.1-DEV Zlib/1.2.13 Xapian/2.0.0 LibiCal/4.0 ICU4C/74.2 SQLite/3.40.1 Server at localhost Port 8802
\n \n\n" + }, + { + "method": "MKCALENDAR", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 401, + "headers": { + "www-authenticate": "Basic realm=\"test\"", + "content-type": "text/html; charset=utf-8" + }, + "body": "\n\n \n 401 Unauthorized\n \n \n

Unauthorized

\n

Must authenticate to access the specified target

\n
\n
Cyrus-HTTP/3.13.7-545-gc04cece19 Cyrus-SASL/2.1.28 LibXML/2.9.14 Jansson/2.14 OpenSSL/3.0.20 Nghttp2/1.52.0 Wslay/1.0.1-DEV Zlib/1.2.13 Xapian/2.0.0 LibiCal/4.0 ICU4C/74.2 SQLite/3.40.1 Server at localhost Port 8802
\n \n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/\n \n \n data:,1792850737-240\n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/\n \n \n \"1792850737-0-0\"\n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/a.ics", + "status": 201, + "headers": { + "etag": "\"4642658b34e5fb0e893158b20b1fefdaab16f4f2\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/b.ics", + "status": 201, + "headers": { + "etag": "\"38a5affdf932bb77789d8b17d879f2601ce5e6f6\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n HTTP/1.1 200 OK\n \n \n\n" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/a.ics\n \n \n \"4642658b34e5fb0e893158b20b1fefdaab16f4f2\"\n \n HTTP/1.1 200 OK\n \n \n \n /dav/calendars/user/user1/calendaring-sync-reading/b.ics\n \n \n \"38a5affdf932bb77789d8b17d879f2601ce5e6f6\"\n \n HTTP/1.1 200 OK\n \n \n data:,1792850737-244\n\n" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/a.ics\n \n \n \n \"4642658b34e5fb0e893158b20b1fefdaab16f4f2\"\n \n HTTP/1.1 200 OK\n \n \n \n \n \n HTTP/1.1 404 Not Found\n \n \n \n /dav/calendars/user/user1/calendaring-sync-reading/b.ics\n \n \n \n \"38a5affdf932bb77789d8b17d879f2601ce5e6f6\"\n \n HTTP/1.1 200 OK\n \n \n \n \n \n HTTP/1.1 404 Not Found\n \n \n\n" + }, + { + "method": "PUT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/a.ics", + "status": 204, + "headers": { + "etag": "\"14839729501215cb3fbbd7c10536bc8e6d6bb051\"" + }, + "body": "" + }, + { + "method": "DELETE", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/b.ics", + "status": 204, + "headers": {}, + "body": "" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/a.ics\n \n \n \"14839729501215cb3fbbd7c10536bc8e6d6bb051\"\n \n HTTP/1.1 200 OK\n \n \n \n /dav/calendars/user/user1/calendaring-sync-reading/b.ics\n HTTP/1.1 404 Not Found\n \n data:,1792850737-247\n\n" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/a.ics\n \n \n \n \"14839729501215cb3fbbd7c10536bc8e6d6bb051\"\n \n HTTP/1.1 200 OK\n \n \n \n \n \n HTTP/1.1 404 Not Found\n \n \n\n" + }, + { + "method": "REPORT", + "path": "/dav/calendars/user/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "\n\n \n /dav/calendars/user/user1/calendaring-sync-reading/b.ics\n HTTP/1.1 404 Not Found\n \n\n" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-delete_gone.json b/src/calendaring_sync/tests/data/caldav/radicale-delete_gone.json new file mode 100644 index 0000000..1d1e758 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-delete_gone.json @@ -0,0 +1,117 @@ +{ + "server": "radicale", + "scenario": "delete_gone", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-delete-gone/a.ics", + "status": 201, + "headers": { + "etag": "\"5beb3ef3e3360455c9dc3d7448cd595f4538f70a2fd73d51aba288a5e773f15a\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-delete-gone/http://radicale.org/ns/sync/1fc1885bc650488f2120c90f10951d9cba4f0a70931db3f1651980a0cd6b498dHTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-delete-gone/\"2369d52d050472fb1cec4968d9a196b6f9022418127e44fe371d8c14982d85bd\"HTTP/1.1 200 OK/user1/calendaring-sync-delete-gone/a.ics\"5beb3ef3e3360455c9dc3d7448cd595f4538f70a2fd73d51aba288a5e773f15a\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-delete-gone/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:delete_gone-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"5beb3ef3e3360455c9dc3d7448cd595f4538f70a2fd73d51aba288a5e773f15a\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-delete-gone/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/1fc1885bc650488f2120c90f10951d9cba4f0a70931db3f1651980a0cd6b498d" + }, + { + "method": "DELETE", + "path": "/user1/calendaring-sync-delete-gone/a.ics", + "status": 200, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-delete-gone/a.icsHTTP/1.1 200 OK" + }, + { + "method": "DELETE", + "path": "/user1/calendaring-sync-delete-gone/a.ics", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/a20f598aba69316e9c6e54df86abc6e7f644e70883eb17551e801567433e0928/user1/calendaring-sync-delete-gone/a.icsHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-delete-gone/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-delete-gone/a.icsHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-invalid_token.json b/src/calendaring_sync/tests/data/caldav/radicale-invalid_token.json new file mode 100644 index 0000000..129eb41 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-invalid_token.json @@ -0,0 +1,110 @@ +{ + "server": "radicale", + "scenario": "invalid_token", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-invalid-token/a.ics", + "status": 201, + "headers": { + "etag": "\"6de1bf4ac30390efd4989182970aa93552bfe61bd6821edf59e530d38c99d367\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-invalid-token/http://radicale.org/ns/sync/8f1eb1c06525f3b27ba1a33d7928b220bf3497bcd01c47fc36a833074f4e6c0fHTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-invalid-token/\"a3529d66ede96b85c887c3373895f4ccb052249556e1876d2d46b24a9b10c292\"HTTP/1.1 200 OK/user1/calendaring-sync-invalid-token/a.ics\"6de1bf4ac30390efd4989182970aa93552bfe61bd6821edf59e530d38c99d367\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-invalid-token/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:invalid_token-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"6de1bf4ac30390efd4989182970aa93552bfe61bd6821edf59e530d38c99d367\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-invalid-token/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 403, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-invalid-token/http://radicale.org/ns/sync/8f1eb1c06525f3b27ba1a33d7928b220bf3497bcd01c47fc36a833074f4e6c0fHTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-invalid-token/\"a3529d66ede96b85c887c3373895f4ccb052249556e1876d2d46b24a9b10c292\"HTTP/1.1 200 OK/user1/calendaring-sync-invalid-token/a.ics\"6de1bf4ac30390efd4989182970aa93552bfe61bd6821edf59e530d38c99d367\"HTTP/1.1 200 OK" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-invalid-token/a.ics", + "status": 204, + "headers": { + "etag": "\"7e5d43aa34701ffc7ef542f54ccc69e44f2db58619e6098e5f6aeeb7c2a27d6d\"" + }, + "body": "" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-listing.json b/src/calendaring_sync/tests/data/caldav/radicale-listing.json new file mode 100644 index 0000000..6c29477 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-listing.json @@ -0,0 +1,109 @@ +{ + "server": "radicale", + "scenario": "listing", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-listing/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-listing/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-listing/a.ics", + "status": 201, + "headers": { + "etag": "\"a295381d60e0973131279650745d34b8c27d6d2691f0cfe82cbbefdd9c17f63a\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-listing/b.ics", + "status": 201, + "headers": { + "etag": "\"087a0833e797b6d6ed71920f528c8c881be27e73a89a432753435fe9e56f97c1\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-listing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-listing/http://radicale.org/ns/sync/f938f41a2e7c9d21614feaa6b6277fa3c0c55fcfc9338aa3a48a30b968dd662bHTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-listing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-listing/\"b515b1b17439657f494c84f15923b5c739b4ab31710b083eda7a527b78986c1e\"HTTP/1.1 200 OK/user1/calendaring-sync-listing/a.ics\"a295381d60e0973131279650745d34b8c27d6d2691f0cfe82cbbefdd9c17f63a\"HTTP/1.1 200 OK/user1/calendaring-sync-listing/b.ics\"087a0833e797b6d6ed71920f528c8c881be27e73a89a432753435fe9e56f97c1\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-listing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-listing/b.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:listing-b\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"087a0833e797b6d6ed71920f528c8c881be27e73a89a432753435fe9e56f97c1\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found/user1/calendaring-sync-listing/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:listing-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"a295381d60e0973131279650745d34b8c27d6d2691f0cfe82cbbefdd9c17f63a\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-listing/b.ics", + "status": 204, + "headers": { + "etag": "\"dedeef986c620a807292f6c8c9b36111f02d2fe89a1879df2d45ed71ca6ee31b\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-listing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-listing/http://radicale.org/ns/sync/d02a2a74f07036762fd7fa9ec75d60563d962666e9498d43d98ee859acd05848HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-listing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-listing/\"244aaebc733bb2191aa689230fe0c0ed489fd0a37a90eae2edfed4bc0cfe7a75\"HTTP/1.1 200 OK/user1/calendaring-sync-listing/a.ics\"a295381d60e0973131279650745d34b8c27d6d2691f0cfe82cbbefdd9c17f63a\"HTTP/1.1 200 OK/user1/calendaring-sync-listing/b.ics\"dedeef986c620a807292f6c8c9b36111f02d2fe89a1879df2d45ed71ca6ee31b\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-listing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-listing/b.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:listing-b\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:two\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"dedeef986c620a807292f6c8c9b36111f02d2fe89a1879df2d45ed71ca6ee31b\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-pushing.json b/src/calendaring_sync/tests/data/caldav/radicale-pushing.json new file mode 100644 index 0000000..9cb3436 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-pushing.json @@ -0,0 +1,171 @@ +{ + "server": "radicale", + "scenario": "pushing", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-pushing/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-pushing/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-pushing/http://radicale.org/ns/sync/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-pushing/\"ca5d66c59a8e12e6801df61f5601aaa0d0380afbd1f8c3623e713eb9b5d486ed\"HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-pushing/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.ics", + "status": 201, + "headers": { + "etag": "\"f5979321c4c9be5ab3e26b5ca799c8b4e90ae5d9f01fdc85cd023908d74061b0\"" + }, + "body": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/28dd7fec63877151bc5d4d3bacb842230e07863e1dd5e1d46eca46c7a08f33ab/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.ics\"f5979321c4c9be5ab3e26b5ca799c8b4e90ae5d9f01fdc85cd023908d74061b0\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:pushing-c\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:made here\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"f5979321c4c9be5ab3e26b5ca799c8b4e90ae5d9f01fdc85cd023908d74061b0\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/28dd7fec63877151bc5d4d3bacb842230e07863e1dd5e1d46eca46c7a08f33ab" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.ics", + "status": 204, + "headers": { + "etag": "\"8859ac8a34953602a12c0798c54fcdb79ef32970bf78dd5a704ea9ce7626a7a3\"" + }, + "body": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/bd58deaf498e3cd701f62ef7a718658faf83d0351e7f78f1a08410f1a979fa45/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.ics\"8859ac8a34953602a12c0798c54fcdb79ef32970bf78dd5a704ea9ce7626a7a3\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:pushing-c\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:changed here\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"8859ac8a34953602a12c0798c54fcdb79ef32970bf78dd5a704ea9ce7626a7a3\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-pushing/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/bd58deaf498e3cd701f62ef7a718658faf83d0351e7f78f1a08410f1a979fa45" + }, + { + "method": "DELETE", + "path": "/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.ics", + "status": 200, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-pushing/00000000-0000-0000-0000-000000000001.icsHTTP/1.1 200 OK" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-reading.json b/src/calendaring_sync/tests/data/caldav/radicale-reading.json new file mode 100644 index 0000000..dba7ae4 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-reading.json @@ -0,0 +1,135 @@ +{ + "server": "radicale", + "scenario": "reading", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-reading/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-reading/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/http://radicale.org/ns/sync/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/\"ca5d66c59a8e12e6801df61f5601aaa0d0380afbd1f8c3623e713eb9b5d486ed\"HTTP/1.1 200 OK" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-reading/a.ics", + "status": 201, + "headers": { + "etag": "\"c7b00d29b7dd7eb1ebe5cb4fd5173e4ec2d27f47432899f908c3465c329e14a0\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-reading/b.ics", + "status": 201, + "headers": { + "etag": "\"ff2ec33c746d9e2024d7d70f46479ec3048d4aca659bbabcb06f0d39cf15df0b\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/9220783cdc401da96c55398cc5ba8da1b848060f8235f3d03897552c6c61fe5f/user1/calendaring-sync-reading/a.ics\"c7b00d29b7dd7eb1ebe5cb4fd5173e4ec2d27f47432899f908c3465c329e14a0\"HTTP/1.1 200 OK/user1/calendaring-sync-reading/b.ics\"ff2ec33c746d9e2024d7d70f46479ec3048d4aca659bbabcb06f0d39cf15df0b\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/b.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:reading-b\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:first b\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"ff2ec33c746d9e2024d7d70f46479ec3048d4aca659bbabcb06f0d39cf15df0b\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found/user1/calendaring-sync-reading/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:reading-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:first a\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"c7b00d29b7dd7eb1ebe5cb4fd5173e4ec2d27f47432899f908c3465c329e14a0\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-reading/a.ics", + "status": 204, + "headers": { + "etag": "\"34f0a8537f67c6417d25afb9a8fe22f9bf3b0d686938190576ebdf03ff9ae10a\"" + }, + "body": "" + }, + { + "method": "DELETE", + "path": "/user1/calendaring-sync-reading/b.ics", + "status": 200, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/b.icsHTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/4af0bf56545d2b26efbc05e27e9b866e70c2a4544e1a8ed5769b8d2c86eaf527/user1/calendaring-sync-reading/b.icsHTTP/1.1 404 Not Found/user1/calendaring-sync-reading/a.ics\"34f0a8537f67c6417d25afb9a8fe22f9bf3b0d686938190576ebdf03ff9ae10a\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:reading-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:second a\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"34f0a8537f67c6417d25afb9a8fe22f9bf3b0d686938190576ebdf03ff9ae10a\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-reading/b.icsHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-rejected.json b/src/calendaring_sync/tests/data/caldav/radicale-rejected.json new file mode 100644 index 0000000..2be7504 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-rejected.json @@ -0,0 +1,117 @@ +{ + "server": "radicale", + "scenario": "rejected", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-rejected/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-rejected/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-rejected/a.ics", + "status": 201, + "headers": { + "etag": "\"701ea4993729ecb40f7c7494910c93dc1e7c70957238c6bde7c5e86a723c9fef\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-rejected/http://radicale.org/ns/sync/5e16d13e447bb8ec8fc76d319d071e050829cff46601111cf8013c5874ecdbdfHTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-rejected/\"0afa4549fe14fafac03961fb8f51ef541a97d245d85b2029933caf5f14a3fe15\"HTTP/1.1 200 OK/user1/calendaring-sync-rejected/a.ics\"701ea4993729ecb40f7c7494910c93dc1e7c70957238c6bde7c5e86a723c9fef\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-rejected/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:rejected-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"701ea4993729ecb40f7c7494910c93dc1e7c70957238c6bde7c5e86a723c9fef\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-rejected/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/5e16d13e447bb8ec8fc76d319d071e050829cff46601111cf8013c5874ecdbdf" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-rejected/a.ics", + "status": 204, + "headers": { + "etag": "\"48fdd177774ce65339ce0a9c670271ad51919e88cec0a304bfafc238003dee01\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-rejected/a.ics", + "status": 412, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "Precondition failed." + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/f3518403f8f8477edf6ac8673dc0c089cf3a87530a14419873b7588a110b380e/user1/calendaring-sync-rejected/a.ics\"48fdd177774ce65339ce0a9c670271ad51919e88cec0a304bfafc238003dee01\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-rejected/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-rejected/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:rejected-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:theirs\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"48fdd177774ce65339ce0a9c670271ad51919e88cec0a304bfafc238003dee01\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-scope_gone.json b/src/calendaring_sync/tests/data/caldav/radicale-scope_gone.json new file mode 100644 index 0000000..f7b4886 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-scope_gone.json @@ -0,0 +1,80 @@ +{ + "server": "radicale", + "scenario": "scope_gone", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-scope-gone/a.ics", + "status": 201, + "headers": { + "etag": "\"16a298802092c852a2febebb9948c58da0ac4760cebc87bcd5c5456b66d70473\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-scope-gone/http://radicale.org/ns/sync/6eb2a686d43535dcf78e4fa31190e58bd501c3161c1aac27f1278a84a113b119HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-scope-gone/\"6554d62c9d57d519ad5f85d9f49d18cacec11b1da51321e928599cb1f697f0ee\"HTTP/1.1 200 OK/user1/calendaring-sync-scope-gone/a.ics\"16a298802092c852a2febebb9948c58da0ac4760cebc87bcd5c5456b66d70473\"HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-scope-gone/a.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:scope_gone-a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:one\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"16a298802092c852a2febebb9948c58da0ac4760cebc87bcd5c5456b66d70473\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "DELETE", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 200, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-scope-gone/HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-scope-gone/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/radicale-uid_taken.json b/src/calendaring_sync/tests/data/caldav/radicale-uid_taken.json new file mode 100644 index 0000000..85855fe --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/radicale-uid_taken.json @@ -0,0 +1,99 @@ +{ + "server": "radicale", + "scenario": "uid_taken", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 404, + "headers": { + "content-type": "text/plain; charset=utf-8" + }, + "body": "The requested resource could not be found." + }, + { + "method": "MKCALENDAR", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 201, + "headers": {}, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-uid-taken/http://radicale.org/ns/sync/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-uid-taken/\"ca5d66c59a8e12e6801df61f5601aaa0d0380afbd1f8c3623e713eb9b5d486ed\"HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "dav": "1, 2, 3, calendar-access, addressbook, extended-mkcol", + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-uid-taken/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\nhttp://radicale.org/ns/sync/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "method": "PUT", + "path": "/user1/calendaring-sync-uid-taken/dup.ics", + "status": 201, + "headers": { + "etag": "\"a2456f3738fce12471ebea271a181d6e2d8b9b4ab4edfba10f3033ccd1980d53\"" + }, + "body": "" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n/user1/calendaring-sync-uid-taken/dup.icsBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nUID:uid_taken-dup\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:theirs\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"a2456f3738fce12471ebea271a181d6e2d8b9b4ab4edfba10f3033ccd1980d53\"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n" + }, + { + "method": "REPORT", + "path": "/user1/calendaring-sync-uid-taken/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=utf-8" + }, + "body": "\n" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/stalwart-listing_cut_short.json b/src/calendaring_sync/tests/data/caldav/stalwart-listing_cut_short.json new file mode 100644 index 0000000..795e270 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/stalwart-listing_cut_short.json @@ -0,0 +1,90 @@ +{ + "server": "stalwart", + "scenario": "listing_cut_short", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/", + "status": 401, + "headers": { + "www-authenticate": "Bearer realm=\"Stalwart Server\", resource_metadata=\"/.well-known/oauth-protected-resource\", Basic realm=\"Stalwart Server\"", + "content-type": "application/problem+json" + }, + "body": "{\"type\":\"about:blank\",\"status\":401,\"title\":\"Unauthorized\",\"detail\":\"You have to authenticate first.\"}" + }, + { + "method": "DELETE", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/", + "status": 404, + "headers": {}, + "body": "" + }, + { + "method": "MKCALENDAR", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/", + "status": 201, + "headers": { + "content-type": "application/xml; charset=utf-8", + "etag": "\"3595052059\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/a.ics", + "status": 201, + "headers": { + "etag": "\"3657037624\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/b.ics", + "status": 201, + "headers": { + "etag": "\"1659598161\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/c.ics", + "status": 201, + "headers": { + "etag": "\"3821733481\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/", + "status": 401, + "headers": { + "www-authenticate": "Bearer realm=\"Stalwart Server\", resource_metadata=\"/.well-known/oauth-protected-resource\", Basic realm=\"Stalwart Server\"", + "content-type": "application/problem+json" + }, + "body": "{\"type\":\"about:blank\",\"status\":401,\"title\":\"Unauthorized\",\"detail\":\"You have to authenticate first.\"}" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/urn:stalwart:davsync:aHTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/"3595052059"HTTP/1.1 200 OK/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/a.ics"3657037624"HTTP/1.1 200 OK/dav/cal/testuser%40example.org/calendaring-sync-listing-cut-short/HTTP/1.1 507 Insufficient StorageThe number of matches exceeds the limit of 2" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/stalwart-reading.json b/src/calendaring_sync/tests/data/caldav/stalwart-reading.json new file mode 100644 index 0000000..c545a21 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/stalwart-reading.json @@ -0,0 +1,151 @@ +{ + "server": "stalwart", + "scenario": "reading", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 401, + "headers": { + "www-authenticate": "Bearer realm=\"Stalwart Server\", resource_metadata=\"/.well-known/oauth-protected-resource\", Basic realm=\"Stalwart Server\"", + "content-type": "application/problem+json" + }, + "body": "{\"type\":\"about:blank\",\"status\":401,\"title\":\"Unauthorized\",\"detail\":\"You have to authenticate first.\"}" + }, + { + "method": "DELETE", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 404, + "headers": {}, + "body": "" + }, + { + "method": "MKCALENDAR", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 201, + "headers": { + "content-type": "application/xml; charset=utf-8", + "etag": "\"1445525724\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 401, + "headers": { + "www-authenticate": "Bearer realm=\"Stalwart Server\", resource_metadata=\"/.well-known/oauth-protected-resource\", Basic realm=\"Stalwart Server\"", + "content-type": "application/problem+json" + }, + "body": "{\"type\":\"about:blank\",\"status\":401,\"title\":\"Unauthorized\",\"detail\":\"You have to authenticate first.\"}" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/urn:stalwart:davsync:60HTTP/1.1 200 OK" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/"1445525724"HTTP/1.1 200 OK" + }, + { + "method": "PUT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/a.ics", + "status": 201, + "headers": { + "etag": "\"2356614064\"" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/b.ics", + "status": 201, + "headers": { + "etag": "\"1009867766\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/b.ics"1009867766"HTTP/1.1 200 OK/dav/cal/testuser%40example.org/calendaring-sync-reading/a.ics"2356614064"HTTP/1.1 200 OKurn:stalwart:davsync:62" + }, + { + "method": "REPORT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/b.ics"1009867766"HTTP/1.1 200 OKHTTP/1.1 404 Not Found/dav/cal/testuser%40example.org/calendaring-sync-reading/a.ics"2356614064"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "PUT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/a.ics", + "status": 204, + "headers": { + "etag": "\"3173451711\"" + }, + "body": "" + }, + { + "method": "DELETE", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/b.ics", + "status": 204, + "headers": {}, + "body": "" + }, + { + "method": "REPORT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/b.icsHTTP/1.1 404 Not Found/dav/cal/testuser%40example.org/calendaring-sync-reading/a.ics"3173451711"HTTP/1.1 200 OKurn:stalwart:davsync:64" + }, + { + "method": "REPORT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/a.ics"3173451711"HTTP/1.1 200 OKHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/dav/cal/testuser%40example.org/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "application/xml; charset=utf-8" + }, + "body": "/dav/cal/testuser%40example.org/calendaring-sync-reading/b.icsHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/xandikos-fetch_after_delete.json b/src/calendaring_sync/tests/data/caldav/xandikos-fetch_after_delete.json new file mode 100644 index 0000000..cf2201b --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/xandikos-fetch_after_delete.json @@ -0,0 +1,126 @@ +{ + "server": "xandikos", + "scenario": "fetch_after_delete", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 404, + "headers": { + "content-type": "application/octet-stream" + }, + "body": "Path /sometestuser/calendars/calendaring-sync-fetch-after-delete/ not found." + }, + { + "method": "MKCALENDAR", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 201, + "headers": { + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.ics", + "status": 201, + "headers": { + "etag": "\"2e2a3c502e7d8c95ce68f1004d48f5d4c0c0970a\"", + "schedule-tag": "\"e20807bf560a55f4749f181f68e0358a18ef6c13b0987d0e9d94c99c7b7b62ce\"", + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/HTTP/1.1 200 OKa718789c62f661ed261ed0b989bbf248bccf2e9e" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/HTTP/1.1 200 OK\"a718789c62f661ed261ed0b989bbf248bccf2e9e\"/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.icsHTTP/1.1 200 OK\"2e2a3c502e7d8c95ce68f1004d48f5d4c0c0970a\"" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.icsHTTP/1.1 200 OKBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nSUMMARY:one\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nUID:fetch_after_delete-a\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"2e2a3c502e7d8c95ce68f1004d48f5d4c0c0970a\"\"e20807bf560a55f4749f181f68e0358a18ef6c13b0987d0e9d94c99c7b7b62ce\"" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.ics", + "status": 204, + "headers": { + "etag": "\"47b3020a32317518d658c03f4d9abc5808c512f6\"", + "schedule-tag": "\"3f58620bfc91eb3b02eef5e26d748bacaa48c7a34d8fc877cc40e700cb2388be\"" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.icsHTTP/1.1 200 OK\"47b3020a32317518d658c03f4d9abc5808c512f6\"c2192e7fc18d3a60a7a93023c2927d4237672752" + }, + { + "method": "DELETE", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.ics", + "status": 204, + "headers": {}, + "body": "" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.icsHTTP/1.1 404 Not Found" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.icsHTTP/1.1 404 Not Foundae4423fea231573dd53ab2567fdc26cd3f98a7ca" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-fetch-after-delete/a.icsHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/xandikos-invalid_token.json b/src/calendaring_sync/tests/data/caldav/xandikos-invalid_token.json new file mode 100644 index 0000000..276e20f --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/xandikos-invalid_token.json @@ -0,0 +1,110 @@ +{ + "server": "xandikos", + "scenario": "invalid_token", + "caldav": "3.6.0", + "recorded": "2026-10-23", + "exchanges": [ + { + "method": "DELETE", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 404, + "headers": { + "content-type": "application/octet-stream" + }, + "body": "Path /sometestuser/calendars/calendaring-sync-invalid-token/ not found." + }, + { + "method": "MKCALENDAR", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 201, + "headers": { + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/a.ics", + "status": 201, + "headers": { + "etag": "\"b836871f52f52267eb79f426462187128e2f325e\"", + "schedule-tag": "\"62e98e85327646d5f78c9b30af62119ce3a8e8a361fe2ac41e37691c8c30422f\"", + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-invalid-token/HTTP/1.1 200 OK79228976f1302bf7b55eaa8d88060787fe12ef0c" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-invalid-token/HTTP/1.1 200 OK\"79228976f1302bf7b55eaa8d88060787fe12ef0c\"/sometestuser/calendars/calendaring-sync-invalid-token/a.icsHTTP/1.1 200 OK\"b836871f52f52267eb79f426462187128e2f325e\"" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-invalid-token/a.icsHTTP/1.1 200 OKBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nSUMMARY:one\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nUID:invalid_token-a\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"b836871f52f52267eb79f426462187128e2f325e\"\"62e98e85327646d5f78c9b30af62119ce3a8e8a361fe2ac41e37691c8c30422f\"" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-invalid-token/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 412, + "headers": { + "content-type": "text/xml; encoding=\"utf-8\"" + }, + "body": "Requested sync token 0000000000000000000000000000000000000000 is invalid" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-invalid-token/HTTP/1.1 200 OK79228976f1302bf7b55eaa8d88060787fe12ef0c" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-invalid-token/HTTP/1.1 200 OK\"79228976f1302bf7b55eaa8d88060787fe12ef0c\"/sometestuser/calendars/calendaring-sync-invalid-token/a.icsHTTP/1.1 200 OK\"b836871f52f52267eb79f426462187128e2f325e\"" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-invalid-token/a.ics", + "status": 204, + "headers": { + "etag": "\"c0cacd974014bf2b1ebfbb0004ffbd584891c34c\"", + "schedule-tag": "\"852bb7368b65716e1bd5a8cb63b399780396ab03a065f2468f9eb528e065f4b3\"" + }, + "body": "" + } + ] +} diff --git a/src/calendaring_sync/tests/data/caldav/xandikos-reading.json b/src/calendaring_sync/tests/data/caldav/xandikos-reading.json new file mode 100644 index 0000000..a913994 --- /dev/null +++ b/src/calendaring_sync/tests/data/caldav/xandikos-reading.json @@ -0,0 +1,137 @@ +{ + "server": "xandikos", + "scenario": "reading", + "caldav": "3.6.0", + "recorded": "2026-10-24", + "exchanges": [ + { + "method": "DELETE", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 404, + "headers": { + "content-type": "application/octet-stream" + }, + "body": "Path /sometestuser/calendars/calendaring-sync-reading/ not found." + }, + { + "method": "MKCALENDAR", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 201, + "headers": { + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/HTTP/1.1 200 OKae4423fea231573dd53ab2567fdc26cd3f98a7ca" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/HTTP/1.1 200 OK\"ae4423fea231573dd53ab2567fdc26cd3f98a7ca\"" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-reading/a.ics", + "status": 201, + "headers": { + "etag": "\"2181d06ae37d5ced47c4a2848921c124576b8d84\"", + "schedule-tag": "\"148d54be3302de9bb4178d131a83eef3430445545a20794feb94ef13359dcba4\"", + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-reading/b.ics", + "status": 201, + "headers": { + "etag": "\"c9850a9dbabe94e41334313677412cc0df949127\"", + "schedule-tag": "\"cc75f585ce4d54b039b9a47aed25dcf9cf4a4f6b5fec95ee8b40ea043c29c7f2\"", + "content-type": "application/octet-stream" + }, + "body": "" + }, + { + "method": "PROPFIND", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/HTTP/1.1 200 OK" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/a.icsHTTP/1.1 200 OK\"2181d06ae37d5ced47c4a2848921c124576b8d84\"/sometestuser/calendars/calendaring-sync-reading/b.icsHTTP/1.1 200 OK\"c9850a9dbabe94e41334313677412cc0df949127\"d8b00387b1c712c5111df0b0f964a2f4d20adc19" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/a.icsHTTP/1.1 200 OKBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nSUMMARY:first a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nUID:reading-a\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"2181d06ae37d5ced47c4a2848921c124576b8d84\"\"148d54be3302de9bb4178d131a83eef3430445545a20794feb94ef13359dcba4\"/sometestuser/calendars/calendaring-sync-reading/b.icsHTTP/1.1 200 OKBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nSUMMARY:first b\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nUID:reading-b\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"c9850a9dbabe94e41334313677412cc0df949127\"\"cc75f585ce4d54b039b9a47aed25dcf9cf4a4f6b5fec95ee8b40ea043c29c7f2\"" + }, + { + "method": "PUT", + "path": "/sometestuser/calendars/calendaring-sync-reading/a.ics", + "status": 204, + "headers": { + "etag": "\"557d64a0cad8e872d7d7f7790141e3ff3c80010e\"", + "schedule-tag": "\"68fba92ef600376a2db669c987c2724ac831bde563289609d3fe7e0ab17699d6\"" + }, + "body": "" + }, + { + "method": "DELETE", + "path": "/sometestuser/calendars/calendaring-sync-reading/b.ics", + "status": 204, + "headers": {}, + "body": "" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/a.icsHTTP/1.1 200 OK\"557d64a0cad8e872d7d7f7790141e3ff3c80010e\"/sometestuser/calendars/calendaring-sync-reading/b.icsHTTP/1.1 404 Not Found7919673520cd4f93afab7ab2b7b10dde9a4a9ad2" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/a.icsHTTP/1.1 200 OKBEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//calendaring-sync tests//EN\r\nBEGIN:VEVENT\r\nSUMMARY:second a\r\nDTSTART:20261101T100000Z\r\nDTSTAMP:20261001T000000Z\r\nUID:reading-a\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n\"557d64a0cad8e872d7d7f7790141e3ff3c80010e\"\"68fba92ef600376a2db669c987c2724ac831bde563289609d3fe7e0ab17699d6\"" + }, + { + "method": "REPORT", + "path": "/sometestuser/calendars/calendaring-sync-reading/", + "status": 207, + "headers": { + "content-type": "text/xml; charset=\"utf-8\"" + }, + "body": "/sometestuser/calendars/calendaring-sync-reading/b.icsHTTP/1.1 404 Not Found" + } + ] +} diff --git a/src/calendaring_sync/tests/record_caldav.py b/src/calendaring_sync/tests/record_caldav.py new file mode 100644 index 0000000..f2ab645 --- /dev/null +++ b/src/calendaring_sync/tests/record_caldav.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Record the CalDAV adapter's exchanges with real servers, for the unit tests to replay. + +Run ``python -m calendaring_sync.tests.record_caldav SERVER [SCENARIO...]``. +Radicale and Xandikos run embedded. Cyrus and Stalwart are calendaring-jmap's +Docker fixtures, and Baïkal is ``ckulka/baikal:nginx`` set up as the evidence +branch's ``servers/baikal_setup.py`` does; start them first, or give +``--url``, ``--username`` and ``--password``. Stalwart's ``listing_cut_short`` +needs its result limit lowered first, with the evidence branch's +``servers/stalwart_admin.py max-results 2``. + +Each scenario checks what it records, so a recording that doesn't show the +behaviour it's about fails instead of being saved. +""" + +from __future__ import annotations + +import argparse +import datetime +from contextlib import AbstractContextManager, nullcontext + +import caldav + +from calendaring_sync.tests._caldav_scenarios import RECORDINGS, SERVERS, Login, run, started +from calendaring_sync.tests._caldav_tape import Tape + + +def main() -> None: + """Record the scenarios named for one server, or all of its recordings.""" + parser = argparse.ArgumentParser(description="Record the CalDAV adapter's exchanges.") + parser.add_argument("server", choices=sorted(SERVERS)) + parser.add_argument("scenarios", nargs="*", help="scenarios to record; all by default") + parser.add_argument("--url", help="the server's base URL") + parser.add_argument("--username") + parser.add_argument("--password") + args = parser.parse_args() + wanted = args.scenarios or [s for server, s in RECORDINGS if server == args.server] + server = SERVERS[args.server] + running: AbstractContextManager[Login] = ( + nullcontext((args.url, args.username, args.password)) if args.url else started(server) + ) + with running as login: + for scenario in wanted: + tape = Tape() + run(server, scenario, tape, login=login) + tape.save( + f"{args.server}-{scenario}", + { + "server": args.server, + "scenario": scenario, + "caldav": caldav.__version__, + "recorded": datetime.date.today().isoformat(), + }, + ) + print(f"{args.server}-{scenario}: {len(tape.exchanges)} exchanges") + + +if __name__ == "__main__": + main() diff --git a/src/calendaring_sync/tests/test_caldav_unit.py b/src/calendaring_sync/tests/test_caldav_unit.py new file mode 100644 index 0000000..4bb4e78 --- /dev/null +++ b/src/calendaring_sync/tests/test_caldav_unit.py @@ -0,0 +1,1156 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +""" +Unit tests for the CalDAV adapter, on exchanges recorded from real servers. + +Every recording replays through the synchronous and the asynchronous +adapter. A rule no tested server shows on its own is tested by editing one +exchange of a real recording, in the test, so the edit is visible there. + +Rule: zero network calls, zero Docker dependency, all tests are fast. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib +import inspect +import re +import runpy +import sys +import threading +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager, nullcontext +from dataclasses import replace +from typing import Any, cast +from unittest.mock import patch + +import pytest +from caldav.collection import Calendar +from caldav.lib import error as dav_error + +from calendaring_sync import ( + ConflictKind, + RecordState, + ScopeGoneError, + StateStore, + UnknownRecordError, + UnknownScopeError, +) +from calendaring_sync.adapters import caldav as caldav_adapter +from calendaring_sync.adapters._flow import Steps, _arun, _InStore +from calendaring_sync.adapters.caldav import ( + FINGERPRINT_SCHEME, + MEDIA_TYPE, + AsyncCalDAVAdapter, + CalDAVAdapter, + _fields, + _server_fields, +) +from calendaring_sync.replica import Replica, Session +from calendaring_sync.tests import _caldav_scenarios as scenarios +from calendaring_sync.tests import record_caldav +from calendaring_sync.tests._caldav_scenarios import ( + RECORDINGS, + REPLAY_URL, + SCENARIOS, + SERVERS, + Env, + adapter_class, + caldav_client, + calendar_path, + create_c, + edit_a, + edit_a_changed_behind, + environment, + put_a_and_b, + run, + sync_a_and_b, +) +from calendaring_sync.tests._caldav_tape import Exchange, Tape, _Reply +from calendaring_sync.tests.test_sync_unit import _SCOPE, _WAIT, _assert_refused + +# An event with a time zone, a recurrence rule and parameters, for the +# fingerprint rules to work on. +_EVENT = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//one product//EN", + "BEGIN:VTIMEZONE", + "TZID:Europe/Berlin", + "BEGIN:STANDARD", + "DTSTART:19701025T030000", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "END:STANDARD", + "END:VTIMEZONE", + "BEGIN:VEVENT", + "UID:event-1", + "DTSTAMP:20261001T000000Z", + "DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20261101T100000", + "RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO", + "SUMMARY:Planning", + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org", + "END:VEVENT", + "END:VCALENDAR", +] + + +# Bytes that aren't UTF-8. +_NOT_UTF8 = b"\xff" + + +def _ical(lines: list[str], newline: str = "\r\n") -> bytes: + return "".join(line + newline for line in lines).encode() + + +def _swap(first: str, second: str) -> list[str]: + """The event with two of its lines swapped.""" + lines = list(_EVENT) + i, j = lines.index(first), lines.index(second) + lines[i], lines[j] = lines[j], lines[i] + return lines + + +def _with(old: str, *new: str) -> list[str]: + """The event with one of its lines replaced by ``new``.""" + lines = list(_EVENT) + i = lines.index(old) + return lines[:i] + list(new) + lines[i + 1 :] + + +def _text(body: str, element: str) -> str: + """What the first ``element`` of a recorded answer holds, such as its sync token.""" + return body.split(f"<{element}>")[1].split(f"")[0] + + +def _tape(name: str, keep: int | None = None) -> Tape: + """A recording to replay, cut after its first ``keep`` exchanges.""" + tape = Tape.load(name) + if keep is not None: + del tape.exchanges[keep:] + return tape + + +def _edit( + tape: Tape, + index: int, + *, + status: int | None = None, + old: str | None = None, + new: str = "", + body: str | None = None, +) -> None: + """Change one recorded answer: its status, its whole body, or ``old`` in it to ``new``.""" + exchange = tape.exchanges[index] + text = exchange.body if body is None else body + if old is not None: + assert old in text, f"{old!r} isn't in exchange {index}" + text = text.replace(old, new) + tape.exchanges[index] = replace( + exchange, status=exchange.status if status is None else status, body=text + ) + + +def _first_two_syncs(env: Env) -> None: + """The first two syncs of the ``reading`` scenario: an empty listing, then a and b.""" + env.sync() + sync_a_and_b(env) + + +@contextmanager +def _replaying(tape: Tape, store: StateStore, asynchronous: bool) -> Iterator[Env]: + """The environment of the recording ``tape`` replays, which must be played to the end.""" + server, scenario = cast(str, tape.name).split("-", 1) + with environment( + SERVERS[server], scenario, tape, store=store, asynchronous=asynchronous + ) as env: + yield env + assert tape.finished + + +class TestReplay: + @pytest.mark.parametrize( + "server, scenario", RECORDINGS, ids=[f"{s}-{n}" for s, n in RECORDINGS] + ) + def test_every_recording_replays(self, server, scenario, store, asynchronous): + tape = Tape.load(f"{server}-{scenario}") + run(SERVERS[server], scenario, tape, store=store, asynchronous=asynchronous) + assert tape.finished + + def test_every_scenario_is_recorded(self): + assert {scenario for _, scenario in RECORDINGS} == set(SCENARIOS) + + @pytest.mark.parametrize( + "keep, method, error", + [ + (None, "PUT", "Request 1: MKCALENDAR /user1/calendaring-sync-reading/, recorded PUT"), + (1, None, "Unrecorded request: MKCALENDAR /user1/calendaring-sync-reading/"), + ], + ) + def test_a_request_off_the_recording_fails(self, keep, method, error): + tape = _tape("radicale-reading", keep=keep) + if method is not None: + tape.exchanges[1] = replace(tape.exchanges[1], method=method) + with pytest.raises(AssertionError, match=error): + run(SERVERS["radicale"], "reading", tape) + + def test_a_request_that_must_never_go_out_fails(self, store): + tape = _tape("radicale-reading", keep=3) + with pytest.raises(AssertionError, match="^No listing$"): + with _replaying(tape, store, asynchronous=False) as env: + env.never("PROPFIND", "", "No listing") + env.sync() + + def test_a_recording_keeps_what_a_replay_needs(self, tmp_path, asynchronous): + answer = Exchange( + "PROPFIND", + "/cal/", + 207, + {"Content-Type": "text/xml", "Date": "today"}, + "", + ) + + class Server: + """A client's own HTTP session, answering every request the same.""" + + closed: list[str] = [] + + def request(self, *args: Any, **kwargs: Any) -> _Reply: + return _Reply(answer) + + def close(self) -> None: + self.closed.append("close") + + class AsyncServer(Server): + async def request(self, *args: Any, **kwargs: Any) -> _Reply: # type: ignore[override] + return _Reply(answer) + + async def close(self) -> None: # type: ignore[override] + self.closed.append("close") + + async def aclose(self) -> None: + self.closed.append("aclose") + + client = caldav_client(asynchronous, url=REPLAY_URL) + client.session = server = AsyncServer() if asynchronous else Server() + tape = Tape() + tape.attach(client, asynchronous=asynchronous) + through_tape: Any = client.session + with patch("calendaring_sync.tests._caldav_tape.RECORDINGS", tmp_path): + _done(client.propfind(f"{REPLAY_URL}/cal/")) + _done(client.close()) + if asynchronous: + asyncio.run(through_tape.aclose()) + tape.save("made", {"server": "test"}) + loaded = Tape.load("made") + assert server.closed == (["close", "aclose"] if asynchronous else ["close"]) + # Only the headers a replay reads are kept. + assert loaded.exchanges == [replace(answer, headers={"content-type": "text/xml"})] + + +def _close_mid_step(steps: Steps[None], held: threading.Event | None = None) -> None: + """Start the asynchronous driver on ``steps`` and close it at its first step. + + A store call that waits for ``held`` is still running when the driver + suspends on it, so the driver can't finish on its own before the close. + """ + + async def close() -> None: + driver = _arun(steps) + driver.send(None) + if held is not None: + held.set() + driver.close() + + asyncio.run(close()) + + +class TestDriver: + """The asynchronous driver, closed in the middle of a step.""" + + def test_closing_while_a_request_runs_closes_the_steps(self): + seen: list[str] = [] + + def steps() -> Steps[None]: + try: + yield asyncio.sleep(_WAIT) + except GeneratorExit: + seen.append("closed") + raise + + _close_mid_step(steps()) + assert seen == ["closed"] + + def test_closing_while_a_store_call_runs_hands_its_outcome_over(self): + seen: list[object] = [] + later = asyncio.sleep(_WAIT) + held = threading.Event() + + def returns(value: str) -> str: + held.wait(_WAIT) + return value + + def fails() -> None: + held.wait(_WAIT) + raise RuntimeError("the store call failed") + + def ends() -> Steps[None]: + seen.append((yield _InStore(returns, ("done",)))) + + def is_told() -> Steps[None]: + try: + yield _InStore(fails) + except RuntimeError as exc: + seen.append(str(exc)) + + def goes_on() -> Steps[None]: + seen.append((yield _InStore(returns, ("on",)))) + yield later + + for steps in (ends(), is_told(), goes_on()): + held.clear() + _close_mid_step(steps, held) + assert seen == ["done", "the store call failed", "on"] + # The request it yielded after the store call is closed, never left unawaited. + assert inspect.getcoroutinestate(later) == inspect.CORO_CLOSED + + +class TestFingerprint: + @pytest.mark.parametrize( + "lines, newline", + [ + pytest.param(_EVENT, "\n", id="LF line endings"), + pytest.param(_with("SUMMARY:Planning", "SUMMARY:Plan", " ning"), "\r\n", id="folded"), + pytest.param(_swap("SUMMARY:Planning", "UID:event-1"), "\r\n", id="properties moved"), + pytest.param( + _EVENT[:3] + _EVENT[11:19] + _EVENT[3:11] + _EVENT[19:], + "\r\n", + id="components moved", + ), + pytest.param( + _with( + "DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20261101T100000", + "DTSTART;VALUE=DATE-TIME;TZID=Europe/Berlin:20261101T100000", + ), + "\r\n", + id="parameters moved", + ), + pytest.param( + _with( + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org", + "attendee;cn=Ana;role=CHAIR:mailto:ana@example.org", + ), + "\r\n", + id="names in lower case", + ), + pytest.param( + _with("RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO", "RRULE:BYDAY=MO;FREQ=WEEKLY;COUNT=3"), + "\r\n", + id="recurrence rule parts moved", + ), + pytest.param( + _with("DTSTAMP:20261001T000000Z", "DTSTAMP:20261023T120000Z"), + "\r\n", + id="DTSTAMP", + ), + pytest.param( + _with("PRODID:-//one product//EN", "PRODID:-//another product//EN"), + "\r\n", + id="PRODID", + ), + pytest.param( + _with("UID:event-1", "UID:event-1", "LAST-MODIFIED:20261023T120000Z"), + "\r\n", + id="LAST-MODIFIED", + ), + ], + ) + def test_what_a_server_rewrites_keeps_the_fingerprint(self, lines, newline): + assert _fields(_ical(lines, newline)) == _fields(_ical(_EVENT)) + + @pytest.mark.parametrize( + "lines", + [ + pytest.param(_with("SUMMARY:Planning", "SUMMARY:Review"), id="value"), + pytest.param(_with("UID:event-1", "UID:event-2"), id="UID"), + pytest.param( + _with("RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO", "RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=MO"), + id="recurrence rule", + ), + pytest.param( + _with( + "ATTENDEE;CN=Ana;ROLE=CHAIR:mailto:ana@example.org", + "ATTENDEE;CN=Anna;ROLE=CHAIR:mailto:ana@example.org", + ), + id="parameter", + ), + pytest.param( + _with("SUMMARY:Planning", "SUMMARY:Planning", "LOCATION:Room 1"), id="property" + ), + pytest.param(_with("TZOFFSETTO:+0100", "TZOFFSETTO:+0000"), id="time zone"), + ], + ) + def test_an_edit_changes_the_fingerprint(self, lines): + assert _fields(_ical(lines)).fingerprint != _fields(_ical(_EVENT)).fingerprint + + @pytest.mark.parametrize( + "lines, logical_key", + [ + pytest.param(_EVENT, "event-1", id="after a time zone"), + pytest.param(_with("UID:event-1", r"UID:one\, two"), "one, two", id="escaped"), + pytest.param( + [line.replace("VEVENT", "VTODO") for line in _EVENT], "event-1", id="task" + ), + pytest.param(_with("UID:event-1"), None, id="none"), + pytest.param( + _with("TZID:Europe/Berlin", "TZID:Europe/Berlin", "UID:zone"), + "event-1", + id="not the time zone's", + ), + pytest.param( + _with( + "UID:event-1", + "BEGIN:VALARM", + "UID:alarm", + "ACTION:DISPLAY", + "TRIGGER:-PT5M", + "END:VALARM", + ), + None, + id="not an alarm's", + ), + ], + ) + def test_the_logical_key_is_the_objects_uid(self, lines, logical_key): + assert _fields(_ical(lines)).logical_key == logical_key + + @pytest.mark.parametrize( + "content, match", + [ + pytest.param(_NOT_UTF8, "must be UTF-8", id="not UTF-8"), + pytest.param(b"", "whole components", id="empty"), + pytest.param(_ical(_EVENT[1:3]), "whole components", id="no component"), + pytest.param(_ical(_EVENT[:-1]), "whole components", id="not ended"), + pytest.param(_ical(["END:VEVENT"]), "never began", id="never began"), + pytest.param( + _ical(["BEGIN:VCALENDAR", "BEGIN:VEVENT", "END:VCALENDAR", "END:VEVENT"]), + "ends VCALENDAR inside VEVENT", + id="ended out of order", + ), + ], + ) + def test_what_isnt_calendar_data_is_refused(self, content, match): + with pytest.raises(ValueError, match=match): + _fields(content) + + def test_what_a_server_sends_always_gets_a_fingerprint(self): + assert _server_fields(_ical(_EVENT)) == _fields(_ical(_EVENT)) + broken = _ical(_EVENT[:-1]) + fields = _server_fields(broken) + assert (fields.fingerprint, fields.logical_key) == ( + hashlib.sha256(broken).hexdigest(), + None, + ) + + +def _calendar(asynchronous: bool, *, features: Any = None) -> Calendar: + client = caldav_client(asynchronous, url=REPLAY_URL, features=features) + return Calendar(client=client, url=f"{REPLAY_URL}/cal/") + + +def _session(replica: Replica, scope_id: str) -> AbstractContextManager[Session]: + """A session on a CalDAV scope, which makes the scope if it's new.""" + return replica.session(scope_id, fingerprint_scheme=FINGERPRINT_SCHEME, media_type=MEDIA_TYPE) + + +def _adapter(store: StateStore, asynchronous: bool) -> CalDAVAdapter | AsyncCalDAVAdapter: + """An adapter on the test scope, which exists.""" + adapter = adapter_class(asynchronous)(Replica(store), _calendar(asynchronous), scope_id=_SCOPE) + with _session(adapter._replica, _SCOPE): + pass + return adapter + + +def _done(step: Any) -> Any: + """The result of an adapter call, awaited if it's a coroutine.""" + return asyncio.run(step) if asyncio.iscoroutine(step) else step + + +class TestAdapter: + @pytest.mark.parametrize( + "url", + [ + f"{REPLAY_URL}/a@b/", + "HTTP://Recorded.Invalid/a@b/", + "http://someone:secret@recorded.invalid:80/a%40b/", + ], + ids=["as written", "capitals", "login"], + ) + def test_scope_id_defaults_to_the_calendars_url(self, store, asynchronous, url): + calendar = Calendar(client=caldav_client(asynchronous, url=url), url=url) + # One spelling, the port included and no login, however the URL was written. + assert ( + adapter_class(asynchronous)(Replica(store), calendar).scope_id + == "http://recorded.invalid:80/a%40b/" + ) + assert _adapter(store, asynchronous).scope_id == _SCOPE + + def test_without_the_extra_the_import_names_it(self): + # A module set to None in sys.modules can't be imported, as if it + # weren't installed; the adapter is imported again under that. + hidden = {"caldav.calendarobjectresource": None, caldav_adapter.__name__: None} + with patch.dict(sys.modules, hidden): + del sys.modules[caldav_adapter.__name__] + with pytest.raises(ImportError, match=r"pip install 'calendaring-sync\[caldav\]'"): + importlib.import_module(caldav_adapter.__name__) + + def test_a_calendar_on_the_other_kind_of_client_is_refused(self, store): + replica = Replica(store) + with pytest.raises(TypeError, match="takes a calendar on a synchronous client"): + CalDAVAdapter(replica, _calendar(asynchronous=True)) + with pytest.raises(TypeError, match="takes a calendar on an asynchronous client"): + AsyncCalDAVAdapter(replica, _calendar(asynchronous=False)) + with pytest.raises(TypeError, match="calendar must be"): + CalDAVAdapter(replica, "a calendar") # type: ignore[arg-type] + + @pytest.mark.parametrize("missing", ["url", "client"]) + def test_a_calendar_without_url_or_client_is_refused(self, store, asynchronous, missing): + calendar = _calendar(asynchronous) + setattr(calendar, missing, None) + with pytest.raises(ValueError, match="no URL or no client"): + adapter_class(asynchronous)(Replica(store), calendar) + + @pytest.mark.parametrize( + "features, needed", + [ + (None, False), + ("radicale", False), + ("cyrus", False), + ("xandikos", True), + ("stalwart", True), + ], + ) + def test_full_listings_are_needed_where_a_token_isnt_refused(self, store, features, needed): + adapter = CalDAVAdapter(Replica(store), _calendar(False, features=features)) + assert adapter.needs_full_listings is needed + + def test_a_client_without_features_needs_no_full_listings(self, store): + adapter = _adapter(store, asynchronous=False) + adapter._client.features = None + assert adapter.needs_full_listings is False + + # Each row calls an adapter of ``scope_id``: local key 1 is the record + # the test makes in the test scope, 2 doesn't exist. + @pytest.mark.parametrize( + "call, scope_id, error, match, before", + [ + pytest.param( + lambda a: a.create(_NOT_UTF8), + _SCOPE, + ValueError, + "must be UTF-8", + True, + id="create", + ), + pytest.param( + lambda a: a.create(_ical(_with("UID:event-1"))), + _SCOPE, + ValueError, + "with a UID", + True, + id="create, no UID", + ), + pytest.param( + lambda a: a.change(1, _NOT_UTF8), + _SCOPE, + ValueError, + "must be UTF-8", + True, + id="change", + ), + pytest.param( + lambda a: a.create(_ical(_EVENT)), + "nope", + UnknownScopeError, + None, + False, + id="scope", + ), + pytest.param( + lambda a: a.change(2, _ical(_EVENT)), + _SCOPE, + UnknownRecordError, + None, + False, + id="record", + ), + pytest.param( + lambda a: a.change(1, _ical(_EVENT)), + "nope", + ValueError, + "in scope 'cal'", + False, + id="other scope", + ), + pytest.param( + lambda a: a.waiting(), "nope", UnknownScopeError, None, False, id="waiting, scope" + ), + ], + ) + def test_a_call_is_refused(self, store, asynchronous, call, scope_id, error, match, before): + made = _adapter(store, asynchronous) + assert _done(made.create(_ical(_EVENT))) == 1 + adapter = adapter_class(asynchronous)( + made._replica, _calendar(asynchronous), scope_id=scope_id + ) + _assert_refused( + made._replica, + store, + lambda: _done(call(adapter)), + error, + match, + before_a_transaction=before, + ) + + def test_an_edit_gets_the_fields_a_sync_gives(self, store, asynchronous): + adapter = _adapter(store, asynchronous) + key = _done(adapter.create(_ical(_EVENT))) + record = adapter._replica.record(key) + assert record is not None and record.edit is not None + edit, fields = record.edit, _fields(_ical(_EVENT)) + assert (edit.fingerprint, edit.logical_key, edit.containers) == ( + fields.fingerprint, + fields.logical_key, + frozenset({_SCOPE}), + ) + assert re.fullmatch(r"/cal/[0-9a-f-]{36}\.ics", record.object_id or "") + + +class TestEditedRecordings: + """Rules no tested server shows on its own, each on one edited exchange.""" + + @pytest.mark.parametrize( + "recording, edit, status, body, state, conflict, raised", + [ + pytest.param( + "radicale-rejected", + edit_a_changed_behind, + 404, + "", + RecordState.CONFLICT, + ConflictKind.REMOVED_REMOTELY, + None, + id="change, gone", + ), + pytest.param( + "radicale-rejected", + edit_a_changed_behind, + 409, + '' + "", + RecordState.CONFLICT, + ConflictKind.LOGICAL_KEY_TAKEN, + None, + id="change, UID taken", + ), + pytest.param( + "radicale-rejected", + edit_a_changed_behind, + 500, + "", + RecordState.IN_FLIGHT, + None, + dav_error.PutError, + id="change, failed", + ), + pytest.param( + "radicale-pushing", + create_c, + 412, + "", + RecordState.PENDING, + None, + None, + id="create, href taken", + ), + pytest.param( + "radicale-pushing", + create_c, + 500, + "", + RecordState.IN_FLIGHT, + None, + dav_error.PutError, + id="create, failed", + ), + ], + ) + def test_how_a_refused_push_ends( + self, store, asynchronous, recording, edit, status, body, state, conflict, raised + ): + # Exchange 9 is the push, in both recordings. + tape = _tape(recording, keep=10) + _edit(tape, 9, status=status, body=body) + with _replaying(tape, store, asynchronous) as env: + key = edit(env) + with nullcontext() if raised is None else pytest.raises(raised): + env.sync() + record = env.at(key) + assert (record.state, record.conflict and record.conflict.kind) == (state, conflict) + # The session ended, after an error too, so the scope takes another. + with _session(env.replica, env.adapter.scope_id): + pass + + @pytest.mark.parametrize( + "body, holder", + [ + pytest.param( + '' + " /user1/calendaring-sync-uid-taken/dup.ics " + "", + "/user1/calendaring-sync-uid-taken/dup.ics", + id="named", + ), + pytest.param( + '' + "/user1/elsewhere/dup.ics" + "", + None, + id="in another calendar", + ), + pytest.param( + '' + "https://proxy.invalid/user1/calendaring-sync-uid-taken/dup.ics" + "", + None, + id="on another host", + ), + pytest.param( + '' + "", + None, + id="not named", + ), + ], + ) + def test_a_server_refusing_a_create_may_name_the_holder( + self, store, asynchronous, body, holder + ): + # The UID search (exchange 7) finds nothing, so the server refuses the PUT. + tape = _tape("radicale-uid_taken") + _edit(tape, 7, body=tape.exchanges[8].body) + path = calendar_path(SERVERS["radicale"], "uid_taken") + tape.exchanges.append( + Exchange( + "PUT", + f"{path}00000000-0000-0000-0000-000000000001.ics", + 409, + {"content-type": "application/xml"}, + body, + ) + ) + with _replaying(tape, store, asynchronous) as env: + SCENARIOS["uid_taken"](env) + conflict = env.record("dup").conflict + assert conflict is not None and conflict.holder == holder + + def test_a_member_answered_without_an_etag_is_kept(self, store, asynchronous): + # Exchange 10 is the second listing's multiget, for b; it loses b's ETag. + tape = _tape("radicale-listing") + etag = _text(tape.exchanges[10].body, "getetag") + _edit(tape, 10, old=f"{etag}") + with _replaying(tape, store, asynchronous) as env: + SCENARIOS["listing"](env) + b = env.record("b") + assert (b.state, b.version) == (RecordState.SYNCED, None) + + def test_a_report_without_a_token_keeps_the_scope_token(self, store, asynchronous): + # Exchange 7 is the second sync's report; it loses its token. + tape = _tape("radicale-reading", keep=9) + _edit( + tape, 7, old=f"{_text(tape.exchanges[7].body, 'sync-token')}" + ) + with _replaying(tape, store, asynchronous) as env: + env.sync() + token = env.replica.scope(env.adapter.scope_id).token + sync_a_and_b(env) + assert env.replica.scope(env.adapter.scope_id).token == token + + def test_a_member_reported_with_its_stored_etag_isnt_fetched(self, store, asynchronous): + # A third report, added after the second (7), names a and b again with + # the ETags the second sync stored, so no multiget follows it. + tape = _tape("radicale-reading", keep=9) + again = "http://radicale.org/ns/sync/again" + report = tape.exchanges[7] + tape.exchanges.append( + replace(report, body=report.body.replace(_text(report.body, "sync-token"), again)) + ) + with _replaying(tape, store, asynchronous) as env: + _first_two_syncs(env) + env.sync() + assert env.replica.scope(env.adapter.scope_id).token == again + + def test_a_member_reported_removed_that_still_has_data_is_kept(self, store, asynchronous): + # The second sync's report (7) names b removed, as Nextcloud names a + # member deleted and created again; the multiget for the removals, + # after the one for a (8), still returns it. + tape = _tape("radicale-reading", keep=9) + tape.exchanges.append(tape.exchanges[8]) + b = "/user1/calendaring-sync-reading/b.ics" + _edit( + tape, + 7, + old=b + tape.exchanges[7].body.split(b)[1].split("")[0] + "", + new=f"{b}HTTP/1.1 404 Not Found", + ) + with _replaying(tape, store, asynchronous) as env: + _first_two_syncs(env) + assert env.record("b").state is RecordState.SYNCED + + @pytest.mark.parametrize("calendar_there", [True, False], ids=["member gone", "calendar gone"]) + def test_a_multiget_refused_whole_is_fetched_member_by_member( + self, store, asynchronous, calendar_there + ): + # Posteo answers the second sync's multiget (8) with 404 when one + # member is gone. A read of the token then shows whether the calendar + # is there, and a multiget for each member follows: a, then b, gone. + tape = _tape("radicale-reading", keep=9) + multiget = tape.exchanges[8] + head, *members = multiget.body.split("") + only_a = head + "" + members[1].replace("", "") + "" + gone = replace(multiget, status=404, body="") + token_read = replace(tape.exchanges[2], status=207 if calendar_there else 404) + tape.exchanges[8:] = [gone, token_read] + if calendar_there: + tape.exchanges += [replace(multiget, body=only_a), gone] + with _replaying(tape, store, asynchronous) as env: + env.sync() + put_a_and_b(env) + with nullcontext() if calendar_there else pytest.raises(ScopeGoneError): + env.sync() + assert [r.object_id for r in env.replica.records(env.adapter.scope_id)] == ( + ["/user1/calendaring-sync-reading/a.ics"] if calendar_there else [] + ) + + def test_a_cancelled_sync_ends_its_session_after_the_store_call(self, store): + # The second sync's ingest runs in a worker thread until the task is + # cancelled; the session ends only after the ingest has finished. + tape = _tape("radicale-reading", keep=9) + ingesting, release = threading.Event(), threading.Event() + ingest = Session.ingest + + def held_ingest(session: Session, change_set: Any) -> None: + ingesting.set() + release.wait(_WAIT) + ingest(session, change_set) + + async def cancel_mid_ingest(adapter: Any) -> None: + task = asyncio.ensure_future(adapter.sync()) + await asyncio.to_thread(ingesting.wait, _WAIT) + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + + with _replaying(tape, store, asynchronous=True) as env: + env.sync() + put_a_and_b(env) + with patch.object(Session, "ingest", held_ingest): + env.run(cancel_mid_ingest(env.adapter)) + assert [r.state for r in env.replica.records(env.adapter.scope_id)] == [ + RecordState.SYNCED, + RecordState.SYNCED, + ] + with _session(env.replica, env.adapter.scope_id): + pass + + @pytest.mark.parametrize("last_call", [False, True], ids=["entering", "last call"]) + def test_a_closed_sync_ends_its_session(self, store, last_call): + # Closing the coroutine while a store call runs waits for that call, + # then ends the session itself, rather than leaving it to whenever the + # garbage collector gets to it: while the first call enters the + # session, or while the first sync's last call, listing the pushes + # (after exchanges 2 and 3, its listing), runs, when ending the session + # is the very step the closing driver drops. + tape = _tape("radicale-reading", keep=4 if last_call else 2) + listing, release = threading.Event(), threading.Event() + to_push = Session.to_push + + def held_to_push(session: Session) -> Any: + listing.set() + release.wait(_WAIT) + return to_push(session) + + ended: list[object] = [] + session = Replica.session + + class Watched: + def __init__(self, manager: Any) -> None: + self.manager = manager + + def __enter__(self) -> Session: + if not last_call: + # Still entering when the driver suspends, so it can't run on. + release.wait(_WAIT) + return self.manager.__enter__() # type: ignore[no-any-return] + + def __exit__(self, *exc: object) -> Any: + ended.append(exc[0]) + return self.manager.__exit__(*exc) + + async def close_mid_call(adapter: Any) -> None: + sync = adapter.sync() + waiting = sync.send(None) + if last_call: + held = asyncio.get_running_loop().run_in_executor(None, listing.wait, _WAIT) + # Drive the sync step by step until its last call is held. + while not held.done(): + await asyncio.wait([waiting, held], return_when=asyncio.FIRST_COMPLETED) + if not held.done(): + waiting = sync.send(None) + threading.Timer(0.05, release.set).start() + else: + release.set() + sync.close() + + with ( + _replaying(tape, store, asynchronous=True) as env, + patch.object(Replica, "session", lambda *a, **k: Watched(session(*a, **k))), + patch.object(Session, "to_push", held_to_push), + ): + env.run(close_mid_call(env.adapter)) + assert ended == [None] + + def test_a_truncated_report_ingests_its_page_and_pushes_nothing(self, store, asynchronous): + # Exchange 7 is the second sync's report; 8 fetches what it named. + tape = _tape("radicale-reading", keep=9) + end = "" + _edit( + tape, + 7, + old=end, + new="/user1/calendaring-sync-reading/" + f"HTTP/1.1 507 Insufficient Storage{end}", + ) + checkpoint = _text(tape.exchanges[7].body, "sync-token") + with _replaying(tape, store, asynchronous) as env: + # A push would be a request the tape doesn't have. + key = create_c(env) + synced_at = env.replica.scope(env.adapter.scope_id).last_sync_at + env.calendar.sync_max_pages = 1 + sync_a_and_b(env) + assert env.at(key).state is RecordState.PENDING + scope = env.replica.scope(env.adapter.scope_id) + # A page that isn't the last moves the token but doesn't finish the sync. + assert (scope.token, scope.last_sync_at) == (checkpoint, synced_at) + + @pytest.mark.parametrize("missing", ["change reports", "token"]) + def test_a_calendar_without_change_reports_or_a_token_is_listed( + self, store, asynchronous, missing + ): + # The second sync's report (7) becomes a listing: the token as the + # first listing (2) read it, then the members the report named, with + # their ETags. Without change reports, the supported reports (6) leave + # the report out; without a token, they aren't read. + tape = _tape("radicale-reading", keep=9) + report, token_read = tape.exchanges[7], tape.exchanges[2] + token = _text(report.body, "sync-token") + listing = [ + replace( + token_read, + body=token_read.body.replace(_text(token_read.body, "sync-token"), token), + ), + replace( + report, + method="PROPFIND", + body=report.body.replace(f"{token}", ""), + ), + ] + if missing == "token": + _edit(tape, 2, old=f"{_text(token_read.body, 'sync-token')}") + tape.exchanges[6:8] = listing + else: + _edit( + tape, + 6, + old="", + ) + tape.exchanges[7:8] = listing + with _replaying(tape, store, asynchronous) as env: + _first_two_syncs(env) + assert env.replica.scope(env.adapter.scope_id).token == token + + def test_a_full_listing_goes_to_the_core_in_pages(self, store, asynchronous): + # From the second listing on, a page holds one object: the calendar's + # own entry, then a (unchanged), then b, which one multiget fetches + # as recorded. Each page but the last is truncated. + tape = _tape("radicale-listing") + one_a_page = patch.object(caldav_adapter, "MULTIGET_SIZE", 1) + with _replaying(tape, store, asynchronous) as env: + env.tape.before("PUT", "SUMMARY:two", one_a_page.start) + try: + SCENARIOS["listing"](env) + finally: + one_a_page.stop() + + def test_an_edit_another_process_claimed_isnt_sent(self, store, asynchronous): + # Exchange 5 is the second sync's report; the create's push would follow. + tape = _tape("radicale-pushing", keep=6) + with _replaying(tape, store, asynchronous) as env: + key = create_c(env) + with patch.object(Session, "claim", return_value=None): + env.sync() + assert env.at(key).state is RecordState.PENDING + # It goes out with a later sync, so it isn't waiting. + assert _done(env.adapter.waiting()) == [] + + def test_a_push_interrupted_while_sending_stays_in_flight(self, store, asynchronous): + # The interruption comes as the create's PUT (9) would go out. + tape = _tape("radicale-pushing", keep=9) + + def interrupt() -> None: + raise KeyboardInterrupt + + with _replaying(tape, store, asynchronous) as env: + key = create_c(env) + env.tape.before("PUT", "", interrupt) + with pytest.raises(KeyboardInterrupt): + env.sync() + # The next complete ingest settles it; the session ended, so one can start. + assert env.at(key).state is RecordState.IN_FLIGHT + with _session(env.replica, env.adapter.scope_id): + pass + + def test_a_weak_etag_waits(self, store, asynchronous): + # Exchange 5 fetches a; nothing after the second sync's report is sent. + tape = _tape("radicale-rejected", keep=8) + _edit(tape, 5, old='"', new='W/"') + with _replaying(tape, store, asynchronous) as env: + key = edit_a(env) + env.sync() + record = env.at(key) + assert record.state is RecordState.PENDING + assert record.version is not None and record.version.startswith("W/") + assert _done(env.adapter.waiting()) == [record] + # Without the edit, nothing waits, though the ETag is still weak. + env.replica.discard(key) + assert _done(env.adapter.waiting()) == [] + + @pytest.mark.parametrize( + "index, edit, error, reset", + [ + pytest.param( + 7, + {"old": ""}, + dav_error.AuthorizationError, + False, + id="refusal", + ), + pytest.param(8, {"status": 500, "body": ""}, dav_error.DAVError, True, id="listing"), + ], + ) + def test_a_token_sync_that_fails_is_raised( + self, store, asynchronous, index, edit, error, reset + ): + # Exchange 7 refuses the token. Without the precondition naming why, + # that's raised as it is; with it, the scope is reset and its listing + # starts (8), and a listing that fails leaves the scope reset. + tape = _tape("radicale-invalid_token", keep=index + 1) + _edit(tape, index, **edit) + with _replaying(tape, store, asynchronous) as env: + key = edit_a(env) + env.forget_token() + with pytest.raises(error): + env.sync() + token = env.replica.scope(env.adapter.scope_id).token + assert token == (None if reset else env.server.unknown_token) + assert env.at(key).state is RecordState.PENDING + + def test_a_listing_error_other_than_a_cut_is_raised(self, store, asynchronous): + # Exchange 8 is the listing, cut short. + tape = _tape("stalwart-listing_cut_short") + _edit(tape, 8, old="507 Insufficient Storage", new="500 Internal Server Error") + with _replaying(tape, store, asynchronous) as env: + for uid in ("a", "b", "c"): + env.put(uid, "one") + with pytest.raises(dav_error.ResponseError, match=" 500 "): + env.sync() + assert env.replica.records(env.adapter.scope_id) == [] + + def test_a_cut_the_client_reports_by_status_is_a_cut(self, store): + # python-caldav after 3.6.0 gives the 507 as a status, not in the message. + tape = _tape("stalwart-listing_cut_short") + with _replaying(tape, store, asynchronous=False) as env: + get_properties = env.calendar.get_properties + + def by_status(*args: Any, **kwargs: Any) -> Any: + try: + return get_properties(*args, **kwargs) + except dav_error.ResponseError: + raise dav_error.ResponseError(status=507) from None + + with patch.object(env.calendar, "get_properties", by_status): + SCENARIOS["listing_cut_short"](env) + + +class TestRecorder: + @pytest.mark.parametrize( + "argv, login", + [ + (["cyrus", "reading"], ("http://localhost:8802", "user1", "x")), + ( + [ + "cyrus", + "reading", + "--url", + "http://h.invalid", + "--username", + "u", + "--password", + "p", + ], + ("http://h.invalid", "u", "p"), + ), + (["radicale", "reading"], ("http://127.0.0.1:1", "user", "secret")), + ], + ) + def test_main_records_and_saves(self, argv, login): + class Embedded: + url, username, password = "http://127.0.0.1:1/user/", "user", "secret" + + def __init__(self, config: object) -> None: + self.running = False + + def start(self) -> None: + self.running = True + + def stop(self) -> None: + assert self.running + + with ( + patch.object(sys, "argv", ["record_caldav", *argv]), + patch.dict(scenarios._EMBEDDED, radicale=Embedded), + patch.object(record_caldav, "run") as recorded, + patch.object(Tape, "save") as saved, + ): + record_caldav.main() + (call,) = recorded.call_args_list + assert call.args[:2] == (SERVERS[argv[0]], "reading") + assert call.kwargs["login"] == login + assert saved.call_args.args[0] == f"{argv[0]}-reading" + + def test_main_records_every_recording_of_a_server(self, monkeypatch): + # Run the module as its docstring says to. + monkeypatch.setattr(sys, "argv", ["record_caldav", "cyrus"]) + monkeypatch.delitem(sys.modules, record_caldav.__name__) + with ( + patch("calendaring_sync.tests._caldav_scenarios.run") as recorded, + patch.object(Tape, "save"), + ): + runpy.run_module(record_caldav.__name__, run_name="__main__") + assert [c.args[1] for c in recorded.call_args_list] == [ + s for server, s in RECORDINGS if server == "cyrus" + ] diff --git a/src/calendaring_sync/tests/test_sync_integration.py b/src/calendaring_sync/tests/test_sync_integration.py index abe09da..0b46189 100644 --- a/src/calendaring_sync/tests/test_sync_integration.py +++ b/src/calendaring_sync/tests/test_sync_integration.py @@ -1,2 +1,199 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later + +""" +Integration tests for the CalDAV adapter, against running servers. + +Radicale and Xandikos run embedded. Cyrus, Stalwart and Baïkal are tested +when their Docker fixtures answer, and skipped otherwise; ``record_caldav`` +says how to start them. Every test runs the synchronous and the +asynchronous adapter, and each server-specific row of the server +compatibility page has a test here or a scenario run against every server. +""" + +from __future__ import annotations + +import os +import socket +import sys +import time +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any +from unittest.mock import patch +from urllib.parse import urlsplit + +import pytest +import radicale.config +from caldav.collection import Calendar +from caldav.elements import dav +from caldav.lib import error as dav_error + +from calendaring_sync import RecordState +from calendaring_sync.tests._caldav_scenarios import ( + SCENARIOS, + SERVERS, + Env, + Login, + Server, + adapter_class, + caldav_client, + edit_a, + environment, + started, +) +from calendaring_sync.tests._caldav_tape import Tape + +# Set to run the scenario that needs Stalwart's result limit lowered to 2, +# as record_caldav's docstring says; the other Stalwart tests need the default. +_LIMITED_STALWART = "CALENDARING_SYNC_STALWART_LIMITED" +# How long the Radicale of the expiry test keeps a sync token, in seconds. +_TOKEN_AGE = 1 +# Where that Radicale listens, next to the one every other test uses. +_EXPIRY_PORT = 5233 + + +def _answers(url: str) -> bool: + parts = urlsplit(url) + try: + socket.create_connection((parts.hostname, parts.port), timeout=1).close() + except OSError: + return False + return True + + +@pytest.fixture(scope="session", params=sorted(SERVERS)) +def live(request: pytest.FixtureRequest) -> Iterator[tuple[Server, Login]]: + """A running server and its login.""" + server = SERVERS[request.param] + if server.login is not None and not _answers(server.login[0]): + pytest.skip(f"{server.name} isn't running") + with started(server) as login: + yield server, login + + +@contextmanager +def _env(live: tuple[Server, Login], name: str, asynchronous: bool) -> Iterator[Env]: + """A new calendar named after ``name`` on the server, and the adapter syncing it. + + The sync and async runs get calendars and UIDs of their own, so neither + has to delete what the other made. + """ + server, login = live + name = f"{name}_{'async' if asynchronous else 'sync'}" + with environment(server, name, Tape(), login=login, asynchronous=asynchronous) as env: + yield env + + +@pytest.mark.parametrize("scenario", [s for s in SCENARIOS if s != "listing_cut_short"]) +def test_every_scenario(live, asynchronous, scenario): + if (live[0].name, scenario, sys.platform) == ("xandikos", "scope_gone", "win32"): + # Its git store leaves read-only files that the delete can't remove. + pytest.skip("Xandikos can't delete a calendar holding objects on Windows") + with _env(live, scenario, asynchronous) as env: + # DAV:limit holds DAV:nresults; a server can lose changes over it. + env.never("REPORT", "nresults", "A report asked for a limited number of results") + SCENARIOS[scenario](env) + + +def test_a_listing_cut_short_raises(live, asynchronous): + server, _ = live + if server.name != "stalwart" or not os.environ.get(_LIMITED_STALWART): + pytest.skip( + "Needs Stalwart with WebDav.maxResults at 2 (the evidence branch's" + f" servers/stalwart_admin.py max-results 2) and {_LIMITED_STALWART} set" + ) + with _env(live, "listing_cut_short", asynchronous) as env: + SCENARIOS["listing_cut_short"](env) + + +def test_a_listing_leaves_out_removed_members(live, asynchronous): + with _env(live, "listing_after_removal", asynchronous) as env: + env.put("a", "one") + env.put("b", "one") + env.remove("b") + env.sync() + assert [r.logical_key for r in env.replica.records(env.adapter.scope_id)] == [ + f"{env.scenario}-a" + ] + + +def test_a_member_created_and_removed_between_syncs_leaves_nothing(live, asynchronous): + with _env(live, "created_and_removed", asynchronous) as env: + env.sync() + env.put("a", "one") + env.remove("a") + env.sync() + assert env.replica.records(env.adapter.scope_id) == [] + + +def test_a_member_removed_and_created_again_keeps_its_record(live, asynchronous): + with _env(live, "removed_and_created", asynchronous) as env: + key = env.synced("a") + env.remove("a") + env.put("a", "two") + env.sync() + record = env.record("a") + assert (record.local_key, record.state) == (key, RecordState.SYNCED) + assert b"two" in (record.content or b"") + + +def test_an_identical_write_changes_nothing(live, asynchronous): + with _env(live, "identical_write", asynchronous) as env: + env.synced("a") + head = env.replica.feed_head() + env.put("a", "one") + env.sync() + assert env.replica.read_feed(head) == [] + + +def test_calendar_data_keeps_its_characters(live, asynchronous): + with _env(live, "characters", asynchronous) as env: + env.synced("a", "Café ☕ 会議") + assert "Café ☕ 会議".encode() in (env.record("a").content or b"") + + +def test_a_refused_login_is_raised_and_keeps_the_token(live, asynchronous): + server, (base_url, username, _) = live + if not server.checks_passwords: + pytest.skip(f"{server.name} lets anyone in") + with _env(live, "refused_login", asynchronous) as env: + env.sync() + client = caldav_client( + asynchronous, url=base_url, username=username, password="not the password" + ) + calendar = Calendar(client=client, url=str(env.calendar.url)) + adapter = adapter_class(asynchronous)(env.replica, calendar) + token = env.replica.scope(adapter.scope_id).token + try: + with pytest.raises(dav_error.AuthorizationError): + env.run(adapter.sync()) + finally: + env.run(client.close()) + assert env.replica.scope(adapter.scope_id).token == token + + +def test_an_expired_token_lists_the_calendar_again(asynchronous): + load = radicale.config.load + + def short_lived_tokens(*args: Any) -> Any: + configuration = load(*args) + configuration.update({"storage": {"max_sync_token_age": str(_TOKEN_AGE)}}, "test") + return configuration + + server = SERVERS["radicale"] + with ( + patch.object(radicale.config, "load", short_lived_tokens), + started(server, {"port": _EXPIRY_PORT}) as login, + _env((server, login), "token_expired", asynchronous) as env, + ): + key = edit_a(env) + time.sleep(_TOKEN_AGE + 1) + # Radicale drops old tokens when it writes a new one, which another + # client's change and read of the token makes it do. + env.put("b", "one") + env.other.get_properties([dav.SyncToken()]) + env.sync() + refused = [e for e in env.tape.exchanges if e.method == "REPORT" and e.status == 403] + assert len(refused) == 1 and "valid-sync-token" in refused[0].body + assert env.at(key).state is RecordState.SYNCED diff --git a/src/calendaring_sync/tests/test_sync_unit.py b/src/calendaring_sync/tests/test_sync_unit.py index 4ec857b..9c064c5 100644 --- a/src/calendaring_sync/tests/test_sync_unit.py +++ b/src/calendaring_sync/tests/test_sync_unit.py @@ -90,6 +90,7 @@ UpdateHolder, ) from calendaring_sync._checks import _CHECKED_WHEN_BUILT, _fields, argument_check +from calendaring_sync.adapters.caldav import AsyncCalDAVAdapter, CalDAVAdapter from calendaring_sync.constants import FEED_PAGE_SIZE, ISSUES_URL, SCHEMA_VERSION from calendaring_sync.store.memory import _Data from calendaring_sync.store.sqlite import _FEED, _META, _RECORDS, _reader @@ -97,7 +98,6 @@ _SCOPE = "cal" _OTHER = "other" -_DB_FILE = "s.db" _SCHEME = "scheme-1" _OTHER_SCHEME = "scheme-2" _MEDIA = "text/plain" @@ -119,34 +119,6 @@ def __call__(self) -> datetime: return self.now -@pytest.fixture -def db_path(tmp_path: Path) -> Path: - """Where a test's SQLite store keeps its file.""" - return tmp_path / _DB_FILE - - -@pytest.fixture(params=[MemoryStore, SQLiteStore], ids=lambda cls: cls.__name__) -def make_store(request: pytest.FixtureRequest, db_path: Path) -> Iterator[Callable[[], StateStore]]: - """Open handles on one store, as separate processes would: one shared - ``MemoryStore``, or a new connection to one SQLite file each time.""" - opened: list[StateStore] = [] - memory = MemoryStore() - - def make() -> StateStore: - store: StateStore = memory if request.param is MemoryStore else SQLiteStore(db_path) - opened.append(store) - return store - - yield make - for store in opened: - store.close() - - -@pytest.fixture -def store(make_store: Callable[[], StateStore]) -> StateStore: - return make_store() - - @pytest.fixture def clock() -> _Clock: return _Clock() @@ -536,9 +508,13 @@ def _refused_create( token: str = "T2", *, logical_key: str | None = "u", + local: str = "create", ) -> int: - """Push a create with id n that the server refuses for its logical key; return its local key.""" - key, _ = _edit_locally(replica, "create", logical_key=logical_key) + """Push an edit that the server refuses for its logical key; return its local key. + + A create gets the id n; a change goes to a, as ``_edit_locally`` makes them. + """ + key, _ = _edit_locally(replica, local, logical_key=logical_key) _push(replica, key, LogicalKeyTaken(holder), since, token) return key @@ -560,14 +536,15 @@ def _in_conflict( token: str = "T2", *, kind: ConflictKind = ConflictKind.BOTH_CHANGED, + refused: str = "create", ) -> int: """Open a conflict of ``kind`` in a seeded scope; return its local key. - A conflict about a create is at the id n, and a refused create names b - as the object holding its logical key u. + A conflict about a create is at the id n, and a refused edit, the + ``refused`` kind of edit, names b as the object holding its logical key u. """ if kind is ConflictKind.LOGICAL_KEY_TAKEN: - return _refused_create(replica, "b", since, token) + return _refused_create(replica, "b", since, token, local=refused) local, remote = _OPENED_BY[kind] key, object_id = _edit_locally(replica, local, object_id) _sync(replica, _page(since, token, {object_id: _remote(remote)})) @@ -655,7 +632,15 @@ def test_benchmarks_run_and_measure_what_they_say(self, monkeypatch, capsys): assert benchmark.entries_after_pushes(size) == {FeedEntryKind.PUSHED: edited} def test_every_entry_point_checks_its_types(self): - for cls in (Replica, Session, StateStore, MemoryStore, SQLiteStore): + for cls in ( + Replica, + Session, + StateStore, + MemoryStore, + SQLiteStore, + CalDAVAdapter, + AsyncCalDAVAdapter, + ): for name, func in vars(cls).items(): if name.startswith("_") and name != "__init__": continue @@ -1255,6 +1240,12 @@ def _conflicted(edit: EditKind, kind: ConflictKind) -> tuple[object, ...]: _conflicted(EditKind.CREATE, ConflictKind.LOGICAL_KEY_TAKEN), [FeedEntryKind.CONFLICT_OPENED], ), + ( + "change", + LogicalKeyTaken("b"), + _conflicted(EditKind.CHANGE, ConflictKind.LOGICAL_KEY_TAKEN), + [FeedEntryKind.CONFLICT_OPENED], + ), ("change", Unknown(), (RecordState.IN_FLIGHT, EditKind.CHANGE, None), []), ] @@ -1321,29 +1312,34 @@ def _conflicted(edit: EditKind, kind: ConflictKind) -> tuple[object, ...]: _MERGED = Merge(_content("fm"), fingerprint="fm") -# Each conflict resolved each way: the conflict's kind, the resolution, then -# what the record ends up as, or None once it's gone. +# Each conflict resolved each way: the conflict's kind, the kind of edit a +# refused one was, the resolution, then what the record ends up as, or None +# once it's gone. _RESOLVED = [ - (ConflictKind.BOTH_CHANGED, TakeServer(), _SYNCED), - (ConflictKind.BOTH_CHANGED, KeepEdit(), _pending(EditKind.CHANGE)), - (ConflictKind.BOTH_CHANGED, _MERGED, _pending(EditKind.CHANGE)), - (ConflictKind.BOTH_CHANGED, Delete(), _pending(EditKind.DELETE)), - (ConflictKind.REMOVED_REMOTELY, TakeServer(), _REMOVED), - (ConflictKind.REMOVED_REMOTELY, KeepEdit(), _pending(EditKind.CREATE)), - (ConflictKind.REMOVED_REMOTELY, _MERGED, _pending(EditKind.CREATE)), - (ConflictKind.REMOVED_REMOTELY, Delete(), _REMOVED), - (ConflictKind.DELETED_LOCALLY, TakeServer(), _SYNCED), - (ConflictKind.DELETED_LOCALLY, KeepEdit(), _pending(EditKind.DELETE)), - (ConflictKind.DELETED_LOCALLY, _MERGED, _pending(EditKind.CHANGE)), - (ConflictKind.DELETED_LOCALLY, Delete(), _pending(EditKind.DELETE)), - (ConflictKind.BOTH_CREATED, TakeServer(), _SYNCED), - (ConflictKind.BOTH_CREATED, KeepEdit(), _pending(EditKind.CHANGE)), - (ConflictKind.BOTH_CREATED, _MERGED, _pending(EditKind.CHANGE)), - (ConflictKind.BOTH_CREATED, Delete(), _pending(EditKind.DELETE)), - (ConflictKind.LOGICAL_KEY_TAKEN, TakeServer(), None), - (ConflictKind.LOGICAL_KEY_TAKEN, _MERGED, _pending(EditKind.CREATE)), - (ConflictKind.LOGICAL_KEY_TAKEN, Delete(), None), - (ConflictKind.LOGICAL_KEY_TAKEN, UpdateHolder(), None), + (ConflictKind.BOTH_CHANGED, "create", TakeServer(), _SYNCED), + (ConflictKind.BOTH_CHANGED, "create", KeepEdit(), _pending(EditKind.CHANGE)), + (ConflictKind.BOTH_CHANGED, "create", _MERGED, _pending(EditKind.CHANGE)), + (ConflictKind.BOTH_CHANGED, "create", Delete(), _pending(EditKind.DELETE)), + (ConflictKind.REMOVED_REMOTELY, "create", TakeServer(), _REMOVED), + (ConflictKind.REMOVED_REMOTELY, "create", KeepEdit(), _pending(EditKind.CREATE)), + (ConflictKind.REMOVED_REMOTELY, "create", _MERGED, _pending(EditKind.CREATE)), + (ConflictKind.REMOVED_REMOTELY, "create", Delete(), _REMOVED), + (ConflictKind.DELETED_LOCALLY, "create", TakeServer(), _SYNCED), + (ConflictKind.DELETED_LOCALLY, "create", KeepEdit(), _pending(EditKind.DELETE)), + (ConflictKind.DELETED_LOCALLY, "create", _MERGED, _pending(EditKind.CHANGE)), + (ConflictKind.DELETED_LOCALLY, "create", Delete(), _pending(EditKind.DELETE)), + (ConflictKind.BOTH_CREATED, "create", TakeServer(), _SYNCED), + (ConflictKind.BOTH_CREATED, "create", KeepEdit(), _pending(EditKind.CHANGE)), + (ConflictKind.BOTH_CREATED, "create", _MERGED, _pending(EditKind.CHANGE)), + (ConflictKind.BOTH_CREATED, "create", Delete(), _pending(EditKind.DELETE)), + (ConflictKind.LOGICAL_KEY_TAKEN, "create", TakeServer(), None), + (ConflictKind.LOGICAL_KEY_TAKEN, "create", _MERGED, _pending(EditKind.CREATE)), + (ConflictKind.LOGICAL_KEY_TAKEN, "create", Delete(), None), + (ConflictKind.LOGICAL_KEY_TAKEN, "create", UpdateHolder(), None), + (ConflictKind.LOGICAL_KEY_TAKEN, "change", TakeServer(), _SYNCED), + (ConflictKind.LOGICAL_KEY_TAKEN, "change", _MERGED, _pending(EditKind.CHANGE)), + (ConflictKind.LOGICAL_KEY_TAKEN, "change", Delete(), _pending(EditKind.DELETE)), + (ConflictKind.LOGICAL_KEY_TAKEN, "change", UpdateHolder(), _SYNCED), ] # Local edits in a row: where the record starts (synced as fa, or a @@ -1493,6 +1489,32 @@ def test_an_open_conflict_meets_the_next_report( assert record.conflict.opened_at == opened_at assert record.conflict.remote_version == (None if following == "removed" else "v3") + @pytest.mark.parametrize( + "following, expected, feed", + [ + ( + "fx", + _conflicted(EditKind.CHANGE, ConflictKind.BOTH_CHANGED), + [FeedEntryKind.CONFLICT_UPDATED], + ), + ( + "removed", + _conflicted(EditKind.CHANGE, ConflictKind.REMOVED_REMOTELY), + [FeedEntryKind.CONFLICT_UPDATED], + ), + ("fl", _SYNCED, [FeedEntryKind.CONFLICT_RESOLVED]), + ], + ) + def test_a_refused_change_meets_the_next_report(self, replica, following, expected, feed): + # The refusal holds no copy of the server's object, so any report is news. + key = _in_conflict(_seeded(replica), kind=ConflictKind.LOGICAL_KEY_TAKEN, refused="change") + # The server still holds the synced copy, so a listing needn't fetch it. + assert replica.known_versions(_SCOPE)["a"] == replica.record(key).version + head = replica.feed_head() + _sync(replica, _page("T2", "T3", {"a": _remote(following, "v3")})) + assert _summary(replica.record(key)) == expected + assert [kind for kind, _ in _feed(replica, head)] == feed + def test_a_version_only_change_moves_the_version_under_an_edit(self, replica): key, _ = _seeded_edit(replica, "change") _sync(replica, _page("T1", "T2", {"a": _changed("fa", "v2", content=b"rewritten")})) @@ -1598,8 +1620,7 @@ def test_settle_records_how_a_push_ended(self, replica, local, outcome, expected "local, outcome, message", [ ("create", Gone(), "can't be gone"), - ("change", LogicalKeyTaken(), "Only a create"), - ("delete", LogicalKeyTaken("b"), "Only a create"), + ("delete", LogicalKeyTaken("b"), "^A delete can't be refused for its logical key$"), ("delete", Applied("x"), "no object id"), ("create without an id", Applied(), "needs the object id"), ("not claimed", Applied(), "takes a record"), @@ -1846,10 +1867,19 @@ def test_a_scope_wide_rejection_is_pushed_again(self, replica, changes, expected class TestResolutions: - @pytest.mark.parametrize("kind, resolution, expected", _RESOLVED) - def test_each_resolution_of_each_conflict(self, replica, kind, resolution, expected): - key = _in_conflict(_seeded(replica), kind=kind) - conflict = replica.record(key).conflict + @pytest.mark.parametrize("kind, refused, resolution, expected", _RESOLVED) + def test_each_resolution_of_each_conflict(self, replica, kind, refused, resolution, expected): + key = _in_conflict(_seeded(replica), kind=kind, refused=refused) + before = replica.record(key) + conflict = before.conflict + # The server's copy: what its side of the conflict holds, or, for a + # change it refused, the synced copy it kept. + refused_change = kind is ConflictKind.LOGICAL_KEY_TAKEN and refused == "change" + server = ( + (before.version, before.content) + if refused_change + else (conflict.remote_version, conflict.remote_content) + ) head = replica.feed_head() replica.resolve(replica.record(key), resolution) record = replica.record(key) @@ -1857,18 +1887,19 @@ def test_each_resolution_of_each_conflict(self, replica, kind, resolution, expec assert [kind for kind, _ in _feed(replica, head)] == [FeedEntryKind.CONFLICT_RESOLVED] if record is not None and record.state is not RecordState.REMOVED: # Whatever is pushed next goes out against the server's current copy. - assert (record.version, record.content) == ( - conflict.remote_version, - conflict.remote_content, - ) + assert (record.version, record.content) == server if isinstance(resolution, Merge): assert record is not None assert (record.edit and record.edit.content) == resolution.content + if isinstance(resolution, UpdateHolder): + # The holder, b, takes the refused edit's content as a change. + holder = replica.find_record(_SCOPE, "b") + assert holder.edit.content == before.edit.content @pytest.mark.parametrize( "kind, resolution, message", [ - *((kind, UpdateHolder(), "^Only a create refused") for kind in _OPENED_BY), + *((kind, UpdateHolder(), "^Only an edit refused") for kind in _OPENED_BY), (ConflictKind.LOGICAL_KEY_TAKEN, KeepEdit(), "can't be pushed again unchanged$"), (None, TakeServer(), "takes a record in conflict$"), ], @@ -2397,6 +2428,7 @@ def test_scope_records_by_state(self, replica, store, states, expected): ): records = store.scope_records(_SCOPE, states=states) assert [r.object_id for r in records] == expected + assert replica.records(_SCOPE, states=states) == records # An empty set is answered by the base class, so no store sees one. assert hook.called == (states != frozenset()) @@ -2589,10 +2621,10 @@ def test_ingest_while_busy_changes_nothing_and_can_be_retried(self, db_path, clo session.ingest(first) assert replica.scope(_SCOPE).token == "T1" - def test_unreadable_files_raise_store_error(self, tmp_path): + def test_unreadable_files_raise_store_error(self, tmp_path, db_path): not_a_store = tmp_path / "junk.db" not_a_store.write_bytes(b"not a database" * 100) - for path in (not_a_store, tmp_path / "missing" / _DB_FILE): + for path in (not_a_store, tmp_path / "missing" / db_path.name): with pytest.raises(StoreError): SQLiteStore(path)