import asyncio import socket from datetime import UTC, datetime, timedelta from types import SimpleNamespace import pytest from fastapi import HTTPException from backend.calendar import ( FetchResult, parse_ics_events, validate_calendar_url, ) from backend.calendar_refresh import refresh_due_subscriptions, refresh_subscription_cache ICS = b"""BEGIN:VCALENDAR\r VERSION:2.0\r BEGIN:VEVENT\r UID:one\r DTSTART:20260920T090000Z\r DTEND:20260920T100000Z\r SUMMARY:Meeting\r DESCRIPTION:Body line one\\nBody line two\r LOCATION:Meeting room\r END:VEVENT\r END:VCALENDAR\r """ RECURRING_ICS = b"""BEGIN:VCALENDAR\r VERSION:2.0\r BEGIN:VEVENT\r UID:daily\r DTSTART;TZID=Asia/Shanghai:20260920T090000\r DTEND;TZID=Asia/Shanghai:20260920T100000\r RRULE:FREQ=DAILY;COUNT=3\r EXDATE;TZID=Asia/Shanghai:20260921T090000\r SUMMARY:Daily\r END:VEVENT\r BEGIN:VEVENT\r UID:daily\r RECURRENCE-ID;TZID=Asia/Shanghai:20260922T090000\r DTSTART;TZID=Asia/Shanghai:20260922T110000\r DTEND;TZID=Asia/Shanghai:20260922T120000\r SUMMARY:Moved\r END:VEVENT\r END:VCALENDAR\r """ INHERITED_DURATION_ICS = RECURRING_ICS.replace( b"DTEND;TZID=Asia/Shanghai:20260922T120000\r\n", b"" ).replace( b"DTEND;TZID=Asia/Shanghai:20260920T100000\r\n", b"DTEND;TZID=Asia/Shanghai:20260920T103000\r\n", ) def initialized(client, username="owner"): response = client.post( "/api/v1/setup/initialize", json={"username": username, "password": "correct horse battery staple"}, ) assert response.status_code == 201 return client def test_parser_restored_with_recurrence_exdates_overrides_and_timezone(): events = parse_ics_events( RECURRING_ICS, "Work", "#123456", datetime(2026, 9, 19, tzinfo=UTC), datetime(2026, 9, 24, tzinfo=UTC), "Asia/Shanghai", ) assert [(event["title"], event["starts_at"].isoformat()) for event in events] == [ ("Daily", "2026-09-20T01:00:00+00:00"), ("Moved", "2026-09-22T03:00:00+00:00"), ] def test_recurrence_override_inherits_master_duration_and_source_id(): events = parse_ics_events( INHERITED_DURATION_ICS, "Work", "#123456", datetime(2026, 9, 19, tzinfo=UTC), datetime(2026, 9, 24, tzinfo=UTC), "Asia/Shanghai", source_id="source-1", ) moved = next(event for event in events if event["title"] == "Moved") assert (moved["ends_at"] - moved["starts_at"]).total_seconds() == 90 * 60 assert moved["source_id"] == "source-1" def test_parser_limits_recurrence_expansion(): endless = ICS.replace(b"UID:one", b"UID:one\r\nRRULE:FREQ=SECONDLY") with pytest.raises(ValueError, match="recurrence limit"): parse_ics_events( endless, "x", "#000000", datetime(2026, 9, 20, tzinfo=UTC), datetime(2026, 9, 21, tzinfo=UTC), "UTC", recurrence_limit=10, ) def test_url_validation_rejects_fragments_userinfo_and_mixed_dns(monkeypatch): with pytest.raises(HTTPException): validate_calendar_url("https://example.com/a.ics#secret") with pytest.raises(HTTPException): validate_calendar_url("https://user@example.com/a.ics") monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443)), (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 443)), ]) with pytest.raises(HTTPException, match="public"): validate_calendar_url("https://example.com/a.ics") def test_subscription_crud_refresh_events_and_stale_cache(client, monkeypatch): client = initialized(client) monkeypatch.setattr( "backend.calendar.validate_calendar_url", lambda url: (url, "93.184.216.34", 443), ) calls = [] def fetch(url, *, etag=None, last_modified=None): calls.append((url, etag, last_modified)) if len(calls) == 1: return FetchResult(ICS, '"v1"', "Sun, 20 Sep 2026 00:00:00 GMT", False) raise HTTPException(502, "upstream down") monkeypatch.setattr("backend.calendar.fetch_calendar", fetch) created = client.post( "/api/v1/calendar-subscriptions", json={"name": " Work ", "url": "https://example.com/work.ics", "color": "#123abc"}, ) assert created.status_code == 201 body = created.json() assert body["name"] == "Work" assert body["enabled"] is True assert body["stale"] is False assert calls == [("https://example.com/work.ics", None, None)] listed = client.get("/api/v1/calendar-subscriptions") assert [item["id"] for item in listed.json()] == [body["id"]] events = client.get( "/api/v1/calendar-events", params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"}, ) assert events.status_code == 200 assert events.json()["events"][0]["title"] == "Meeting" assert events.json()["events"][0]["description"] == "Body line one\nBody line two" assert events.json()["events"][0]["location"] == "Meeting room" assert events.json()["sources"][0]["stale"] is False refreshed = client.post(f"/api/v1/calendar-subscriptions/{body['id']}/refresh") assert refreshed.status_code == 200 assert refreshed.json()["stale"] is True assert refreshed.json()["last_error"] == "upstream down" assert calls[1][1:] == ('"v1"', "Sun, 20 Sep 2026 00:00:00 GMT") patched = client.patch( f"/api/v1/calendar-subscriptions/{body['id']}", json={"name": "Personal", "enabled": False, "color": "#abcdef"}, ) assert patched.status_code == 200 assert patched.json()["name"] == "Personal" assert patched.json()["enabled"] is False assert client.delete(f"/api/v1/calendar-subscriptions/{body['id']}").status_code == 204 def test_parser_bounds_large_event_text_fields(): oversized = ICS.replace( b"DESCRIPTION:Body line one\\nBody line two", b"DESCRIPTION:" + b"x" * 3_000, ).replace( b"LOCATION:Meeting room", b"LOCATION:" + b"y" * 1_000, ) event = parse_ics_events( oversized, "Work", "#123456", datetime(2026, 9, 20, tzinfo=UTC), datetime(2026, 9, 21, tzinfo=UTC), "UTC", )[0] assert event["description"] == "x" * 2_000 assert event["location"] == "y" * 500 def test_events_validate_window_and_disabled_sources_are_skipped(client, monkeypatch): client = initialized(client) monkeypatch.setattr( "backend.calendar.validate_calendar_url", lambda url: (url, "93.184.216.34", 443), ) monkeypatch.setattr( "backend.calendar.fetch_calendar", lambda *args, **kwargs: FetchResult(ICS, None, None, False), ) sub = client.post( "/api/v1/calendar-subscriptions", json={"name": "x", "url": "https://example.com/x.ics", "enabled": False}, ).json() assert sub["enabled"] is False response = client.get( "/api/v1/calendar-events", params={"start": "2026-09-21T00:00:00Z", "end": "2026-09-20T00:00:00Z"}, ) assert response.status_code == 422 valid = client.get( "/api/v1/calendar-events", params={"start": "2026-09-20T00:00:00Z", "end": "2026-09-21T00:00:00Z"}, ) assert valid.json() == {"events": [], "sources": []} def test_subscription_ownership_is_strict(client, monkeypatch): client = initialized(client) monkeypatch.setattr( "backend.calendar.validate_calendar_url", lambda url: (url, "93.184.216.34", 443), ) monkeypatch.setattr( "backend.calendar.fetch_calendar", lambda *args, **kwargs: FetchResult(ICS, None, None, False), ) sub = client.post( "/api/v1/calendar-subscriptions", json={"name": "private", "url": "https://example.com/private.ics"}, ).json() client.post("/api/v1/auth/logout") assert client.patch(f"/api/v1/calendar-subscriptions/{sub['id']}", json={"name": "x"}).status_code == 401 assert client.delete(f"/api/v1/calendar-subscriptions/{sub['id']}").status_code == 401 def test_due_enabled_subscriptions_refresh_automatically(client, monkeypatch): client = initialized(client) monkeypatch.setattr( "backend.calendar.validate_calendar_url", lambda url: (url, "93.184.216.34", 443), ) calls = [] def fetch(url, *, etag=None, last_modified=None): calls.append(url) return FetchResult(ICS, None, None, False) monkeypatch.setattr("backend.calendar.fetch_calendar", fetch) client.post( "/api/v1/calendar-subscriptions", json={"name": "enabled", "url": "https://example.com/enabled.ics"}, ) client.post( "/api/v1/calendar-subscriptions", json={"name": "disabled", "url": "https://example.com/disabled.ics", "enabled": False}, ) async def refresh(): from sqlalchemy.ext.asyncio import async_sessionmaker from backend.db import get_engine factory = async_sessionmaker(get_engine(), expire_on_commit=False) async with factory() as db: return await refresh_due_subscriptions( db, now=datetime.now(UTC) + timedelta(minutes=16), refresh_interval=timedelta(minutes=15), ) assert asyncio.run(refresh()) == 1 assert calls == [ "https://example.com/enabled.ics", "https://example.com/disabled.ics", "https://example.com/enabled.ics", ] def test_failed_cached_refresh_preserves_last_success_timestamp(monkeypatch): original_refresh = datetime(2026, 9, 20, tzinfo=UTC) row = SimpleNamespace( id="source-1", url="https://example.com/work.ics", name="work", color="#123456", ics_cache=ICS.decode(), etag=None, last_modified=None, refreshed_at=original_refresh, last_error=None, ) class FakeDb: async def refresh(self, _row): pass async def commit(self): pass async def rollback(self): pass def fail(*args, **kwargs): raise HTTPException(502, "upstream down") monkeypatch.setattr("backend.calendar.fetch_calendar", fail) assert asyncio.run(refresh_subscription_cache(FakeDb(), row)) is False assert row.refreshed_at == original_refresh assert row.last_error == "upstream down" def test_refresh_discards_response_when_url_changes_in_flight(monkeypatch): row = SimpleNamespace( id="source-1", url="https://example.com/old.ics", name="work", color="#123456", ics_cache="old cache", etag=None, last_modified=None, refreshed_at=datetime(2026, 9, 20, tzinfo=UTC), last_error=None, ) class FakeDb: committed = False async def refresh(self, target): target.url = "https://example.com/new.ics" target.ics_cache = None target.refreshed_at = None async def commit(self): self.committed = True async def rollback(self): pass monkeypatch.setattr( "backend.calendar.fetch_calendar", lambda *args, **kwargs: FetchResult(ICS, None, None, False), ) db = FakeDb() assert asyncio.run(refresh_subscription_cache(db, row)) is False assert row.url == "https://example.com/new.ics" assert row.ics_cache is None assert db.committed is False