diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index bd2b443..137fde9 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -16,6 +16,23 @@ from src.auth_helpers import get_current_user, require_user logger = logging.getLogger(__name__) + +def _ics_naive_dtstart(dt): + """Naive value matching how import_ics STORES CalendarEvent.dtstart. + + Timed tz-aware events are stored as UTC with tzinfo stripped, all-day + dates as midnight datetimes, naive datetimes unchanged. The ICS dedup + must compute the same value or a re-import never matches the stored row. + """ + if isinstance(dt, datetime): + if dt.tzinfo is not None: + from datetime import timezone as _tz + return dt.astimezone(_tz.utc).replace(tzinfo=None) + return dt + if isinstance(dt, date): + return datetime(dt.year, dt.month, dt.day) + return dt + # Single-user fallback identity. Used only when: # 1. The app is configured for single-user (no auth middleware), AND # 2. The request didn't resolve to an authenticated user. @@ -1023,7 +1040,12 @@ def setup_calendar_routes() -> APIRouter: source_uid = str(comp.get("uid", "")) or None if source_uid: src_dtstart = dtstart.dt - naive_src = src_dtstart.replace(tzinfo=None) if hasattr(src_dtstart, 'tzinfo') and src_dtstart.tzinfo else src_dtstart + # Normalize to the SAME naive form import_ics stores, so a + # re-import of a tz-aware event matches the existing row. + # The old code stripped tzinfo WITHOUT converting to UTC + # (wall clock), while storage converts to UTC first, so + # every re-import of a TZID event created a duplicate. + naive_src = _ics_naive_dtstart(src_dtstart) existing = ( db.query(CalendarEvent) .filter( diff --git a/tests/test_ics_import_dedup_tz.py b/tests/test_ics_import_dedup_tz.py new file mode 100644 index 0000000..47c52fd --- /dev/null +++ b/tests/test_ics_import_dedup_tz.py @@ -0,0 +1,43 @@ +"""ICS re-import must dedup tz-aware timed events. + +import_ics stores a tz-aware DTSTART as naive UTC (e.g. 09:00 America/ +New_York becomes 13:00), but the dedup key stripped tzinfo WITHOUT the UTC +conversion (kept 09:00 wall clock). So the dedup query never matched the +stored row and every re-import of a TZID event inserted a duplicate. The +shared _ics_naive_dtstart helper now drives both. +""" +from datetime import date, datetime, timezone, timedelta + +import pytest + +pytest.importorskip("sqlalchemy") + +from routes.calendar_routes import _ics_naive_dtstart + + +def test_tz_aware_dedup_key_matches_utc_storage_form(): + zi = pytest.importorskip("zoneinfo") + ny = zi.ZoneInfo("America/New_York") + dt = datetime(2026, 6, 15, 9, 0, tzinfo=ny) # EDT = UTC-4 -> 13:00 UTC + assert _ics_naive_dtstart(dt) == datetime(2026, 6, 15, 13, 0) + + +def test_fixed_offset_dedup_key_is_utc(): + dt = datetime(2026, 6, 15, 9, 0, tzinfo=timezone(timedelta(hours=2))) + assert _ics_naive_dtstart(dt) == datetime(2026, 6, 15, 7, 0) + + +def test_naive_datetime_unchanged(): + dt = datetime(2026, 6, 15, 9, 0) + assert _ics_naive_dtstart(dt) == dt + + +def test_all_day_date_becomes_midnight_datetime(): + assert _ics_naive_dtstart(date(2026, 6, 15)) == datetime(2026, 6, 15, 0, 0) + + +def test_dedup_key_equals_storage_conversion(): + zi = pytest.importorskip("zoneinfo") + dt_val = datetime(2026, 11, 1, 9, 30, tzinfo=zi.ZoneInfo("America/New_York")) + stored = dt_val.astimezone(timezone.utc).replace(tzinfo=None) + assert _ics_naive_dtstart(dt_val) == stored