chore: sync GovOPlaN module split state
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user