mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-19 19:25:27 -04:00
Harden CalDAV write-back with retries (#1193)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Regression coverage for bidirectional CalDAV sync plumbing.
|
||||
|
||||
These tests avoid a live CalDAV server. They pin the local invariants that keep
|
||||
Odysseus-created CalDAV events from being pruned before they can be pushed.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from src.caldav_writeback import build_event_ical
|
||||
|
||||
|
||||
def test_event_to_ical_serializes_core_fields_and_rrule():
|
||||
ical = build_event_ical({
|
||||
"uid": "evt-123",
|
||||
"summary": "Planning",
|
||||
"description": "Bring notes",
|
||||
"location": "HQ",
|
||||
"dtstart": datetime(2026, 6, 5, 9, 0),
|
||||
"dtend": datetime(2026, 6, 5, 10, 0),
|
||||
"all_day": False,
|
||||
"is_utc": False,
|
||||
"rrule": "FREQ=WEEKLY;COUNT=2",
|
||||
})
|
||||
|
||||
assert "UID:evt-123" in ical
|
||||
assert "SUMMARY:Planning" in ical
|
||||
assert "DESCRIPTION:Bring notes" in ical
|
||||
assert "LOCATION:HQ" in ical
|
||||
assert "RRULE:FREQ=WEEKLY;COUNT=2" in ical
|
||||
|
||||
|
||||
def test_caldav_pull_prune_skips_unsynced_or_pending_local_rows():
|
||||
source = Path("src/caldav_sync.py").read_text()
|
||||
|
||||
assert 'existing.caldav_sync_pending in {"create", "update"}' in source
|
||||
assert "CalendarEvent.remote_href.isnot(None)" in source
|
||||
assert "CalendarEvent.caldav_sync_pending.is_(None)" in source
|
||||
|
||||
|
||||
def test_http_calendar_writes_mark_pending_and_push_after_commit():
|
||||
source = Path("routes/calendar_routes.py").read_text()
|
||||
|
||||
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
|
||||
assert 'ev.caldav_sync_pending = "update"' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
|
||||
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
|
||||
assert 'not result.get("ok")' in source
|
||||
|
||||
|
||||
def test_agent_calendar_writes_share_caldav_push_path():
|
||||
source = Path("src/tool_implementations.py").read_text()
|
||||
|
||||
assert "_push_caldav_event_after_commit" in source
|
||||
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
|
||||
assert 'ev.caldav_sync_pending = "update"' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
|
||||
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
|
||||
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
|
||||
|
||||
|
||||
def test_database_declares_and_migrates_caldav_remote_metadata():
|
||||
source = Path("core/database.py").read_text()
|
||||
|
||||
for needle in [
|
||||
"class CalendarDeletedEvent",
|
||||
"remote_href = Column(String, nullable=True)",
|
||||
"remote_etag = Column(String, nullable=True)",
|
||||
"caldav_sync_pending = Column(String, nullable=True)",
|
||||
"caldav_base_url = Column(String, nullable=True)",
|
||||
"ALTER TABLE calendar_events ADD COLUMN remote_href TEXT",
|
||||
"ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT",
|
||||
"ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT",
|
||||
"ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT",
|
||||
"_migrate_add_caldav_sync_columns()",
|
||||
]:
|
||||
assert needle in source
|
||||
|
||||
|
||||
def test_failed_remote_delete_leaves_tombstone_and_later_retry_cleans_up(tmp_path, monkeypatch):
|
||||
import src.caldav_writeback as writeback
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'calendar.db'}")
|
||||
spec = importlib.util.spec_from_file_location("core.database", Path("core/database.py"))
|
||||
dbmod = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "core.database", dbmod)
|
||||
spec.loader.exec_module(dbmod)
|
||||
|
||||
CalendarCal = dbmod.CalendarCal
|
||||
CalendarDeletedEvent = dbmod.CalendarDeletedEvent
|
||||
CalendarEvent = dbmod.CalendarEvent
|
||||
TestingSessionLocal = dbmod.SessionLocal
|
||||
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
cal = CalendarCal(
|
||||
id="caldav-test",
|
||||
owner="alice",
|
||||
name="Remote",
|
||||
source="caldav",
|
||||
caldav_base_url="https://caldav.example/calendars/alice/main/",
|
||||
)
|
||||
ev = CalendarEvent(
|
||||
uid="evt-delete",
|
||||
calendar_id=cal.id,
|
||||
summary="Delete me",
|
||||
dtstart=datetime(2026, 6, 5, 9, 0),
|
||||
dtend=datetime(2026, 6, 5, 10, 0),
|
||||
remote_href="https://caldav.example/calendars/alice/main/evt-delete.ics",
|
||||
)
|
||||
session.add(cal)
|
||||
session.add(ev)
|
||||
session.commit()
|
||||
|
||||
tombstone = CalendarDeletedEvent(
|
||||
uid=ev.uid,
|
||||
owner="alice",
|
||||
calendar_id=ev.calendar_id,
|
||||
remote_href=ev.remote_href,
|
||||
remote_etag=ev.remote_etag,
|
||||
caldav_base_url=cal.caldav_base_url,
|
||||
summary=ev.summary,
|
||||
)
|
||||
session.add(tombstone)
|
||||
session.delete(ev)
|
||||
session.commit()
|
||||
|
||||
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
|
||||
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
|
||||
assert tombstone is not None
|
||||
assert tombstone.remote_href.endswith("evt-delete.ics")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
writeback._persist_writeback_result(
|
||||
"alice",
|
||||
"caldav-test",
|
||||
"evt-delete",
|
||||
{"ok": False, "error": "temporary remote delete failure"},
|
||||
delete=True,
|
||||
)
|
||||
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
|
||||
assert tombstone is not None
|
||||
assert "temporary remote delete failure" in tombstone.last_error
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
writeback._persist_writeback_result(
|
||||
"alice",
|
||||
"caldav-test",
|
||||
"evt-delete",
|
||||
{"ok": True},
|
||||
delete=True,
|
||||
)
|
||||
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
assert session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first() is None
|
||||
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
|
||||
finally:
|
||||
session.close()
|
||||
@@ -22,7 +22,9 @@ CAL_ID = _stable_cal_id(REMOTE_URL)
|
||||
|
||||
|
||||
class FakeEvent:
|
||||
def __init__(self):
|
||||
def __init__(self, url="https://p69-caldav.icloud.com/123/calendars/home/evt-1.ics"):
|
||||
self.url = url
|
||||
self.etag = '"abc123"'
|
||||
self.data = "OLD"
|
||||
self.saved = False
|
||||
self.deleted = False
|
||||
@@ -39,6 +41,7 @@ class FakeCalendar:
|
||||
self.url = url
|
||||
self._existing = existing
|
||||
self.saved_ical = None
|
||||
self.created = FakeEvent(str(url).rstrip("/") + "/created.ics")
|
||||
|
||||
def event_by_uid(self, uid):
|
||||
if self._existing is None:
|
||||
@@ -47,6 +50,7 @@ class FakeCalendar:
|
||||
|
||||
def save_event(self, ical):
|
||||
self.saved_ical = ical
|
||||
return self.created
|
||||
|
||||
|
||||
def _ev(**over):
|
||||
@@ -91,6 +95,8 @@ def test_push_create_calls_save_event():
|
||||
res = push_event([cal], CAL_ID, _ev(), delete=False)
|
||||
assert res["ok"] and res.get("created")
|
||||
assert cal.saved_ical and "UID:evt-1" in cal.saved_ical
|
||||
assert res["calendar_url"] == REMOTE_URL
|
||||
assert res["remote_href"].endswith("/created.ics")
|
||||
|
||||
|
||||
def test_push_update_overwrites_existing():
|
||||
@@ -100,6 +106,8 @@ def test_push_update_overwrites_existing():
|
||||
assert res["ok"] and res.get("updated")
|
||||
assert existing.saved and "SUMMARY:Moved" in existing.data
|
||||
assert cal.saved_ical is None # used update path, not create
|
||||
assert res["remote_href"].endswith("evt-1.ics")
|
||||
assert res["remote_etag"] == '"abc123"'
|
||||
|
||||
|
||||
def test_push_delete_removes_existing():
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
import routes.calendar_routes as croutes
|
||||
import src.caldav_writeback as wb
|
||||
import src.caldav_sync as csync
|
||||
from core.database import CalendarCal
|
||||
from routes.calendar_routes import EventCreate
|
||||
|
||||
@@ -39,11 +39,16 @@ croutes.SessionLocal = _TS
|
||||
def calls(monkeypatch):
|
||||
recorded = []
|
||||
|
||||
async def _fake_writeback(owner, source, cal_id, ev, *, delete=False):
|
||||
recorded.append({"source": source, "cal_id": cal_id, "uid": ev.get("uid"), "delete": delete})
|
||||
async def _fake_create(owner, uid):
|
||||
recorded.append({"uid": uid, "delete": False, "action": "create"})
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(wb, "writeback_event", _fake_writeback)
|
||||
async def _fake_delete(owner, uid):
|
||||
recorded.append({"uid": uid, "delete": True, "action": "delete"})
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(csync, "push_event_create", _fake_create)
|
||||
monkeypatch.setattr(csync, "push_event_delete", _fake_delete)
|
||||
return recorded
|
||||
|
||||
|
||||
@@ -77,7 +82,6 @@ async def test_create_on_caldav_calendar_pushes_to_remote(calls):
|
||||
summary="Dentist", dtstart="2026-06-10T14:00:00Z", calendar_href=cal_id))
|
||||
assert res["ok"] is True
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["source"] == "caldav" and calls[0]["cal_id"] == cal_id
|
||||
assert calls[0]["delete"] is False
|
||||
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ def _install_calendar_db_stub(monkeypatch):
|
||||
db = types.ModuleType("core.database")
|
||||
db.SessionLocal = MagicMock()
|
||||
db.CalendarCal = _CalendarCal
|
||||
db.CalendarDeletedEvent = MagicMock()
|
||||
db.CalendarEvent = _CalendarEvent
|
||||
for name in [
|
||||
"Base",
|
||||
|
||||
@@ -28,7 +28,7 @@ from unittest.mock import MagicMock
|
||||
def _null_owner_stubs(monkeypatch):
|
||||
for _stub, _attrs in (
|
||||
("core.database", (
|
||||
"Base", "SessionLocal", "CalendarCal", "CalendarEvent",
|
||||
"Base", "SessionLocal", "CalendarCal", "CalendarDeletedEvent", "CalendarEvent",
|
||||
"Document", "DocumentVersion", "Session", "ChatMessage",
|
||||
"GalleryImage", "GalleryAlbum", "Note", "ScheduledTask",
|
||||
"TaskRun", "ModelEndpoint", "Webhook",
|
||||
|
||||
Reference in New Issue
Block a user