diff --git a/routes/calendar_routes.py b/routes/calendar_routes.py index 87397e6fc..65f4339f7 100644 --- a/routes/calendar_routes.py +++ b/routes/calendar_routes.py @@ -34,6 +34,24 @@ def _ics_naive_dtstart(dt): return datetime(dt.year, dt.month, dt.day) return dt + +def _ensure_positive_duration(start_dt, end_dt, all_day): + """Clamp an imported event's end so it has a positive duration. + + Some .ics exporters write a single-day all-day event with DTEND equal to + DTSTART (treating DTEND as inclusive rather than the RFC 5545 exclusive + bound). Stored verbatim that produces a zero-duration row, which the + list_events overlap filter (dtstart < end AND dtend > start) silently + drops — the event never appears on the calendar even though the web UI + would otherwise show it. Normalize a non-positive end to the same default + span used when DTEND is absent: one day for all-day events, one hour + otherwise. + """ + if end_dt <= start_dt: + return start_dt + (timedelta(days=1) if all_day else timedelta(hours=1)) + return end_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. @@ -1226,7 +1244,7 @@ def setup_calendar_routes() -> APIRouter: db.commit() db.refresh(target_cal) - imported = skipped = 0 + imported = skipped = repaired = 0 for comp in cal_data.walk(): if comp.name != "VEVENT": continue @@ -1262,6 +1280,18 @@ def setup_calendar_routes() -> APIRouter: .first() ) if existing: + # An import predating the clamp below may have stored + # this same event with a non-positive duration, which + # the list_events overlap filter hides. Re-importing + # lands here and would skip without touching that row, + # so the event would stay invisible. Backfill the clamp + # onto the stored row before skipping it. + fixed_end = _ensure_positive_duration( + existing.dtstart, existing.dtend, bool(existing.all_day) + ) + if fixed_end != existing.dtend: + existing.dtend = fixed_end + repaired += 1 skipped += 1 continue @@ -1295,6 +1325,8 @@ def setup_calendar_routes() -> APIRouter: else: end_dt = start_dt + timedelta(hours=1) + end_dt = _ensure_positive_duration(start_dt, end_dt, all_day) + ev = CalendarEvent( uid=uid_val, calendar_id=target_cal.id, @@ -1315,6 +1347,7 @@ def setup_calendar_routes() -> APIRouter: "ok": True, "imported": imported, "skipped": skipped, + "repaired": repaired, "calendar": cal_display, "calendar_id": target_cal.id, } diff --git a/src/caldav_sync.py b/src/caldav_sync.py index 4cf3c1e5a..35b233080 100644 --- a/src/caldav_sync.py +++ b/src/caldav_sync.py @@ -274,6 +274,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i # the integrations form still works, sync just no-ops with an error. from caldav.lib.error import AuthorizationError, NotFoundError from core.database import CalendarCal, CalendarEvent, SessionLocal + from routes.calendar_routes import _ensure_positive_duration result = {"calendars": 0, "events": 0, "deleted": 0, "errors": []} @@ -390,6 +391,11 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i end_dt = start_dt + timedelta(days=1) else: end_dt = start_dt + timedelta(hours=1) + # A synced event with DTEND <= DTSTART (e.g. a single-day + # all-day event whose source wrote DTEND equal to DTSTART) + # would be stored zero-duration and silently dropped by the + # list_events overlap filter. Clamp to a positive span. + end_dt = _ensure_positive_duration(start_dt, end_dt, all_day) # is_utc reflects whether the source carried a TZ # we converted from. All-day = no TZ semantics. diff --git a/tests/test_calendar_import_zero_duration.py b/tests/test_calendar_import_zero_duration.py new file mode 100644 index 000000000..0b0b0d85d --- /dev/null +++ b/tests/test_calendar_import_zero_duration.py @@ -0,0 +1,170 @@ +"""Imported events with a non-positive duration must not vanish from the list. + +list_events selects events that overlap the query window with +``dtstart < end AND dtend > start``. An import that stores ``dtend == dtstart`` +(a single-day all-day event whose source wrote DTEND equal to DTSTART, treating +it as an inclusive bound) is therefore silently dropped — the event never shows +on the calendar even though it was imported. import_ics now clamps such an end +to a positive span, matching the default used when DTEND is absent. +""" +import asyncio +import sys +from datetime import datetime +from types import SimpleNamespace + +import pytest + +pytest.importorskip("sqlalchemy") +pytest.importorskip("icalendar") + +from tests.helpers.import_state import clear_fake_database_modules +from tests.helpers.sqlite_db import make_temp_sqlite + +clear_fake_database_modules() + +import core.database as cdb # noqa: E402 +import routes.calendar_routes as cr # noqa: E402 +from core.database import CalendarCal, CalendarEvent # noqa: E402 +from routes.calendar_routes import _ensure_positive_duration # noqa: E402 + +_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata) + + +@pytest.fixture(autouse=True) +def _bind_temp_db(monkeypatch): + monkeypatch.setattr(cdb, "SessionLocal", _TS) + monkeypatch.setattr(cr, "SessionLocal", _TS) + monkeypatch.setattr(cr, "require_user", lambda request: "tester") + yield + + +# ---- pure helper ----------------------------------------------------------- + +def test_all_day_same_date_end_clamped_to_one_day(): + start = datetime(2026, 6, 20) + assert _ensure_positive_duration(start, start, True) == datetime(2026, 6, 21) + + +def test_timed_non_positive_end_clamped_to_one_hour(): + start = datetime(2026, 6, 20, 9, 0) + assert _ensure_positive_duration(start, start, False) == datetime(2026, 6, 20, 10, 0) + # reversed end (dtend < dtstart) is also normalized + earlier = datetime(2026, 6, 20, 8, 0) + assert _ensure_positive_duration(start, earlier, False) == datetime(2026, 6, 20, 10, 0) + + +def test_positive_duration_end_is_unchanged(): + start = datetime(2026, 6, 20, 9, 0) + end = datetime(2026, 6, 20, 17, 0) + assert _ensure_positive_duration(start, end, False) is end + + +# ---- behavioral: import -> list ------------------------------------------- + +def _ics(dtstart_date, dtend_date): + return ( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" + "BEGIN:VEVENT\r\nUID:holiday-1\r\nSUMMARY:Public Holiday\r\n" + f"DTSTART;VALUE=DATE:{dtstart_date}\r\nDTEND;VALUE=DATE:{dtend_date}\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" + ).encode() + + +class _FakeUpload: + def __init__(self, content, filename="cal.ics"): + self._content = content + self.filename = filename + + async def read(self, n=-1): + return self._content + + +def _endpoints(): + router = cr.setup_calendar_routes() + eps = {} + for route in router.routes: + if route.path == "/api/calendar/import" and "POST" in route.methods: + eps["import"] = route.endpoint + if route.path == "/api/calendar/events" and "GET" in route.methods: + eps["list"] = route.endpoint + return eps + + +def _request(): + return SimpleNamespace(state=SimpleNamespace(current_user="tester")) + + +def test_single_day_all_day_event_with_same_date_end_appears_in_list(): + eps = _endpoints() + res = asyncio.run(eps["import"]( + _request(), file=_FakeUpload(_ics("20260620", "20260620")), calendar_name="A", + )) + assert res["imported"] == 1 + + out = asyncio.run(eps["list"]( + _request(), start="2026-06-20T00:00:00", end="2026-06-23T00:00:00", + )) + assert [e["summary"] for e in out["events"]] == ["Public Holiday"] + + +def test_normal_multi_day_all_day_event_still_appears(): + # Regression: a well-formed exclusive DTEND must keep working. + eps = _endpoints() + res = asyncio.run(eps["import"]( + _request(), file=_FakeUpload(_ics("20260710", "20260711")), calendar_name="B", + )) + assert res["imported"] == 1 + + out = asyncio.run(eps["list"]( + _request(), start="2026-07-10T00:00:00", end="2026-07-12T00:00:00", + )) + assert [e["summary"] for e in out["events"]] == ["Public Holiday"] + + +def test_reimport_repairs_legacy_zero_duration_row(): + # A row persisted by an import that predates the duration clamp has + # dtend == dtstart and is invisible to list_events. Re-importing the same + # ICS hits the duplicate branch; it must repair the stored row in place + # rather than skip past it, so the event becomes visible. + eps = _endpoints() + db = cr.SessionLocal() + try: + cal = CalendarCal(id="legacy-cal", owner="tester", name="C", source="import") + db.add(cal) + db.add(CalendarEvent( + uid="legacy-row", + calendar_id="legacy-cal", + summary="Public Holiday", + dtstart=datetime(2026, 8, 1), + dtend=datetime(2026, 8, 1), # zero duration: the legacy bug + all_day=True, + )) + db.commit() + finally: + db.close() + + # Confirm the seeded row is invisible (proves the bug it repairs). + before = asyncio.run(eps["list"]( + _request(), start="2026-08-01T00:00:00", end="2026-08-04T00:00:00", + )) + assert before["events"] == [] + + res = asyncio.run(eps["import"]( + _request(), file=_FakeUpload(_ics("20260801", "20260801")), calendar_name="C", + )) + # Duplicate, so nothing new is imported, but the stale row is repaired. + assert res["imported"] == 0 + assert res["skipped"] == 1 + assert res["repaired"] == 1 + + after = asyncio.run(eps["list"]( + _request(), start="2026-08-01T00:00:00", end="2026-08-04T00:00:00", + )) + assert [e["summary"] for e in after["events"]] == ["Public Holiday"] + + # Re-importing once more is a no-op: the row is already positive-duration. + res2 = asyncio.run(eps["import"]( + _request(), file=_FakeUpload(_ics("20260801", "20260801")), calendar_name="C", + )) + assert res2["repaired"] == 0 + assert res2["skipped"] == 1