"""Regression tests for calendar recurrence expansion. Tests _expand_rrule and _resolve_base_uid — imported directly from routes/calendar_routes using the same stub-friendly import pattern as test_null_owner_gates.py. No live DB or FastAPI test client needed. """ from datetime import datetime, timedelta from types import SimpleNamespace import pytest from tests.test_null_owner_gates import _import_calendar_helpers # ── _resolve_base_uid ────────────────────────────────────────────────── def test_resolve_base_uid_plain_passthrough(): cal = _import_calendar_helpers() assert cal._resolve_base_uid("evt-123") == "evt-123" def test_resolve_base_uid_compound_strips_suffix_date(): cal = _import_calendar_helpers() assert cal._resolve_base_uid("evt-123::2026-06-15") == "evt-123" def test_resolve_base_uid_compound_strips_suffix_datetime(): cal = _import_calendar_helpers() assert cal._resolve_base_uid("evt-123::2026-06-15T09:00") == "evt-123" def test_resolve_base_uid_rejects_empty(): cal = _import_calendar_helpers() with pytest.raises(ValueError, match="empty uid"): cal._resolve_base_uid("") def test_resolve_base_uid_rejects_missing_base(): cal = _import_calendar_helpers() with pytest.raises(ValueError, match="malformed compound UID"): cal._resolve_base_uid("::2026-06-15") # ── _expand_rrule ────────────────────────────────────────────────────── _MOCK_CAL = SimpleNamespace(name="Personal", color="#5b8abf") def _make_event(**overrides): """Build a dict-shaped mock CalendarEvent for _expand_rrule.""" defaults = { "uid": "evt-test-001", "summary": "Test Event", "dtstart": datetime(2026, 6, 1, 9, 0), "dtend": datetime(2026, 6, 1, 10, 0), "all_day": False, "is_utc": False, "rrule": "", "calendar": _MOCK_CAL.name, "calendar_id": "cal-001", "color": None, "description": "", "location": "", "event_type": None, "importance": "normal", } defaults.update(overrides) ev = SimpleNamespace(**defaults) ev.calendar = _MOCK_CAL return ev def test_expand_non_recurring_returns_single(): """Non-recurring events pass through unchanged with series_uid=uid.""" cal = _import_calendar_helpers() ev = _make_event(rrule="") results = cal._expand_rrule(ev, datetime(2026, 5, 1), datetime(2026, 7, 1)) assert len(results) == 1 r = results[0] assert r["uid"] == "evt-test-001" assert r["series_uid"] == "evt-test-001" assert r["is_recurrence"] is False def test_expand_yearly_old_dtstart_later_year_single_occurrence(): """Create an old DTSTART + FREQ=YEARLY, query a later year, verify exactly one occurrence is returned. This is the explicit regression case from PR review feedback. """ cal = _import_calendar_helpers() ev = _make_event( uid="evt-bday-001", summary="Annual Review", dtstart=datetime(2020, 4, 15, 10, 0), dtend=datetime(2020, 4, 15, 11, 0), rrule="FREQ=YEARLY", ) # Query year 2028 — should find the 2028-04-15 occurrence only results = cal._expand_rrule(ev, datetime(2028, 1, 1), datetime(2029, 1, 1)) assert len(results) == 1, ( f"Expected exactly 1 yearly occurrence in 2028, got {len(results)}: " f"{[r['uid'] for r in results]}" ) r = results[0] assert r["uid"] == "evt-bday-001::2028-04-15T10:00" assert r["dtstart"] == "2028-04-15T10:00:00" assert r["series_uid"] == "evt-bday-001" assert r["is_recurrence"] is True assert r["summary"] == "Annual Review" def test_expand_yearly_narrow_window_after_dtstart_returns_one(): """DTSTART=2020, query just two months in 2029 — should return exactly one occurrence (the one that falls in that window). """ cal = _import_calendar_helpers() ev = _make_event( uid="evt-ann", dtstart=datetime(2020, 3, 1), dtend=datetime(2020, 3, 2), all_day=True, rrule="FREQ=YEARLY", ) results = cal._expand_rrule(ev, datetime(2029, 1, 1), datetime(2029, 4, 1)) assert len(results) == 1 assert results[0]["uid"] == "evt-ann::2029-03-01" assert results[0]["all_day"] is True def test_expand_yearly_strict_before_window_returns_empty(): """DTSTART=2020, query a window that ends before the yearly occurrence in that year. Should return zero. """ cal = _import_calendar_helpers() ev = _make_event( uid="evt-late", dtstart=datetime(2020, 12, 25), dtend=datetime(2020, 12, 26), all_day=True, rrule="FREQ=YEARLY", ) results = cal._expand_rrule(ev, datetime(2026, 1, 1), datetime(2026, 6, 1)) assert len(results) == 0 def test_expand_yearly_strict_after_window_returns_empty(): """DTSTART=2020. Query a window that starts after the occurrence in that year. Should return zero. """ cal = _import_calendar_helpers() ev = _make_event( uid="evt-early", dtstart=datetime(2020, 1, 15), dtend=datetime(2020, 1, 16), all_day=True, rrule="FREQ=YEARLY", ) results = cal._expand_rrule(ev, datetime(2026, 6, 1), datetime(2026, 12, 31)) assert len(results) == 0 def test_expand_weekly_unique_no_overwrites(): """Multiple occurrences from the same series must have unique UIDs so _allEvents[uid] = ev doesn't overwrite earlier ones. """ cal = _import_calendar_helpers() ev = _make_event( uid="evt-wk", dtstart=datetime(2026, 6, 1, 9, 0), dtend=datetime(2026, 6, 1, 10, 0), rrule="FREQ=WEEKLY;BYDAY=MO,WE,FR", ) results = cal._expand_rrule(ev, datetime(2026, 6, 1), datetime(2026, 7, 1)) # June 2026 has 4 Mondays, 5 Wednesdays, 4 Fridays = 13 occurrences assert len(results) >= 10 # sanity lower bound uids = [r["uid"] for r in results] assert len(uids) == len(set(uids)), f"Duplicate UIDs found: {uids}" for r in results: assert r["series_uid"] == "evt-wk" assert r["is_recurrence"] is True def test_expand_monthly_all_day(): cal = _import_calendar_helpers() ev = _make_event( uid="evt-rent", dtstart=datetime(2026, 1, 1), dtend=datetime(2026, 1, 2), all_day=True, rrule="FREQ=MONTHLY", ) results = cal._expand_rrule(ev, datetime(2026, 1, 1), datetime(2026, 12, 31)) assert len(results) == 12 for r in results: assert r["uid"].startswith("evt-rent::") assert r["all_day"] is True def test_expand_bad_rrule_graceful(): """Malformed rrule should fall back to returning the base event, but only when the base event overlaps the requested window.""" cal = _import_calendar_helpers() ev = _make_event( uid="evt-broken", rrule="FREQ=GARBAGE", ) # Base event (2026-06-01) falls inside the window — should appear results = cal._expand_rrule(ev, datetime(2026, 1, 1), datetime(2026, 12, 31)) assert len(results) == 1 assert results[0]["uid"] == "evt-broken" assert results[0]["is_recurrence"] is False def test_expand_bad_rrule_fallback_rejects_non_overlapping(): """Malformed rrule with a base event outside the requested window must return zero results, not leak the event into an unrelated range.""" cal = _import_calendar_helpers() ev = _make_event( uid="evt-old-broken", dtstart=datetime(2020, 1, 1, 9, 0), dtend=datetime(2020, 1, 1, 10, 0), rrule="FREQ=GARBAGE", ) # Query a far-future window that the base event doesn't overlap results = cal._expand_rrule(ev, datetime(2030, 1, 1), datetime(2030, 2, 1)) assert len(results) == 0, ( f"Malformed rrule base event outside window should return empty, " f"got {len(results)}: {[r['uid'] for r in results]}" ) def test_expand_exclusive_end_boundary(): """An occurrence whose start equals the window end must be excluded. The contract is [start, end), same as the non-recurring SQL filter.""" cal = _import_calendar_helpers() ev = _make_event( uid="evt-daily", dtstart=datetime(2026, 6, 1, 9, 0), dtend=datetime(2026, 6, 1, 10, 0), rrule="FREQ=DAILY", ) # Query [Jun 1, Jun 5) — occurrences on Jun 1-4 only results = cal._expand_rrule(ev, datetime(2026, 6, 1), datetime(2026, 6, 5)) uids = [r["uid"] for r in results] assert len(results) == 4, f"Expected 4 (Jun 1-4), got {len(results)}: {uids}" assert "evt-daily::2026-06-05T09:00" not in uids, "Jun 5 is at end boundary, must be excluded" def test_expand_multi_day_crossing_range_start(): """A multi-day occurrence that starts before the window but ends inside it must be included (matching non-recurring overlap: dtend > start).""" cal = _import_calendar_helpers() ev = _make_event( uid="evt-weekly-multi", summary="Weekend Trip", dtstart=datetime(2026, 5, 29, 18, 0), # Friday evening dtend=datetime(2026, 6, 1, 12, 0), # Monday noon rrule="FREQ=WEEKLY", ) # Query the Monday window — the occurrence starts Fri but ends Mon, # so it overlaps the query. results = cal._expand_rrule(ev, datetime(2026, 6, 1), datetime(2026, 6, 2)) # The 2026-06-05 occurrence starts Fri Jun 5 and ends Mon Jun 8 — # that crosses [Jun 1, Jun 2): occ_start=2026-06-05 >= end=2026-06-02 → excluded. # The 2026-05-29 occurrence starts Fri May 29 and ends Mon Jun 1 — # occ_end=2026-06-01T12:00 > start=2026-06-01 → included. assert len(results) == 1, ( f"Expected 1 occurrence crossing into the window, got {len(results)}: " f"{[r['uid'] for r in results]}" ) assert results[0]["uid"] == "evt-weekly-multi::2026-05-29T18:00" def test_expand_multi_day_fully_before_window(): """A multi-day occurrence that ends exactly at the window start must be excluded (occ_end <= start).""" cal = _import_calendar_helpers() ev = _make_event( uid="evt-multi", dtstart=datetime(2026, 5, 29, 18, 0), dtend=datetime(2026, 6, 1, 0, 0), # ends at midnight Jun 1 rrule="FREQ=WEEKLY", ) # Query starting Jun 1 midnight — occ_end <= start, excluded results = cal._expand_rrule(ev, datetime(2026, 6, 1), datetime(2026, 6, 8)) assert len(results) == 1 # only the next week's occurrence (Jun 5-8) assert results[0]["uid"] == "evt-multi::2026-06-05T18:00" def test_expand_metadata_inheritance(): """Occurrence dicts must carry the base event's metadata (summary, importance, event_type, color, location).""" cal = _import_calendar_helpers() ev = _make_event( uid="evt-meta", summary="Board Meeting", dtstart=datetime(2026, 1, 1, 14, 0), dtend=datetime(2026, 1, 1, 16, 0), rrule="FREQ=MONTHLY", event_type="work", importance="critical", location="Room 42", ) results = cal._expand_rrule(ev, datetime(2026, 1, 1), datetime(2026, 3, 1)) assert len(results) == 2 # Jan + Feb for r in results: assert r["summary"] == "Board Meeting" assert r["importance"] == "critical" assert r["event_type"] == "work" assert r["location"] == "Room 42"