mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-28 15:45:22 -04:00
a6400c10af
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.)
171 lines
5.9 KiB
Python
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
|