feat(calendar): make external mutations durable
This commit is contained in:
@@ -9,7 +9,8 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
|
||||
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
|
||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||
from govoplan_calendar.backend.service import (
|
||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||
@@ -226,6 +227,18 @@ END:VCALENDAR</C:calendar-data>
|
||||
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
|
||||
CalDAVClient(collection_url="/remote.php/dav")
|
||||
|
||||
def test_object_url_rejects_off_origin_and_cross_collection_hrefs(self) -> None:
|
||||
client = CalDAVClient(collection_url="https://dav.example.test/cal")
|
||||
|
||||
with self.assertRaisesRegex(CalDAVError, "collection origin"):
|
||||
client.object_url("https://evil.example.test/steal.ics")
|
||||
with self.assertRaisesRegex(CalDAVError, "collection path"):
|
||||
client.object_url("https://dav.example.test/other/steal.ics")
|
||||
self.assertEqual(
|
||||
client.object_url("/cal/event.ics"),
|
||||
"https://dav.example.test/cal/event.ics",
|
||||
)
|
||||
|
||||
|
||||
class CalDAVSyncTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -392,36 +405,39 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
self.assertEqual(len(provider.requests), 1)
|
||||
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
|
||||
|
||||
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
|
||||
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"'])
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
event = create_event(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=calendar.id,
|
||||
uid="event-1@example.test",
|
||||
summary="Planning",
|
||||
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
)
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-1"'])
|
||||
event = create_event(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=calendar.id,
|
||||
uid="event-1@example.test",
|
||||
summary="Planning",
|
||||
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
)
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
self.assertEqual(fake.puts, [])
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(len(fake.puts), 2)
|
||||
operations = session.query(CalendarOutboxOperation).all()
|
||||
self.assertCountEqual([operation.status for operation in operations], ["superseded", "pending"])
|
||||
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
|
||||
|
||||
self.assertEqual(len(fake.puts), 1)
|
||||
self.assertTrue(fake.puts[0]["create"])
|
||||
self.assertEqual(fake.puts[1]["etag"], '"etag-1"')
|
||||
self.assertFalse(fake.puts[1]["create"])
|
||||
self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
|
||||
self.assertEqual(event.source_kind, "caldav")
|
||||
self.assertEqual(event.etag, '"etag-2"')
|
||||
self.assertEqual(event.etag, '"etag-1"')
|
||||
self.assertIn("SUMMARY:Updated", event.raw_ics or "")
|
||||
|
||||
def test_update_event_reports_remote_etag_conflict(self) -> None:
|
||||
@@ -433,10 +449,22 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
session.add(event)
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(fail_precondition=True)
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
with self.assertRaisesRegex(CalendarError, "changed remotely"):
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
fake = FakeCalDAVClient(
|
||||
fail_precondition=True,
|
||||
objects={event.source_href: recurring_resource()},
|
||||
)
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
session.commit()
|
||||
result = dispatch_calendar_outbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
client_factory=lambda _session, _source: fake,
|
||||
)
|
||||
|
||||
self.assertEqual(result["failed"], 1)
|
||||
operation = session.query(CalendarOutboxOperation).one()
|
||||
self.assertEqual(operation.status, "conflict")
|
||||
self.assertIn("changed remotely", operation.last_error or "")
|
||||
|
||||
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
|
||||
session = self.Session()
|
||||
@@ -450,9 +478,9 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-2"'])
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
delete_event(session, tenant_id="tenant-1", event_id=override.id)
|
||||
delete_event(session, tenant_id="tenant-1", event_id=override.id)
|
||||
session.commit()
|
||||
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
|
||||
|
||||
self.assertEqual(len(fake.puts), 1)
|
||||
self.assertEqual(fake.deletes, [])
|
||||
|
||||
Reference in New Issue
Block a user