test: localize calendar recurrence helper import (#4944)

* test: localize calendar recurrence helper import

* test: share calendar route import helper
This commit is contained in:
Alexandre Teixeira
2026-06-28 18:04:15 +01:00
committed by GitHub
parent 927b1f7ecf
commit bad9ec2f9c
6 changed files with 47 additions and 49 deletions
+8
View File
@@ -0,0 +1,8 @@
"""Shared imports for calendar route tests."""
def import_calendar_routes():
"""Import the calendar routes module after test stubs are installed."""
import routes.calendar_routes as cal
return cal
+4 -4
View File
@@ -13,7 +13,7 @@ The fallback now normalizes to UTC and strips tz, exactly like the ISO path.
""" """
import pytest import pytest
from tests.test_null_owner_gates import _import_calendar_helpers from tests.helpers.calendar_routes import import_calendar_routes
# Inputs datetime.fromisoformat() rejects (so they hit the dateutil fallback) # Inputs datetime.fromisoformat() rejects (so they hit the dateutil fallback)
# but that carry a numeric UTC offset dateutil resolves to tz-aware. # but that carry a numeric UTC offset dateutil resolves to tz-aware.
@@ -25,7 +25,7 @@ _OFFSET_NONISO = [
@pytest.mark.parametrize("s", _OFFSET_NONISO) @pytest.mark.parametrize("s", _OFFSET_NONISO)
def test_parse_dt_dateutil_fallback_returns_naive(s): def test_parse_dt_dateutil_fallback_returns_naive(s):
cal = _import_calendar_helpers() cal = import_calendar_routes()
d = cal._parse_dt(s) d = cal._parse_dt(s)
assert d.tzinfo is None, f"{s!r} leaked tz-aware: {d!r}" assert d.tzinfo is None, f"{s!r} leaked tz-aware: {d!r}"
# +0900 14:00 -> 05:00 UTC, naive. # +0900 14:00 -> 05:00 UTC, naive.
@@ -34,13 +34,13 @@ def test_parse_dt_dateutil_fallback_returns_naive(s):
@pytest.mark.parametrize("s", _OFFSET_NONISO) @pytest.mark.parametrize("s", _OFFSET_NONISO)
def test_parse_dt_pair_fallback_returns_naive(s): def test_parse_dt_pair_fallback_returns_naive(s):
cal = _import_calendar_helpers() cal = import_calendar_routes()
dt, _is_utc = cal._parse_dt_pair(s) dt, _is_utc = cal._parse_dt_pair(s)
assert dt.tzinfo is None, f"{s!r} leaked tz-aware via _parse_dt_pair: {dt!r}" assert dt.tzinfo is None, f"{s!r} leaked tz-aware via _parse_dt_pair: {dt!r}"
def test_parse_dt_naive_input_unchanged(): def test_parse_dt_naive_input_unchanged():
cal = _import_calendar_helpers() cal = import_calendar_routes()
d = cal._parse_dt("January 5, 2026 14:00") # no offset -> stays as parsed d = cal._parse_dt("January 5, 2026 14:00") # no offset -> stays as parsed
assert d.tzinfo is None assert d.tzinfo is None
assert (d.hour, d.minute) == (14, 0) assert (d.hour, d.minute) == (14, 0)
+22 -22
View File
@@ -1,8 +1,8 @@
"""Regression tests for calendar recurrence expansion. """Regression tests for calendar recurrence expansion.
Tests _expand_rrule and _resolve_base_uid — imported directly from Tests _expand_rrule and _resolve_base_uid — imported directly from
routes/calendar_routes using the same stub-friendly import pattern routes/calendar_routes using the shared stub-friendly test helper.
as test_null_owner_gates.py. No live DB or FastAPI test client needed. No live DB or FastAPI test client needed.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -10,34 +10,34 @@ from types import SimpleNamespace
import pytest import pytest
from tests.test_null_owner_gates import _import_calendar_helpers from tests.helpers.calendar_routes import import_calendar_routes
# ── _resolve_base_uid ────────────────────────────────────────────────── # ── _resolve_base_uid ──────────────────────────────────────────────────
def test_resolve_base_uid_plain_passthrough(): def test_resolve_base_uid_plain_passthrough():
cal = _import_calendar_helpers() cal = import_calendar_routes()
assert cal._resolve_base_uid("evt-123") == "evt-123" assert cal._resolve_base_uid("evt-123") == "evt-123"
def test_resolve_base_uid_compound_strips_suffix_date(): def test_resolve_base_uid_compound_strips_suffix_date():
cal = _import_calendar_helpers() cal = import_calendar_routes()
assert cal._resolve_base_uid("evt-123::2026-06-15") == "evt-123" assert cal._resolve_base_uid("evt-123::2026-06-15") == "evt-123"
def test_resolve_base_uid_compound_strips_suffix_datetime(): def test_resolve_base_uid_compound_strips_suffix_datetime():
cal = _import_calendar_helpers() cal = import_calendar_routes()
assert cal._resolve_base_uid("evt-123::2026-06-15T09:00") == "evt-123" assert cal._resolve_base_uid("evt-123::2026-06-15T09:00") == "evt-123"
def test_resolve_base_uid_rejects_empty(): def test_resolve_base_uid_rejects_empty():
cal = _import_calendar_helpers() cal = import_calendar_routes()
with pytest.raises(ValueError, match="empty uid"): with pytest.raises(ValueError, match="empty uid"):
cal._resolve_base_uid("") cal._resolve_base_uid("")
def test_resolve_base_uid_rejects_missing_base(): def test_resolve_base_uid_rejects_missing_base():
cal = _import_calendar_helpers() cal = import_calendar_routes()
with pytest.raises(ValueError, match="malformed compound UID"): with pytest.raises(ValueError, match="malformed compound UID"):
cal._resolve_base_uid("::2026-06-15") cal._resolve_base_uid("::2026-06-15")
@@ -73,7 +73,7 @@ def _make_event(**overrides):
def test_expand_non_recurring_returns_single(): def test_expand_non_recurring_returns_single():
"""Non-recurring events pass through unchanged with series_uid=uid.""" """Non-recurring events pass through unchanged with series_uid=uid."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event(rrule="") ev = _make_event(rrule="")
results = cal._expand_rrule(ev, datetime(2026, 5, 1), datetime(2026, 7, 1)) results = cal._expand_rrule(ev, datetime(2026, 5, 1), datetime(2026, 7, 1))
@@ -90,7 +90,7 @@ def test_expand_yearly_old_dtstart_later_year_single_occurrence():
This is the explicit regression case from PR review feedback. This is the explicit regression case from PR review feedback.
""" """
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-bday-001", uid="evt-bday-001",
summary="Annual Review", summary="Annual Review",
@@ -118,7 +118,7 @@ def test_expand_yearly_narrow_window_after_dtstart_returns_one():
"""DTSTART=2020, query just two months in 2029 — should return """DTSTART=2020, query just two months in 2029 — should return
exactly one occurrence (the one that falls in that window). exactly one occurrence (the one that falls in that window).
""" """
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-ann", uid="evt-ann",
dtstart=datetime(2020, 3, 1), dtstart=datetime(2020, 3, 1),
@@ -137,7 +137,7 @@ def test_expand_yearly_strict_before_window_returns_empty():
"""DTSTART=2020, query a window that ends before the yearly """DTSTART=2020, query a window that ends before the yearly
occurrence in that year. Should return zero. occurrence in that year. Should return zero.
""" """
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-late", uid="evt-late",
dtstart=datetime(2020, 12, 25), dtstart=datetime(2020, 12, 25),
@@ -154,7 +154,7 @@ def test_expand_yearly_strict_after_window_returns_empty():
"""DTSTART=2020. Query a window that starts after the occurrence in """DTSTART=2020. Query a window that starts after the occurrence in
that year. Should return zero. that year. Should return zero.
""" """
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-early", uid="evt-early",
dtstart=datetime(2020, 1, 15), dtstart=datetime(2020, 1, 15),
@@ -171,7 +171,7 @@ def test_expand_weekly_unique_no_overwrites():
"""Multiple occurrences from the same series must have unique UIDs """Multiple occurrences from the same series must have unique UIDs
so _allEvents[uid] = ev doesn't overwrite earlier ones. so _allEvents[uid] = ev doesn't overwrite earlier ones.
""" """
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-wk", uid="evt-wk",
dtstart=datetime(2026, 6, 1, 9, 0), dtstart=datetime(2026, 6, 1, 9, 0),
@@ -192,7 +192,7 @@ def test_expand_weekly_unique_no_overwrites():
def test_expand_monthly_all_day(): def test_expand_monthly_all_day():
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-rent", uid="evt-rent",
dtstart=datetime(2026, 1, 1), dtstart=datetime(2026, 1, 1),
@@ -210,7 +210,7 @@ def test_expand_monthly_all_day():
def test_expand_bad_rrule_graceful(): def test_expand_bad_rrule_graceful():
"""Malformed rrule should fall back to returning the base event, """Malformed rrule should fall back to returning the base event,
but only when the base event overlaps the requested window.""" but only when the base event overlaps the requested window."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-broken", uid="evt-broken",
rrule="FREQ=GARBAGE", rrule="FREQ=GARBAGE",
@@ -225,7 +225,7 @@ def test_expand_bad_rrule_graceful():
def test_expand_bad_rrule_fallback_rejects_non_overlapping(): def test_expand_bad_rrule_fallback_rejects_non_overlapping():
"""Malformed rrule with a base event outside the requested window """Malformed rrule with a base event outside the requested window
must return zero results, not leak the event into an unrelated range.""" must return zero results, not leak the event into an unrelated range."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-old-broken", uid="evt-old-broken",
dtstart=datetime(2020, 1, 1, 9, 0), dtstart=datetime(2020, 1, 1, 9, 0),
@@ -243,7 +243,7 @@ def test_expand_bad_rrule_fallback_rejects_non_overlapping():
def test_expand_exclusive_end_boundary(): def test_expand_exclusive_end_boundary():
"""An occurrence whose start equals the window end must be excluded. """An occurrence whose start equals the window end must be excluded.
The contract is [start, end), same as the non-recurring SQL filter.""" The contract is [start, end), same as the non-recurring SQL filter."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-daily", uid="evt-daily",
dtstart=datetime(2026, 6, 1, 9, 0), dtstart=datetime(2026, 6, 1, 9, 0),
@@ -260,7 +260,7 @@ def test_expand_exclusive_end_boundary():
def test_expand_multi_day_crossing_range_start(): def test_expand_multi_day_crossing_range_start():
"""A multi-day occurrence that starts before the window but ends inside """A multi-day occurrence that starts before the window but ends inside
it must be included (matching non-recurring overlap: dtend > start).""" it must be included (matching non-recurring overlap: dtend > start)."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-weekly-multi", uid="evt-weekly-multi",
summary="Weekend Trip", summary="Weekend Trip",
@@ -285,7 +285,7 @@ def test_expand_multi_day_crossing_range_start():
def test_expand_multi_day_fully_before_window(): def test_expand_multi_day_fully_before_window():
"""A multi-day occurrence that ends exactly at the window start """A multi-day occurrence that ends exactly at the window start
must be excluded (occ_end <= start).""" must be excluded (occ_end <= start)."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-multi", uid="evt-multi",
dtstart=datetime(2026, 5, 29, 18, 0), dtstart=datetime(2026, 5, 29, 18, 0),
@@ -301,7 +301,7 @@ def test_expand_multi_day_fully_before_window():
def test_expand_metadata_inheritance(): def test_expand_metadata_inheritance():
"""Occurrence dicts must carry the base event's metadata """Occurrence dicts must carry the base event's metadata
(summary, importance, event_type, color, location).""" (summary, importance, event_type, color, location)."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-meta", uid="evt-meta",
summary="Board Meeting", summary="Board Meeting",
@@ -323,7 +323,7 @@ def test_expand_metadata_inheritance():
def test_expand_daily_rrule_large_window_is_capped_and_marked_truncated(): def test_expand_daily_rrule_large_window_is_capped_and_marked_truncated():
"""Wide recurring windows must not materialize unbounded occurrence lists.""" """Wide recurring windows must not materialize unbounded occurrence lists."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event( ev = _make_event(
uid="evt-daily-cap", uid="evt-daily-cap",
dtstart=datetime(2020, 1, 1, 9, 0), dtstart=datetime(2020, 1, 1, 9, 0),
+2 -2
View File
@@ -24,7 +24,7 @@ UNTIL must expand to all of its occurrences.
from datetime import datetime from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from tests.test_null_owner_gates import _import_calendar_helpers from tests.helpers.calendar_routes import import_calendar_routes
_MOCK_CAL = SimpleNamespace(name="Personal", color="#5b8abf") _MOCK_CAL = SimpleNamespace(name="Personal", color="#5b8abf")
@@ -55,7 +55,7 @@ def _make_event(**overrides):
def test_expand_rrule_with_utc_until_keeps_all_occurrences(): def test_expand_rrule_with_utc_until_keeps_all_occurrences():
"""FREQ=DAILY;UNTIL=...Z must expand to every occurrence, not collapse """FREQ=DAILY;UNTIL=...Z must expand to every occurrence, not collapse
to a single non-recurring event.""" to a single non-recurring event."""
cal = _import_calendar_helpers() cal = import_calendar_routes()
ev = _make_event(rrule="FREQ=DAILY;UNTIL=20240105T090000Z") ev = _make_event(rrule="FREQ=DAILY;UNTIL=20240105T090000Z")
results = cal._expand_rrule(ev, datetime(2024, 1, 1), datetime(2024, 1, 10)) results = cal._expand_rrule(ev, datetime(2024, 1, 1), datetime(2024, 1, 10))
+4 -4
View File
@@ -1,9 +1,9 @@
"""Tests for iCalendar TEXT escaping in calendar export (RFC 5545 §3.3.11).""" """Tests for iCalendar TEXT escaping in calendar export (RFC 5545 §3.3.11)."""
from tests.test_null_owner_gates import _import_calendar_helpers from tests.helpers.calendar_routes import import_calendar_routes
def _esc(): def _esc():
return _import_calendar_helpers()._ics_escape return import_calendar_routes()._ics_escape
def test_escapes_comma_and_semicolon(): def test_escapes_comma_and_semicolon():
@@ -26,7 +26,7 @@ def test_empty_and_none_safe():
def test_safe_ics_filename_strips_header_metacharacters(): def test_safe_ics_filename_strips_header_metacharacters():
safe_filename = _import_calendar_helpers()._safe_ics_filename safe_filename = import_calendar_routes()._safe_ics_filename
assert ( assert (
safe_filename('Work\r\nX-Injected: yes";/..\\evil') safe_filename('Work\r\nX-Injected: yes";/..\\evil')
@@ -35,7 +35,7 @@ def test_safe_ics_filename_strips_header_metacharacters():
def test_safe_ics_filename_falls_back_for_empty_names(): def test_safe_ics_filename_falls_back_for_empty_names():
safe_filename = _import_calendar_helpers()._safe_ics_filename safe_filename = import_calendar_routes()._safe_ics_filename
assert safe_filename("////") == "calendar.ics" assert safe_filename("////") == "calendar.ics"
assert safe_filename(None) == "calendar.ics" assert safe_filename(None) == "calendar.ics"
+7 -17
View File
@@ -18,6 +18,8 @@ import pytest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
from tests.helpers.calendar_routes import import_calendar_routes
# `tests/conftest.py` stubs the heavy optional deps. We additionally # `tests/conftest.py` stubs the heavy optional deps. We additionally
# stub `core.database` here because the real module instantiates # stub `core.database` here because the real module instantiates
# SQLAlchemy declarative classes at import-time — which blows up under # SQLAlchemy declarative classes at import-time — which blows up under
@@ -64,20 +66,8 @@ from fastapi import HTTPException
# calendar._get_or_404_calendar / _get_or_404_event # calendar._get_or_404_calendar / _get_or_404_event
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _import_calendar_helpers():
"""Import the two private gate helpers without booting the full
calendar router. We patch sys.modules so the module-load side
effects (DB import) don't blow up under the conftest stubs."""
mod_name = "routes.calendar_routes"
if mod_name in sys.modules:
return sys.modules[mod_name]
# core.database is stubbed by conftest already; the module should
# import cleanly.
return __import__(mod_name, fromlist=["_get_or_404_calendar", "_get_or_404_event"])
def test_calendar_gate_rejects_null_owner_for_authenticated_user(): def test_calendar_gate_rejects_null_owner_for_authenticated_user():
cal_mod = _import_calendar_helpers() cal_mod = import_calendar_routes()
db = MagicMock() db = MagicMock()
cal = SimpleNamespace(id="c1", owner=None) cal = SimpleNamespace(id="c1", owner=None)
db.query.return_value.filter.return_value.first.return_value = cal db.query.return_value.filter.return_value.first.return_value = cal
@@ -87,7 +77,7 @@ def test_calendar_gate_rejects_null_owner_for_authenticated_user():
def test_calendar_gate_rejects_cross_owner(): def test_calendar_gate_rejects_cross_owner():
cal_mod = _import_calendar_helpers() cal_mod = import_calendar_routes()
db = MagicMock() db = MagicMock()
cal = SimpleNamespace(id="c1", owner="bob") cal = SimpleNamespace(id="c1", owner="bob")
db.query.return_value.filter.return_value.first.return_value = cal db.query.return_value.filter.return_value.first.return_value = cal
@@ -97,7 +87,7 @@ def test_calendar_gate_rejects_cross_owner():
def test_calendar_gate_accepts_matching_owner(): def test_calendar_gate_accepts_matching_owner():
cal_mod = _import_calendar_helpers() cal_mod = import_calendar_routes()
db = MagicMock() db = MagicMock()
cal = SimpleNamespace(id="c1", owner="alice") cal = SimpleNamespace(id="c1", owner="alice")
db.query.return_value.filter.return_value.first.return_value = cal db.query.return_value.filter.return_value.first.return_value = cal
@@ -106,7 +96,7 @@ def test_calendar_gate_accepts_matching_owner():
def test_calendar_event_gate_rejects_null_owner_calendar(): def test_calendar_event_gate_rejects_null_owner_calendar():
cal_mod = _import_calendar_helpers() cal_mod = import_calendar_routes()
db = MagicMock() db = MagicMock()
cal = SimpleNamespace(owner=None) cal = SimpleNamespace(owner=None)
ev = SimpleNamespace(uid="e1", calendar=cal) ev = SimpleNamespace(uid="e1", calendar=cal)
@@ -117,7 +107,7 @@ def test_calendar_event_gate_rejects_null_owner_calendar():
def test_calendar_event_gate_rejects_cross_owner(): def test_calendar_event_gate_rejects_cross_owner():
cal_mod = _import_calendar_helpers() cal_mod = import_calendar_routes()
db = MagicMock() db = MagicMock()
cal = SimpleNamespace(owner="bob") cal = SimpleNamespace(owner="bob")
ev = SimpleNamespace(uid="e1", calendar=cal) ev = SimpleNamespace(uid="e1", calendar=cal)