Files
odysseus/tests/test_calendar_recurrence.py
AzaelMew 7023468cea Fix YEARLY recurring CalDAV events only showing on DTSTART year (#179)
* 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>
2026-06-01 13:42:44 +09:00

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"