diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py index 1d49d24a..56f83495 100644 --- a/server/reflector/services/ics_sync.py +++ b/server/reflector/services/ics_sync.py @@ -356,6 +356,44 @@ class ICSSyncService: time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync return time_since_sync.total_seconds() >= room.ics_fetch_interval + def _event_data_changed(self, existing: CalendarEvent, new_data: EventData) -> bool: + """Check if event data has changed by comparing relevant fields. + + IMPORTANT: When adding fields to CalendarEvent/EventData, update this method + and the _COMPARED_FIELDS set below for runtime validation. + """ + # Fields that come from ICS and should trigger updates when changed + _COMPARED_FIELDS = { + "title", + "description", + "start_time", + "end_time", + "location", + "attendees", + "ics_raw_data", + } + + # Runtime exhaustiveness check: ensure we're comparing all EventData fields + event_data_fields = set(EventData.__annotations__.keys()) - {"ics_uid"} + if event_data_fields != _COMPARED_FIELDS: + missing = event_data_fields - _COMPARED_FIELDS + extra = _COMPARED_FIELDS - event_data_fields + raise RuntimeError( + f"_event_data_changed() field mismatch: " + f"missing={missing}, extra={extra}. " + f"Update the comparison logic when adding/removing fields." + ) + + return ( + existing.title != new_data["title"] + or existing.description != new_data["description"] + or existing.start_time != new_data["start_time"] + or existing.end_time != new_data["end_time"] + or existing.location != new_data["location"] + or existing.attendees != new_data["attendees"] + or existing.ics_raw_data != new_data["ics_raw_data"] + ) + async def _sync_events_to_database( self, room_id: str, events: list[EventData] ) -> SyncStats: @@ -371,11 +409,14 @@ class ICSSyncService: ) if existing: - updated += 1 + # Only count as updated if data actually changed + if self._event_data_changed(existing, event_data): + updated += 1 + await calendar_events_controller.upsert(calendar_event) else: created += 1 + await calendar_events_controller.upsert(calendar_event) - await calendar_events_controller.upsert(calendar_event) current_ics_uids.append(event_data["ics_uid"]) # Soft delete events that are no longer in calendar diff --git a/server/tests/test_ics_sync.py b/server/tests/test_ics_sync.py index 6ead3868..cac10848 100644 --- a/server/tests/test_ics_sync.py +++ b/server/tests/test_ics_sync.py @@ -291,3 +291,43 @@ async def test_ics_sync_service_error_handling(): result = await sync_service.sync_room_calendar(room) assert result["status"] == "error" assert "Network error" in result["error"] + + +@pytest.mark.asyncio +async def test_event_data_changed_exhaustiveness(): + """Test that _event_data_changed compares all EventData fields (except ics_uid). + + This test ensures programmers don't forget to update the comparison logic + when adding new fields to EventData/CalendarEvent. + """ + from reflector.services.ics_sync import EventData + + sync_service = ICSSyncService() + + from reflector.db.calendar_events import CalendarEvent + + now = datetime.now(timezone.utc) + event_data: EventData = { + "ics_uid": "test-123", + "title": "Test", + "description": "Desc", + "location": "Loc", + "start_time": now, + "end_time": now + timedelta(hours=1), + "attendees": [], + "ics_raw_data": "raw", + } + + existing = CalendarEvent( + room_id="room1", + **event_data, + ) + + # Will raise RuntimeError if fields are missing from comparison + result = sync_service._event_data_changed(existing, event_data) + assert result is False + + modified_data = event_data.copy() + modified_data["title"] = "Changed Title" + result = sync_service._event_data_changed(existing, modified_data) + assert result is True