* Fix YEARLY recurring CalDAV events only showing on DTSTART year (#170) Recurring events with RRULE:FREQ=YEARLY only appeared in the calendar on the year matching DTSTART, not in subsequent years. The list_events query filtered by , which excludes recurring events whose original dtend (e.g. 2019-07-22) falls before the requested window (e.g. 2026). Fix: split the query into two branches — non-recurring events still require window overlap, but recurring events (with non-empty RRULE) are fetched by dtstart < end_dt alone. A new helper, _expand_rrule_occurrences(), uses dateutil.rrule to expand each recurring event into individual occurrence dicts within the requested date range, so YEARLY/WEEKLY/MONTHLY events render correctly across all years. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * recurrence: compound UIDs, frontend fixes, python-dateutil req, tests - Replace _expand_rrule_occurrences with _expand_rrule that emits stable compound UIDs ({base_uid}::{date_or_datetime}) so the frontend can distinguish occurrences from the same series. Non-recurring events pass through with is_recurrence=false and series_uid=uid. - Add _resolve_base_uid() to extract the base series UID from compound UIDs — used by PUT/DELETE /api/calendar/events/{uid} and the manage_calendar tool so edits/deletes always target the base row. - Update manage_calendar tool to import and use _resolve_base_uid. - Frontend _updateEvent / _deleteEvent: detect compound UIDs and invalidate localStorage cache after success so stale sibling occurrences aren't shown. - Add python-dateutil to requirements.txt as an explicit dependency. - Add 14 regression tests in tests/test_calendar_recurrence.py covering _resolve_base_uid edge cases, _expand_rrule with yearly/weekly/monthly/all-day/bad-rrule, unique UIDs, and metadata inheritance. - Merge upstream's cleaner SQLAlchemy or_/and_ query pattern. * recurrence: overlapping malformed-RRULE, exclusive end, multi-day crossings Fix three edge cases in _expand_rrule: 1. Malformed-RRULE fallback now checks window overlap. list_events fetches recurring rows with only dtstart < end_dt, so a broken old recurring event could appear in unrelated future windows. Now fallback returns [] unless the base event's dtstart/dtend actually intersect [start, end). 2. Exclusive end boundary. rule.between(start, end, inc=True) was inclusive on end, but the route contract and non-recurring SQL filter both use [start, end). Added occ_start >= end guard. 3. Multi-day crossings. A recurring occurrence that starts before the window but ends inside it was missed (only occ_start was checked). Now expands from start - duration and filters by occ_start < end AND occ_end > start, matching non-recurring overlap behavior. Tests: +4 tests for these cases (18 total) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
11 KiB
Python
322 lines
11 KiB
Python
"""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"
|