chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:18 +02:00
parent c3c867391c
commit 15d5613f9b
20 changed files with 4704 additions and 902 deletions

View File

@@ -9,7 +9,7 @@ from sqlalchemy import create_engine
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 CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
from govoplan_calendar.backend.service import (
@@ -19,7 +19,9 @@ from govoplan_calendar.backend.service import (
create_calendar,
create_caldav_source,
create_event,
delete_calendar,
delete_event,
list_caldav_sources,
list_freebusy,
sync_caldav_source,
sync_due_caldav_sources,
@@ -36,12 +38,14 @@ class FakeCalDAVClient:
list_result: CalDAVReportResult | None = None,
sync_result: CalDAVReportResult | None = None,
objects: dict[str, str] | None = None,
fetch_not_found: set[str] | None = None,
put_etags: list[str | None] | None = None,
fail_precondition: bool = False,
) -> None:
self.list_result = list_result or CalDAVReportResult()
self.sync_result = sync_result
self.objects = objects or {}
self.fetch_not_found = fetch_not_found or set()
self.put_etags = put_etags or ['"etag-written"']
self.fail_precondition = fail_precondition
self.fetched: list[str] = []
@@ -61,6 +65,8 @@ class FakeCalDAVClient:
def fetch_object(self, href: str) -> str:
self.fetched.append(href)
if href in self.fetch_not_found:
raise CalDAVNotFound(f"GET {href} returned HTTP 404")
return self.objects[href]
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
@@ -116,6 +122,104 @@ END:VCALENDAR</C:calendar-data>
self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "")
self.assertTrue(result.objects[1].deleted)
def test_discovery_resolves_nextcloud_dav_root_to_calendar_collections(self) -> None:
requests: list[tuple[str, str, str | None]] = []
def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int):
requests.append((method, url, headers.get("Authorization")))
if url == "https://cloud.example.test/remote.php/dav/":
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/remote.php/dav/</D:href>
<D:propstat>
<D:prop>
<D:current-user-principal><D:href>/remote.php/dav/principals/users/ada/</D:href></D:current-user-principal>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
if url == "https://cloud.example.test/remote.php/dav/principals/users/ada/":
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/remote.php/dav/principals/users/ada/</D:href>
<D:propstat>
<D:prop>
<C:calendar-home-set><D:href>/remote.php/dav/calendars/ada/</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
if url == "https://cloud.example.test/remote.php/dav/calendars/ada/":
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:response>
<D:href>/remote.php/dav/calendars/ada/</D:href>
<D:propstat>
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/remote.php/dav/calendars/ada/personal/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Personal</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>#44aa99ff</ICAL:calendar-color>
<CS:getctag>ctag-1</CS:getctag>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
raise AssertionError(f"Unexpected {method} {url}")
client = CalDAVClient(collection_url="https://cloud.example.test/remote.php/dav", username="ada", password="secret", transport=transport)
calendars = client.discover_calendars()
self.assertEqual(len(calendars), 1)
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
self.assertEqual(calendars[0].display_name, "Personal")
self.assertEqual(calendars[0].color, "#44aa99ff")
self.assertEqual(requests[0][2], "Basic YWRhOnNlY3JldA==")
def test_discovery_accepts_host_path_url_without_scheme(self) -> None:
requested_urls: list[str] = []
def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int):
requested_urls.append(url)
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/remote.php/dav/calendars/ada/personal/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Personal</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
client = CalDAVClient(collection_url="cloud.example.test/remote.php/dav", transport=transport)
calendars = client.discover_calendars()
self.assertEqual(requested_urls[0], "https://cloud.example.test/remote.php/dav/")
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
def test_discovery_reports_relative_url_as_caldav_error(self) -> None:
client = CalDAVClient(collection_url="/remote.php/dav")
with self.assertRaisesRegex(CalDAVError, "unknown url type"):
client.discover_calendars()
class CalDAVSyncTests(unittest.TestCase):
def setUp(self) -> None:
@@ -153,6 +257,79 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
def test_delete_calendar_retires_caldav_source_and_credential(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"))
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None:
session = self.Session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old"))
old_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=old_calendar.id, collection_url="https://dav.example.test/cal"),
)
old_calendar.deleted_at = datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc)
new_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="New"))
session.commit()
new_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=new_calendar.id, collection_url="https://dav.example.test/cal"),
)
session.commit()
self.assertIsNotNone(old_source.deleted_at)
self.assertEqual(new_source.calendar_id, new_calendar.id)
self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id])
def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None:
session = self.Session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First"))
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=first.id, collection_url="https://dav.example.test/cal"),
)
session.commit()
with self.assertRaisesRegex(CalendarError, "already linked"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=second.id, collection_url="https://dav.example.test/cal"),
)
def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
session = self.Session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
@@ -381,6 +558,57 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(source.sync_token, "token-2")
self.assertIsNotNone(event.deleted_at)
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
session = self.Session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
)
source.sync_token = "token-1"
event = CalendarEvent(
tenant_id="tenant-1",
calendar_id=calendar.id,
uid="event-1@example.test",
recurrence_id=None,
summary="Remote",
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
all_day=False,
attendees=[],
categories=[],
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
source_kind="caldav",
source_href="https://dav.example.test/cal/event-1.ics",
icalendar={},
metadata_={"caldav": {"source_id": source.id}},
)
session.add(event)
session.commit()
client = FakeCalDAVClient(
sync_result=CalDAVReportResult(objects=[CalDAVObject(href="/cal/event-1.ics", etag='"gone"', calendar_data=None)], sync_token="token-2"),
fetch_not_found={"/cal/event-1.ics"},
)
source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client)
session.commit()
self.assertTrue(stats.used_sync_token)
self.assertEqual(stats.deleted, 1)
self.assertEqual(source.last_status, "ok")
self.assertEqual(source.sync_token, "token-2")
self.assertIsNotNone(event.deleted_at)
def recurring_resource() -> str:
return """BEGIN:VCALENDAR

View File

@@ -92,6 +92,7 @@ END:VCALENDAR
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
self.assertEqual(event["duration_seconds"], 5400)
self.assertTrue(event["icalendar"]["standard"]["uses_duration"])
self.assertEqual(event["description"], "Line one\nLine two")
self.assertEqual(event["location"], "Room; 1")
self.assertEqual(event["timezone"], "Europe/Berlin")
@@ -164,6 +165,38 @@ END:VCALENDAR
self.assertIn("BEGIN:VALARM", ics)
self.assertIn("TRIGGER:-PT15M", ics)
def test_generated_ics_preserves_duration_when_source_used_duration(self) -> None:
event = SimpleNamespace(
uid="event-duration@example.test",
start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc),
duration_seconds=5400,
all_day=False,
timezone="Europe/Berlin",
summary="Duration",
sequence=0,
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
description=None,
location=None,
categories=[],
rrule=None,
organizer=None,
attendees=[],
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
icalendar={"standard": {"uses_duration": True}},
)
ics = event_to_ics(event)
self.assertIn("DURATION:PT1H30M", ics)
self.assertNotIn("DTEND", ics)
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
parsed = parse_vevent(
"""BEGIN:VCALENDAR

171
tests/test_sync_sources.py Normal file
View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import json
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import create_engine
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.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest
from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, sync_source
from govoplan_core.db.base import Base
from govoplan_tenancy.backend.db.models import Tenant
class CalendarSyncSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
def tearDown(self) -> None:
Base.metadata.drop_all(bind=self.engine)
def session_with_calendar(self):
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"))
session.flush()
return session, calendar
def test_ics_subscription_sync_imports_events_and_blocks_local_mutation(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/feed.ics",
sync_direction="two_way",
),
)
ics = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:ics-1@example.test
DTSTART:20260708T090000Z
DTEND:20260708T100000Z
SUMMARY:ICS item
END:VEVENT
END:VCALENDAR
"""
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {"etag": '"feed-1"'}, ics)):
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(refreshed.sync_direction, "inbound")
self.assertEqual(stats.created, 1)
self.assertEqual(event.source_kind, "ics")
self.assertEqual(event.summary, "ICS item")
with self.assertRaisesRegex(CalendarError, "inbound-only"):
create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
summary="Local edit",
start_at=datetime(2026, 7, 8, 12, tzinfo=timezone.utc),
),
)
def test_graph_sync_imports_delta_events(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [
{
"id": "graph-event-1",
"iCalUId": "graph-uid-1",
"subject": "Graph item",
"start": {"dateTime": "2026-07-08T09:00:00", "timeZone": "UTC"},
"end": {"dateTime": "2026-07-08T10:00:00", "timeZone": "UTC"},
"@odata.etag": "graph-etag-1",
}
],
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendar/events/delta?$deltatoken=next",
}
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, json.dumps(payload))) as request:
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(stats.created, 1)
self.assertEqual(refreshed.sync_token, payload["@odata.deltaLink"])
self.assertEqual(event.source_kind, "graph")
self.assertEqual(event.source_href, "graph-event-1")
self.assertEqual(event.summary, "Graph item")
self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"])
def test_ews_sync_imports_calendar_view_items(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ews",
calendar_id=calendar.id,
collection_url="https://exchange.example.test/EWS/Exchange.asmx",
auth_type="basic",
username="ada",
password="secret",
),
)
response_xml = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
<s:Body>
<m:FindItemResponse>
<m:ResponseMessages>
<m:FindItemResponseMessage ResponseClass="Success">
<m:RootFolder>
<t:Items>
<t:CalendarItem>
<t:ItemId Id="ews-event-1" ChangeKey="ews-etag-1" />
<t:Subject>EWS item</t:Subject>
<t:Start>2026-07-08T09:00:00Z</t:Start>
<t:End>2026-07-08T10:00:00Z</t:End>
<t:IsAllDayEvent>false</t:IsAllDayEvent>
<t:UID>ews-uid-1</t:UID>
</t:CalendarItem>
</t:Items>
</m:RootFolder>
</m:FindItemResponseMessage>
</m:ResponseMessages>
</m:FindItemResponse>
</s:Body>
</s:Envelope>"""
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, response_xml)) as request:
_refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(stats.created, 1)
self.assertEqual(event.source_kind, "ews")
self.assertEqual(event.source_href, "ews-event-1")
self.assertEqual(event.summary, "EWS item")
self.assertTrue(request.call_args.kwargs["body"].startswith("<?xml"))
if __name__ == "__main__":
unittest.main()