feat(calendar): make external mutations durable

This commit is contained in:
2026-07-20 16:58:45 +02:00
parent 371a00aff5
commit a6cd64e3f9
15 changed files with 4726 additions and 152 deletions

View File

@@ -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, [])

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
class CalendarSchedulingCapabilityTests(unittest.TestCase):
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
provider = SqlCalendarSchedulingProvider()
with self.assertRaises(CalendarCapabilityError):
provider.create_event(
object(),
tenant_id="tenant-1",
user_id="user-1",
request=CalendarEventRequest(
summary="x" * 501,
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
),
)
if __name__ == "__main__":
unittest.main()

1819
tests/test_outbox.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
from govoplan_calendar.backend.service import delete_calendar, event_response
from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
class FakeSession:
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
self.assertEqual(response.start_at.tzinfo, timezone.utc)
class CalendarVisibilityTests(unittest.TestCase):
@staticmethod
def calendar(**overrides):
values = {
"visibility": "private",
"owner_type": "user",
"owner_id": "owner-1",
"created_by_user_id": "creator-1",
}
values.update(overrides)
return SimpleNamespace(**values)
def test_tenant_shared_and_public_calendars_are_visible_to_readers(self) -> None:
for visibility in ("tenant", "shared", "public"):
with self.subTest(visibility=visibility):
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(visibility=visibility),
user_id="reader-1",
)
)
def test_private_calendar_is_visible_to_user_owner_creator_or_group_member(self) -> None:
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(),
user_id="owner-1",
)
)
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(),
user_id="creator-1",
)
)
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(owner_type="group", owner_id="group-1"),
user_id="member-1",
group_ids=("group-1",),
)
)
def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None:
calendar = self.calendar()
self.assertFalse(
calendar_is_visible_to_principal(
calendar,
user_id="other-1",
group_ids=("group-2",),
)
)
self.assertTrue(
calendar_is_visible_to_principal(
calendar,
user_id="other-1",
can_admin=True,
)
)
class CalendarServiceDeleteTests(unittest.TestCase):
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
session = FakeSession()