diff --git a/docs/how-to/errors.rst b/docs/how-to/errors.rst index 13a78d8..a48f022 100644 --- a/docs/how-to/errors.rst +++ b/docs/how-to/errors.rst @@ -35,7 +35,7 @@ See :doc:`../reference/errors` for what each error class means and the full list Malformed iCalendar input ========================= -``create_event``, ``send_invite``, and ``update_event`` take a JSCalendar dict directly and raise nothing client-side on a malformed one; whether a missing required property comes back as the server's own ``invalidProperties`` (surfaced as :class:`~calendaring_jmap.error.JMAPMethodError`) depends on the server and the property. Confirmed live that Cyrus rejects a create missing ``uid``/``start`` with ``invalidProperties``, while Stalwart accepts it, generating its own ``uid`` and leaving the event with no ``start`` at all rather than rejecting the request. The ``ValueError`` case below applies to ``ical_to_jscal`` itself, and so to ``JMAPCalendar.add_event``/``JMAPCalendarObject.save``, which call it on your behalf: if the iCalendar string is missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration, conversion raises a plain ``ValueError`` before any request reaches the server. Catch it if you're constructing iCalendar strings by hand rather than passing through data a server already returned to you: +``create_event``, ``send_invite``, and ``update_event`` take a JSCalendar dict directly and raise nothing client-side on a malformed one; whether a missing required property comes back as the server's own ``invalidProperties`` (surfaced as :class:`~calendaring_jmap.error.JMAPMethodError`) depends on the server and the property. Confirmed live that Cyrus rejects a create missing ``uid``/``start`` with ``invalidProperties``, while Stalwart accepts it, generating its own ``uid`` and leaving the event with no ``start`` at all rather than rejecting the request. ``JMAPMethodError.properties`` lists which properties an ``invalidProperties`` rejection actually named, when the server included that list, so you can tell a duplicate UID apart from an unrelated rejection sharing the same ``error_type``. The ``ValueError`` case below applies to ``ical_to_jscal`` itself, and so to ``JMAPCalendar.add_event``/``JMAPCalendarObject.save``, which call it on your behalf: if the iCalendar string is missing a mandatory property (``UID``, ``DTSTART``) or has a negative duration, conversion raises a plain ``ValueError`` before any request reaches the server. Catch it if you're constructing iCalendar strings by hand rather than passing through data a server already returned to you. ``create_event``/``send_invite`` raise the same exception type for an unrelated reason: passing ``calendar_id=None`` without the event's own ``calendarIds`` set. .. code-block:: python diff --git a/docs/how-to/events.rst b/docs/how-to/events.rst index 77fda99..6f56730 100644 --- a/docs/how-to/events.rst +++ b/docs/how-to/events.rst @@ -72,7 +72,9 @@ Delete an event .. code-block:: python - client.delete_event(event_id) + old_state, new_state = client.delete_event(event_id) + +``old_state`` and ``new_state`` are the account's state immediately before and after the delete; pass ``new_state`` as a later write's ``if_in_state`` to chain writes without a separate read in between. Search events ============= diff --git a/news/+create-event-optional-calendar-id.feature.rst b/news/+create-event-optional-calendar-id.feature.rst new file mode 100644 index 0000000..bbc765b --- /dev/null +++ b/news/+create-event-optional-calendar-id.feature.rst @@ -0,0 +1 @@ +``create_event``/``send_invite`` now accept ``calendar_id=None`` to send an event's own ``calendarIds`` unchanged, instead of always overwriting it down to one calendar. Raises ``ValueError`` if the event has no ``calendarIds`` at all. diff --git a/news/+delete-event-returns-state.breaking.rst b/news/+delete-event-returns-state.breaking.rst new file mode 100644 index 0000000..905daba --- /dev/null +++ b/news/+delete-event-returns-state.breaking.rst @@ -0,0 +1 @@ +``delete_event`` now returns ``(old_state, new_state)`` instead of ``None``, matching ``create_event``/``update_event``. Both Cyrus and Stalwart already send this on every destroy; it was just being discarded. diff --git a/news/+jmap-method-error-properties.feature.rst b/news/+jmap-method-error-properties.feature.rst new file mode 100644 index 0000000..460cb4d --- /dev/null +++ b/news/+jmap-method-error-properties.feature.rst @@ -0,0 +1 @@ +``JMAPMethodError`` now has a ``properties`` attribute, listing the properties a server's ``invalidProperties`` rejection named, when the server included that list. ``None`` for any other error. diff --git a/src/calendaring_jmap/async_client.py b/src/calendaring_jmap/async_client.py index dd3fb37..885a9c4 100644 --- a/src/calendaring_jmap/async_client.py +++ b/src/calendaring_jmap/async_client.py @@ -437,7 +437,7 @@ async def set_default_alerts( async def _create_event_impl( self, - calendar_id: str, + calendar_id: str | None, event: dict, account_id: str | None, send_scheduling_messages: bool, @@ -445,7 +445,7 @@ async def _create_event_impl( ) -> tuple[str, str, str]: session = await self._get_session() jscal = self._strip_server_managed_event_properties(event) - jscal["calendarIds"] = {calendar_id: True} + self._resolve_create_calendar_ids(jscal, calendar_id) target_account = self._resolve_account(session, account_id) call = build_event_set_create( target_account, @@ -462,7 +462,7 @@ async def _create_event_impl( async def create_event( self, - calendar_id: str, + calendar_id: str | None, event: dict, account_id: str | None = None, if_in_state: str | None = None, @@ -470,14 +470,20 @@ async def create_event( """Create a calendar event from a JSCalendar dict. Args: - calendar_id: The JMAP calendar ID to create the event in. + calendar_id: The JMAP calendar ID to create the event in. If + given, overwrites ``event``'s own ``calendarIds`` with + this one calendar, replacing whatever it already had. If + ``None``, ``event``'s own ``calendarIds`` is sent + unchanged, so an event genuinely belonging to more than + one calendar keeps all of them; confirmed live that + Stalwart accepts this, while Cyrus rejects a create + naming more than one calendar with + ``invalidProperties``/``properties: ["calendarIds"]``. event: A JSCalendar CalendarEvent dict (:rfc:`8984#section-2.1`), e.g. one returned by :meth:`get_event`. Not mutated: a copy is made before ``id``, ``baseEventId``, and ``isOrigin`` are stripped, since draft-ietf-jmap-calendars documents all three as - server-set and immutable, and ``calendarIds`` is overwritten - with ``calendar_id``, replacing whatever calendar membership - ``event`` already had. + server-set and immutable. account_id: The account owning ``calendar_id``; pass the owner's account here to add an event to a calendar shared with you (see ``get_calendars(account_id=...)``). @@ -499,6 +505,8 @@ async def create_event( Raises: JMAPMethodError: If the server rejects the create request. + ValueError: If ``calendar_id`` is ``None`` and ``event`` has + no ``calendarIds``, or an empty one. """ return await self._create_event_impl( calendar_id, @@ -510,7 +518,7 @@ async def create_event( async def send_invite( self, - calendar_id: str, + calendar_id: str | None, event: dict, account_id: str | None = None, if_in_state: str | None = None, @@ -525,6 +533,7 @@ async def send_invite( Args: calendar_id: The JMAP calendar ID to create the event in. + See :meth:`create_event` for ``None``'s meaning. event: A JSCalendar CalendarEvent dict. See :meth:`create_event`. account_id: The account owning ``calendar_id``, same as :meth:`create_event`. @@ -537,6 +546,7 @@ async def send_invite( JMAPMethodError: If the server rejects the create request, or with ``error_type == "noSupportedScheduleMethods"`` if a participant has no usable delivery method. + ValueError: Same condition as :meth:`create_event`. """ return await self._create_event_impl( calendar_id, @@ -1119,7 +1129,7 @@ async def delete_event( account_id: str | None = None, send_scheduling_messages: bool = False, if_in_state: str | None = None, - ) -> None: + ) -> tuple[str, str]: """Delete a calendar event. Args: @@ -1138,6 +1148,10 @@ async def delete_event( the account, not just this one. ``None`` applies unconditionally. + Returns: + A 2-tuple ``(old_state, new_state)``. See + :meth:`~calendaring_jmap.client.JMAPClient.delete_event`. + Raises: JMAPMethodError: If the server rejects the delete. """ @@ -1156,6 +1170,7 @@ async def delete_event( self._parse_delete_response( responses, session.api_url, "CalendarEvent/set", parse_event_set, event_id ) + return self._parse_set_state(responses, "CalendarEvent/set") async def _get_object_by_uid( self, diff --git a/src/calendaring_jmap/client.py b/src/calendaring_jmap/client.py index 0cd7a28..da484b1 100644 --- a/src/calendaring_jmap/client.py +++ b/src/calendaring_jmap/client.py @@ -277,10 +277,21 @@ def _as_utc_datetime(local_datetime: str) -> str: @staticmethod def _raise_set_error(api_url: str, err: dict) -> None: + """Raise a ``JMAPMethodError`` for one ``SetError`` from a + ``notCreated``/``notUpdated``/``notDestroyed`` map. + + ``properties`` is read unconditionally from ``err``, not only when + ``type == "invalidProperties"``: :rfc:`8620#section-5.3` leaves + room for other ``SetError`` types to carry method-specific fields + of their own, so gating on one hardcoded type would silently drop + a field this client doesn't yet know the name of. Absent, it + stays ``None`` on the raised error. + """ raise JMAPMethodError( url=api_url, reason=f"set failed: {err}", error_type=err.get("type", _DEFAULT_ERROR_TYPE), + properties=err.get("properties"), ) @staticmethod @@ -478,6 +489,39 @@ def _strip_server_managed_event_properties(event: dict) -> dict: copy.pop(key, None) return copy + @staticmethod + def _resolve_create_calendar_ids(jscal: dict, calendar_id: str | None) -> None: + """Set ``jscal["calendarIds"]`` in place for a ``CalendarEvent/set`` create. + + ``calendar_id`` given: overwrites ``calendarIds`` with that one + calendar, same as every release before this one. ``None``: leaves + ``jscal``'s own ``calendarIds`` as the caller built it, so a dict + that genuinely belongs to more than one calendar keeps all of + them; confirmed live that Stalwart accepts a create with two real + calendar ids in ``calendarIds`` at once, while Cyrus rejects it + with ``invalidProperties``/``properties: ["calendarIds"]``, a + per-server difference this client passes through rather than + working around. + + Raises: + ValueError: If ``calendar_id`` is ``None`` and ``jscal`` has no + ``calendarIds`` key, an empty one, or ``None``. + draft-ietf-jmap-calendars documents an event as belonging + to one or more calendars at all times; confirmed live both + servers reject a create with ``calendarIds`` absent, empty, + or explicitly ``null`` the same way, + ``invalidProperties``/``properties: ["calendarIds"]``, so + this raises before sending a request either server would + reject anyway. + """ + if calendar_id is not None: + jscal["calendarIds"] = {calendar_id: True} + elif not jscal.get("calendarIds"): + raise ValueError( + "event has no calendarIds and calendar_id was not given; " + "an event must belong to at least one calendar" + ) + @staticmethod def _build_event_update_patch(event: dict) -> tuple[dict, frozenset[str]]: """Build a JSCalendar PatchObject for a ``CalendarEvent/set`` update. @@ -1462,7 +1506,7 @@ def set_default_alerts( def _create_event_impl( self, - calendar_id: str, + calendar_id: str | None, event: dict, account_id: str | None, send_scheduling_messages: bool, @@ -1470,7 +1514,7 @@ def _create_event_impl( ) -> tuple[str, str, str]: session = self._get_session() jscal = self._strip_server_managed_event_properties(event) - jscal["calendarIds"] = {calendar_id: True} + self._resolve_create_calendar_ids(jscal, calendar_id) target_account = self._resolve_account(session, account_id) call = build_event_set_create( target_account, @@ -1487,7 +1531,7 @@ def _create_event_impl( def create_event( self, - calendar_id: str, + calendar_id: str | None, event: dict, account_id: str | None = None, if_in_state: str | None = None, @@ -1495,14 +1539,20 @@ def create_event( """Create a calendar event from a JSCalendar dict. Args: - calendar_id: The JMAP calendar ID to create the event in. + calendar_id: The JMAP calendar ID to create the event in. If + given, overwrites ``event``'s own ``calendarIds`` with + this one calendar, replacing whatever it already had. If + ``None``, ``event``'s own ``calendarIds`` is sent + unchanged, so an event genuinely belonging to more than + one calendar keeps all of them; confirmed live that + Stalwart accepts this, while Cyrus rejects a create + naming more than one calendar with + ``invalidProperties``/``properties: ["calendarIds"]``. event: A JSCalendar CalendarEvent dict (:rfc:`8984#section-2.1`), e.g. one returned by :meth:`get_event`. Not mutated: a copy is made before ``id``, ``baseEventId``, and ``isOrigin`` are stripped, since draft-ietf-jmap-calendars documents all three as - server-set and immutable, and ``calendarIds`` is overwritten - with ``calendar_id``, replacing whatever calendar membership - ``event`` already had. + server-set and immutable. account_id: The account owning ``calendar_id``; pass the owner's account here to add an event to a calendar shared with you (see ``get_calendars(account_id=...)``). @@ -1524,6 +1574,8 @@ def create_event( Raises: JMAPMethodError: If the server rejects the create request. + ValueError: If ``calendar_id`` is ``None`` and ``event`` has + no ``calendarIds``, or an empty one. """ return self._create_event_impl( calendar_id, @@ -1535,7 +1587,7 @@ def create_event( def send_invite( self, - calendar_id: str, + calendar_id: str | None, event: dict, account_id: str | None = None, if_in_state: str | None = None, @@ -1550,6 +1602,7 @@ def send_invite( Args: calendar_id: The JMAP calendar ID to create the event in. + See :meth:`create_event` for ``None``'s meaning. event: A JSCalendar CalendarEvent dict. See :meth:`create_event`. account_id: The account owning ``calendar_id``, same as :meth:`create_event`. @@ -1562,6 +1615,7 @@ def send_invite( JMAPMethodError: If the server rejects the create request, or with ``error_type == "noSupportedScheduleMethods"`` if a participant has no usable delivery method. + ValueError: Same condition as :meth:`create_event`. """ return self._create_event_impl( calendar_id, @@ -2535,7 +2589,7 @@ def delete_event( account_id: str | None = None, send_scheduling_messages: bool = False, if_in_state: str | None = None, - ) -> None: + ) -> tuple[str, str]: """Delete a calendar event. Args: @@ -2554,6 +2608,14 @@ def delete_event( the account, not just this one. ``None`` applies unconditionally. + Returns: + A 2-tuple ``(old_state, new_state)``, the account's state + immediately before and after this delete (:rfc:`8620#section-5.3`), + confirmed live present on every ``CalendarEvent/set`` response + on both Cyrus and Stalwart. Pass ``new_state`` as a later + call's ``if_in_state`` to require that no other write has + landed since. + Raises: JMAPMethodError: If the server rejects the delete. """ @@ -2572,6 +2634,7 @@ def delete_event( self._parse_delete_response( responses, session.api_url, "CalendarEvent/set", parse_event_set, event_id ) + return self._parse_set_state(responses, "CalendarEvent/set") def _get_object_by_uid( self, diff --git a/src/calendaring_jmap/error.py b/src/calendaring_jmap/error.py index 0a11084..5c9e572 100644 --- a/src/calendaring_jmap/error.py +++ b/src/calendaring_jmap/error.py @@ -126,6 +126,28 @@ class JMAPMethodError(JMAPError): Individual methods define further, method-specific error types beyond this generic set (e.g. ``notFound``, ``calendarHasEvent``, ``expandDurationTooLarge``); see each method builder's own docstring. + + Attributes: + properties: The failing properties named by a ``SetError`` + (:rfc:`8620#section-5.3`), or ``None`` for an error with no + such list. The RFC documents this field only for + ``invalidProperties``, and as a SHOULD there too, so a server + may omit it even then; confirmed live that a method-level + error such as ``stateMismatch`` never has one at all, since it + aborts the whole call before any per-object ``SetError`` is + produced. """ error_type = _DEFAULT_ERROR_TYPE + properties: list[str] | None = None + + def __init__( + self, + url: str | None = None, + reason: str | None = None, + error_type: str | None = None, + properties: list[str] | None = None, + ) -> None: + super().__init__(url=url, reason=reason, error_type=error_type) + if properties is not None: + self.properties = properties diff --git a/src/calendaring_jmap/tests/test_jmap_integration.py b/src/calendaring_jmap/tests/test_jmap_integration.py index bc257e5..181cf24 100644 --- a/src/calendaring_jmap/tests/test_jmap_integration.py +++ b/src/calendaring_jmap/tests/test_jmap_integration.py @@ -1025,6 +1025,69 @@ def test_delete_event_with_stale_if_in_state_raises_state_mismatch( finally: event_client.delete_event(event_id) + def test_delete_event_returns_state_chainable_into_next_write( + self, event_client, event_calendar_id + ): + """calendaring-sync's adapter chains each write's if_in_state from + the previous write's new_state; a delete must participate in that + chain the same way create/update already do.""" + event_id, _, delete_target_state = event_client.create_event( + event_calendar_id, + _minimal_event("Delete State Chain Test Event", calendar_id=event_calendar_id), + ) + _, new_state = event_client.delete_event(event_id, if_in_state=delete_target_state) + + next_event_id, _, _ = event_client.create_event( + event_calendar_id, + _minimal_event("Delete State Chain Next Event", calendar_id=event_calendar_id), + if_in_state=new_state, + ) + event_client.delete_event(next_event_id) + + def test_create_event_duplicate_uid_error_carries_properties( + self, event_client, event_calendar_id + ): + event = _minimal_event("Duplicate UID Test Event", calendar_id=event_calendar_id) + event_id, _, _ = event_client.create_event(event_calendar_id, event) + try: + with pytest.raises(JMAPMethodError) as exc_info: + event_client.create_event(event_calendar_id, event) + assert exc_info.value.error_type == "invalidProperties" + assert exc_info.value.properties == ["uid"] + finally: + event_client.delete_event(event_id) + + def test_create_event_calendar_id_none_preserves_multi_calendar_membership( + self, event_client, event_calendar_id, server + ): + """Confirmed live this session: Stalwart accepts a create naming + two real calendars in calendarIds at once; Cyrus rejects it with + invalidProperties. calendar_id=None must pass the caller's own + calendarIds through unchanged either way, not silently collapse + it to one calendar the way calendar_id= always has.""" + second_calendar_id = event_client.create_calendar("Second Calendar For Multi-Cal Test") + try: + event = _minimal_event("Multi Calendar Test Event") + event["calendarIds"] = {event_calendar_id: True, second_calendar_id: True} + + if server == "stalwart": + event_id, _, _ = event_client.create_event(None, event) + try: + fetched = event_client.get_event(event_id) + assert fetched.get_data()["calendarIds"] == { + event_calendar_id: True, + second_calendar_id: True, + } + finally: + event_client.delete_event(event_id) + else: + with pytest.raises(JMAPMethodError) as exc_info: + event_client.create_event(None, event) + assert exc_info.value.error_type == "invalidProperties" + assert exc_info.value.properties == ["calendarIds"] + finally: + event_client.delete_calendar(second_calendar_id) + def test_get_objects_by_sync_token_pages_with_max_changes( self, event_client, event_calendar_id ): diff --git a/src/calendaring_jmap/tests/test_jmap_unit.py b/src/calendaring_jmap/tests/test_jmap_unit.py index 632b61d..194854c 100644 --- a/src/calendaring_jmap/tests/test_jmap_unit.py +++ b/src/calendaring_jmap/tests/test_jmap_unit.py @@ -5617,6 +5617,24 @@ def test_create_event_raises_on_failure(self, monkeypatch): client.create_event("cal1", self._MINIMAL_EVENT) assert exc_info.value.error_type == "invalidArguments" + def test_create_event_error_carries_properties(self, monkeypatch): + resp = self._set_response( + notCreated={"new-0": {"type": "invalidProperties", "properties": ["uid"]}} + ) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.create_event("cal1", self._MINIMAL_EVENT) + assert exc_info.value.properties == ["uid"] + + def test_create_event_error_properties_defaults_to_none(self, monkeypatch): + resp = self._set_response( + notCreated={"new-0": {"type": "invalidArguments", "description": "bad"}} + ) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.create_event("cal1", self._MINIMAL_EVENT) + assert exc_info.value.properties is None + def test_create_event_raises_on_malformed_response(self, monkeypatch): resp = self._set_response(created={}, notCreated={}) client = _make_client_with_mocked_session(monkeypatch, resp) @@ -5633,6 +5651,45 @@ def test_create_event_passes_calendar_id(self, monkeypatch): event_payload = create_args["create"]["new-0"] assert event_payload.get("calendarIds") == {"my-calendar": True} + def test_create_event_calendar_id_none_keeps_event_calendar_ids(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-3"}}) + client, captured = self._capturing_client(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = {"cal-a": True, "cal-b": True} + before = dict(event) + client.create_event(None, event) + + event_payload = captured["json"]["methodCalls"][0][1]["create"]["new-0"] + assert event_payload["calendarIds"] == {"cal-a": True, "cal-b": True} + assert event == before + + def test_create_event_calendar_id_none_raises_without_calendar_ids(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-4"}}) + client, captured = self._capturing_client(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event.pop("calendarIds", None) + with pytest.raises(ValueError, match="calendarIds"): + client.create_event(None, event) + assert captured.get("json") is None + + def test_create_event_calendar_id_none_raises_on_empty_calendar_ids(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-5"}}) + client, captured = self._capturing_client(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = {} + with pytest.raises(ValueError, match="calendarIds"): + client.create_event(None, event) + assert captured.get("json") is None + + def test_create_event_calendar_id_none_raises_on_null_calendar_ids(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-6"}}) + client, captured = self._capturing_client(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = None + with pytest.raises(ValueError, match="calendarIds"): + client.create_event(None, event) + assert captured.get("json") is None + def test_create_event_does_not_mutate_caller_dict(self, monkeypatch): resp = self._set_response(created={"new-0": {"id": "sv-3"}}) client, _ = self._capturing_client(monkeypatch, resp) @@ -5840,6 +5897,29 @@ def test_delete_event_raises_on_failure(self, monkeypatch): client.delete_event("ev1") assert exc_info.value.error_type == "notFound" + def test_delete_event_error_carries_properties(self, monkeypatch): + resp = self._set_response( + notDestroyed={"ev1": {"type": "invalidProperties", "properties": ["uid"]}} + ) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.delete_event("ev1") + assert exc_info.value.properties == ["uid"] + + def test_delete_event_error_properties_defaults_to_none(self, monkeypatch): + resp = self._set_response(notDestroyed={"ev1": {"type": "notFound"}}) + client = _make_client_with_mocked_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + client.delete_event("ev1") + assert exc_info.value.properties is None + + def test_delete_event_returns_old_and_new_state(self, monkeypatch): + resp = self._set_response(destroyed=["ev1"], oldState="state-1", newState="state-2") + client = _make_client_with_mocked_session(monkeypatch, resp) + old_state, new_state = client.delete_event("ev1") + assert old_state == "state-1" + assert new_state == "state-2" + def test_update_event_uses_given_account_id(self, monkeypatch): resp = self._set_response(updated={"ev1": None}) client, captured = self._capturing_client(monkeypatch, resp) @@ -5946,6 +6026,18 @@ def test_send_invite_sends_scheduling_messages(self, monkeypatch): create_args = captured["json"]["methodCalls"][0][1] assert create_args["sendSchedulingMessages"] is True + def test_send_invite_calendar_id_none_keeps_event_calendar_ids(self, monkeypatch): + resp = self._set_response(created={"new-0": {"id": "sv-1"}}) + client, captured = self._capturing_client(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = {"cal-a": True, "cal-b": True} + before = dict(event) + client.send_invite(None, event) + + event_payload = captured["json"]["methodCalls"][0][1]["create"]["new-0"] + assert event_payload["calendarIds"] == {"cal-a": True, "cal-b": True} + assert event == before + def test_send_invite_raises_no_supported_schedule_methods(self, monkeypatch): resp = self._set_response(notCreated={"new-0": {"type": "noSupportedScheduleMethods"}}) client = _make_client_with_mocked_session(monkeypatch, resp) @@ -8291,6 +8383,24 @@ async def test_create_event_raises_on_failure(self, monkeypatch): await self._make_client().create_event("cal1", self._MINIMAL_EVENT) assert exc_info.value.error_type == "invalidArguments" + @pytest.mark.asyncio + async def test_create_event_error_carries_properties(self, monkeypatch): + resp = self._event_set_resp( + created={}, notCreated={"new-0": {"type": "invalidProperties", "properties": ["uid"]}} + ) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().create_event("cal1", self._MINIMAL_EVENT) + assert exc_info.value.properties == ["uid"] + + @pytest.mark.asyncio + async def test_create_event_error_properties_defaults_to_none(self, monkeypatch): + resp = self._event_set_resp(created={}, notCreated={"new-0": {"type": "invalidArguments"}}) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().create_event("cal1", self._MINIMAL_EVENT) + assert exc_info.value.properties is None + @pytest.mark.asyncio async def test_get_event_returns_ical(self, monkeypatch): self._patch_async_session(monkeypatch, self._event_get_resp([self._RAW_EVENT])) @@ -8451,6 +8561,33 @@ async def test_delete_event_raises_on_failure(self, monkeypatch): await self._make_client().delete_event("ev-async-1") assert exc_info.value.error_type == "notFound" + @pytest.mark.asyncio + async def test_delete_event_error_carries_properties(self, monkeypatch): + resp = self._event_set_resp( + destroyed=[], + notDestroyed={"ev-async-1": {"type": "invalidProperties", "properties": ["uid"]}}, + ) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().delete_event("ev-async-1") + assert exc_info.value.properties == ["uid"] + + @pytest.mark.asyncio + async def test_delete_event_error_properties_defaults_to_none(self, monkeypatch): + resp = self._event_set_resp(destroyed=[], notDestroyed={"ev-async-1": {"type": "notFound"}}) + self._patch_async_session(monkeypatch, resp) + with pytest.raises(JMAPMethodError) as exc_info: + await self._make_client().delete_event("ev-async-1") + assert exc_info.value.properties is None + + @pytest.mark.asyncio + async def test_delete_event_returns_old_and_new_state(self, monkeypatch): + resp = self._event_set_resp(destroyed=["ev-async-1"], oldState="s1", newState="s2") + self._patch_async_session(monkeypatch, resp) + old_state, new_state = await self._make_client().delete_event("ev-async-1") + assert old_state == "s1" + assert new_state == "s2" + @pytest.mark.asyncio async def test_send_invite_returns_id(self, monkeypatch): resp = self._event_set_resp(created={"new-0": {"id": "sv-async-1"}}, notCreated={}) @@ -8466,6 +8603,19 @@ async def test_send_invite_sends_scheduling_messages(self, monkeypatch): create_args = mock_http.post.call_args.kwargs["json"]["methodCalls"][0][1] assert create_args["sendSchedulingMessages"] is True + @pytest.mark.asyncio + async def test_send_invite_calendar_id_none_keeps_event_calendar_ids(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "sv-async-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = {"cal-a": True, "cal-b": True} + before = dict(event) + await client.send_invite(None, event) + + event_payload = captured["json"]["methodCalls"][0][1]["create"]["new-0"] + assert event_payload["calendarIds"] == {"cal-a": True, "cal-b": True} + assert event == before + def _participant_event_resp(self, own_email="me@example.com", participant_id="p1"): return self._event_get_resp( [_participant_event(self._RAW_EVENT, own_email, participant_id)] @@ -9260,6 +9410,49 @@ async def test_create_event_passes_calendar_id(self, monkeypatch): new_event = create_args["create"]["new-0"] assert "my-cal" in new_event.get("calendarIds", {}) + @pytest.mark.asyncio + async def test_create_event_calendar_id_none_keeps_event_calendar_ids(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = {"cal-a": True, "cal-b": True} + before = dict(event) + await client.create_event(None, event) + + event_payload = captured["json"]["methodCalls"][0][1]["create"]["new-0"] + assert event_payload["calendarIds"] == {"cal-a": True, "cal-b": True} + assert event == before + + @pytest.mark.asyncio + async def test_create_event_calendar_id_none_raises_without_calendar_ids(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event.pop("calendarIds", None) + with pytest.raises(ValueError, match="calendarIds"): + await client.create_event(None, event) + assert captured.get("json") is None + + @pytest.mark.asyncio + async def test_create_event_calendar_id_none_raises_on_empty_calendar_ids(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = {} + with pytest.raises(ValueError, match="calendarIds"): + await client.create_event(None, event) + assert captured.get("json") is None + + @pytest.mark.asyncio + async def test_create_event_calendar_id_none_raises_on_null_calendar_ids(self, monkeypatch): + resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={}) + client, captured = self._capturing_async_session(monkeypatch, resp) + event = dict(self._MINIMAL_EVENT) + event["calendarIds"] = None + with pytest.raises(ValueError, match="calendarIds"): + await client.create_event(None, event) + assert captured.get("json") is None + @pytest.mark.asyncio async def test_create_event_does_not_mutate_caller_dict(self, monkeypatch): resp = self._event_set_resp(created={"new-0": {"id": "ev-new-1"}}, notCreated={})