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, [])
|
||||
|
||||
27
tests/test_capabilities.py
Normal file
27
tests/test_capabilities.py
Normal 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
1819
tests/test_outbox.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user