Repository: allenporter/gcal_sync (language: python)

Issue:
Support sync for deletion of existing events
Ensure that a deleted event is removed from local storage.


Discussion / hints:

Fix patch:
diff --git a/gcal_sync/api.py b/gcal_sync/api.py
index 40f51ac..d631294 100644
--- a/gcal_sync/api.py
+++ b/gcal_sync/api.py
@@ -13,7 +13,7 @@
 
 from .auth import AbstractAuth
 from .const import EVENTS
-from .model import EVENT_FIELDS, Calendar, Event
+from .model import EVENT_FIELDS, Calendar, Event, EventStatusEnum
 from .store import CalendarStore
 
 _LOGGER = logging.getLogger(__name__)
@@ -239,6 +239,8 @@ async def async_list_events(
         events = []
         for event_data in events_data.values():
             event = Event.parse_obj(event_data)
+            if event.status == EventStatusEnum.CANCELLED:
+                continue
             if request.start_time:
                 if event.end.date and request.start_time.date() > event.end.date:
                     continue
diff --git a/gcal_sync/model.py b/gcal_sync/model.py
index e1ba5dc..1e105db 100644
--- a/gcal_sync/model.py
+++ b/gcal_sync/model.py
@@ -4,6 +4,7 @@
 
 import datetime
 import zoneinfo
+from enum import Enum
 from typing import Any, Optional, Union
 
 from pydantic import BaseModel, Field, root_validator
@@ -61,6 +62,14 @@ class Config:
         arbitrary_types_allowed = True
 
 
+class EventStatusEnum(str, Enum):
+    "Status of the event"
+
+    CONFIRMED = "confirmed"
+    TENTATIVE = "tentative"
+    CANCELLED = "cancelled"
+
+
 class Event(BaseModel):
     """A single event on a calendar."""
 
@@ -71,6 +80,21 @@ class Event(BaseModel):
     description: Optional[str]
     location: Optional[str]
     transparency: str = Field(default="opaque")
+    # Note deleted events are only returned in some scenarios based on request options
+    # such as enabling incremental sync or explicitly asking for deleted items. That is,
+    # most users should not need to check the status.
+    status: EventStatusEnum = EventStatusEnum.CONFIRMED
+
+    @root_validator(pre=True)
+    def allow_cancelled_events(cls, values: dict[str, Any]) -> dict[str, Any]:
+        """Special case for canceled event tombstones that are missing required fields."""
+        if status := values.get("status"):
+            if status == EventStatusEnum.CANCELLED:
+                if "start" not in values:
+                    values["start"] = DateOrDatetime(date=datetime.date.min)
+                if "end" not in values:
+                    values["end"] = DateOrDatetime(date=datetime.date.min)
+        return values
 
     class Config:
         """Model configuration."""


Test patch:
diff --git a/tests/test_event_sync.py b/tests/test_event_sync.py
index 5b5ab5a..4304806 100644
--- a/tests/test_event_sync.py
+++ b/tests/test_event_sync.py
@@ -537,3 +537,110 @@ async def test_token_version_invalidation(
     result = await sync.store_service.async_list_events(LocalListEventsRequest())
     assert len(result.events) == 1
     assert result.events[0].id == "some-event-id-2"
+
+
+@freeze_time("2022-04-05 07:31:02", tz_offset=-7)
+async def test_canceled_events(
+    event_sync_manager_cb: Callable[[], Awaitable[CalendarEventSyncManager]],
+    json_response: ApiResult,
+) -> None:
+    """Test lookup events API."""
+
+    json_response(
+        {
+            "items": [
+                {
+                    "id": "some-event-id-1",
+                    "summary": "Event 1",
+                    "description": "Event description 1",
+                    "start": {
+                        "date": "2022-04-13",
+                    },
+                    "end": {
+                        "date": "2022-04-14",
+                    },
+                    "status": "confirmed",
+                },
+                {
+                    "id": "some-event-id-2",
+                    "summary": "Event 2",
+                    "description": "Event description 2",
+                    "start": {
+                        "date": "2022-04-15",
+                    },
+                    "end": {
+                        "date": "2022-04-20",
+                    },
+                },
+            ],
+            "nextSyncToken": "sync-token-1",
+        }
+    )
+
+    sync = await event_sync_manager_cb()
+    await sync.run()
+    result = await sync.store_service.async_list_events(
+        LocalListEventsRequest(
+            start_time=datetime.datetime.fromisoformat("0001-01-01T00:00:00"),
+        )
+    )
+    assert result.events == [
+        Event(
+            id="some-event-id-1",
+            summary="Event 1",
+            description="Event description 1",
+            start=DateOrDatetime(date=datetime.date(2022, 4, 13)),
+            end=DateOrDatetime(date=datetime.date(2022, 4, 14)),
+        ),
+        Event(
+            id="some-event-id-2",
+            summary="Event 2",
+            description="Event description 2",
+            start=DateOrDatetime(date=datetime.date(2022, 4, 15)),
+            end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
+        ),
+    ]
+    json_response(
+        {
+            "items": [
+                {
+                    "id": "some-event-id-1",
+                    "status": "cancelled",
+                },
+                {
+                    "id": "some-event-id-3",
+                    "summary": "Event 3",
+                    "description": "Event description 3",
+                    "start": {
+                        "date": "2022-04-15",
+                    },
+                    "end": {
+                        "date": "2022-04-20",
+                    },
+                },
+            ],
+            "nextSyncToken": "sync-token-2",
+        }
+    )
+    await sync.run()
+    result = await sync.store_service.async_list_events(
+        LocalListEventsRequest(
+            start_time=datetime.datetime.fromisoformat("0001-01-01T00:00:00"),
+        )
+    )
+    assert result.events == [
+        Event(
+            id="some-event-id-2",
+            summary="Event 2",
+            description="Event description 2",
+            start=DateOrDatetime(date=datetime.date(2022, 4, 15)),
+            end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
+        ),
+        Event(
+            id="some-event-id-3",
+            summary="Event 3",
+            description="Event description 3",
+            start=DateOrDatetime(date=datetime.date(2022, 4, 15)),
+            end=DateOrDatetime(date=datetime.date(2022, 4, 20)),
+        ),
+    ]
diff --git a/tests/test_model.py b/tests/test_model.py
index c5dd172..ec4e5fe 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -6,7 +6,7 @@
 import pytest
 from pydantic import ValidationError
 
-from gcal_sync.model import Calendar, Event
+from gcal_sync.model import Calendar, Event, EventStatusEnum
 
 
 def test_calendar() -> None:
@@ -36,7 +36,7 @@ def test_event_with_date() -> None:
         {
             "kind": "calendar#event",
             "id": "some-event-id",
-            "status": "some-status",
+            "status": "confirmed",
             "summary": "Event summary",
             "description": "Event description",
             "location": "Event location",
@@ -52,6 +52,7 @@ def test_event_with_date() -> None:
     assert event.id == "some-event-id"
     assert event.summary == "Event summary"
     assert event.description == "Event description"
+    assert event.status == EventStatusEnum.CONFIRMED
     assert event.location == "Event location"
     assert event.transparency == "transparent"
     assert event.start
@@ -73,7 +74,6 @@ def test_event_datetime() -> None:
         {
             "kind": "calendar#event",
             "id": "some-event-id",
-            "status": "some-status",
             "summary": "Event summary",
             "start": {
                 "dateTime": "2022-04-12T16:30:00-08:00",
@@ -86,6 +86,7 @@ def test_event_datetime() -> None:
     assert event.id == "some-event-id"
     assert event.summary == "Event summary"
     assert event.description is None
+    assert event.status == EventStatusEnum.CONFIRMED
     assert event.location is None
     tzinfo = datetime.timezone(datetime.timedelta(hours=-8))
 
@@ -113,7 +114,6 @@ def test_invalid_datetime() -> None:
     base_event = {
         "kind": "calendar#event",
         "id": "some-event-id",
-        "status": "some-status",
         "summary": "Event summary",
         "end": {
             "dateTime": "2022-04-12T17:00:00-08:00",
@@ -152,7 +152,6 @@ def test_event_timezone() -> None:
         {
             "kind": "calendar#event",
             "id": "some-event-id",
-            "status": "some-status",
             "summary": "Event summary",
             "start": {
                 "dateTime": "2022-04-12T16:30:00",
@@ -199,7 +198,6 @@ def test_event_utc() -> None:
         {
             "kind": "calendar#event",
             "id": "some-event-id",
-            "status": "some-status",
             "summary": "Event summary",
             "start": {
                 "dateTime": "2022-04-12T16:30:00Z",
@@ -318,3 +316,31 @@ def test_event_timezone_comparison_zimetone_not_used() -> None:
     assert dt1.astimezone(datetime.timezone.utc) == dt2.astimezone(
         datetime.timezone.utc
     )
+
+
+def test_event_cancelled() -> None:
+    """Exercise basic parsing of an event API response."""
+
+    event = Event.parse_obj(
+        {
+            "id": "some-event-id",
+            "status": "cancelled",
+        }
+    )
+    assert event.id == "some-event-id"
+    assert not event.summary
+    assert event.description is None
+    assert event.location is None
+    assert event.status == EventStatusEnum.CANCELLED
+
+
+def test_required_fields() -> None:
+    """Exercise required fields for normal non-deleted events."""
+
+    with pytest.raises(ValidationError):
+        Event.parse_obj(
+            {
+                "id": "some-event-id",
+                "status": "confirmed",
+            }
+        )