Files
odysseus/tests/test_calendar_import_zero_duration.py
T
Ashvin a6400c10af fix(calendar): keep imported events with non-positive duration visible (#4484)
A single-day all-day event whose source writes DTEND equal to DTSTART
(treating DTEND as an inclusive bound rather than the RFC 5545 exclusive
one) was stored verbatim as a zero-duration row. list_events selects
events overlapping the window with `dtstart < end AND dtend > start`, so
that row is filtered out for any window starting at or after its date and
the event never appears, even though the import reported success.

Events created via the API never hit this because creation always
synthesizes a positive duration; only the two import paths can persist a
non-positive one. Clamp a non-positive end at import (import_ics and the
CalDAV pull) to the same default span used when DTEND is absent: one day
for all-day events, one hour otherwise.

Also repair the persisted state for users who already imported before this
clamp existed. Their stored zero-duration row is invisible, and re-importing
the same ICS hit the duplicate branch and skipped without touching it, so
the event stayed hidden. The duplicate branch now backfills the clamp onto
the matched row before skipping, and the response reports a `repaired` count.
(The CalDAV pull already rewrites dtend on re-sync, so it self-heals.)
2026-06-27 16:52:40 +02:00

171 lines
5.9 KiB
Python

"""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