intermittent commit
This commit is contained in:
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -10,7 +9,7 @@ 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, CalendarSyncSource
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential
|
||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||
from govoplan_calendar.backend.service import (
|
||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||
@@ -84,6 +83,14 @@ class FakeCalDAVClient:
|
||||
return CalDAVWriteResult(href=href, status=204)
|
||||
|
||||
|
||||
class FakeNotificationProvider:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[object] = []
|
||||
|
||||
def enqueue_notification(self, _session, request, **_kwargs) -> None:
|
||||
self.requests.append(request)
|
||||
|
||||
|
||||
class CalDAVParsingTests(unittest.TestCase):
|
||||
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
|
||||
result = parse_multistatus(
|
||||
@@ -216,10 +223,8 @@ END:VCALENDAR</C:calendar-data>
|
||||
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()
|
||||
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
|
||||
CalDAVClient(collection_url="/remote.php/dav")
|
||||
|
||||
|
||||
class CalDAVSyncTests(unittest.TestCase):
|
||||
@@ -359,6 +364,34 @@ class CalDAVSyncTests(unittest.TestCase):
|
||||
next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc)
|
||||
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
|
||||
|
||||
def test_due_sync_emits_recovery_notification_after_previous_error(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"),
|
||||
)
|
||||
source.last_status = "error"
|
||||
source.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc)
|
||||
session.commit()
|
||||
|
||||
provider = FakeNotificationProvider()
|
||||
fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1"))
|
||||
with patch("govoplan_calendar.backend.service.notification_dispatch_provider", return_value=provider):
|
||||
results = sync_due_caldav_sources(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc),
|
||||
client_factory=lambda _source: fake,
|
||||
)
|
||||
|
||||
self.assertEqual(results[0].status, "ok")
|
||||
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:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
|
||||
@@ -11,7 +11,7 @@ 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_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, soft_delete_unseen_source_events, sync_source
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
@@ -78,6 +78,55 @@ END:VCALENDAR
|
||||
),
|
||||
)
|
||||
|
||||
def test_ics_not_modified_sync_clears_error_and_reschedules(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",
|
||||
),
|
||||
)
|
||||
source.ctag = '"feed-1"'
|
||||
source.last_status = "error"
|
||||
source.last_error = "previous failure"
|
||||
|
||||
with patch("govoplan_calendar.backend.service.http_request", return_value=(304, {}, "")):
|
||||
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||
|
||||
self.assertEqual(refreshed.last_status, "ok")
|
||||
self.assertIsNone(refreshed.last_error)
|
||||
self.assertIsNotNone(refreshed.last_synced_at)
|
||||
self.assertIsNotNone(refreshed.next_sync_at)
|
||||
self.assertEqual(stats.created, 0)
|
||||
self.assertEqual(stats.updated, 0)
|
||||
self.assertEqual(stats.deleted, 0)
|
||||
|
||||
def test_ics_sync_failure_records_error_and_reschedules(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",
|
||||
),
|
||||
)
|
||||
|
||||
with patch("govoplan_calendar.backend.service.http_request", side_effect=RuntimeError("network down")):
|
||||
with self.assertRaisesRegex(RuntimeError, "network down"):
|
||||
sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||
|
||||
self.assertEqual(source.last_status, "error")
|
||||
self.assertIn("network down", source.last_error or "")
|
||||
self.assertIsNotNone(source.last_attempt_at)
|
||||
self.assertIsNotNone(source.next_sync_at)
|
||||
|
||||
def test_graph_sync_imports_delta_events(self) -> None:
|
||||
session, calendar = self.session_with_calendar()
|
||||
source = create_sync_source(
|
||||
@@ -168,6 +217,59 @@ END:VCALENDAR
|
||||
self.assertEqual(event.summary, "EWS item")
|
||||
self.assertTrue(request.call_args.kwargs["body"].startswith("<?xml"))
|
||||
|
||||
def test_full_sync_cleanup_soft_deletes_unseen_events_in_batches(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/events.ics",
|
||||
),
|
||||
)
|
||||
for index in range(3):
|
||||
session.add(
|
||||
CalendarEvent(
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=calendar.id,
|
||||
uid=f"event-{index}@example.test",
|
||||
recurrence_id=None,
|
||||
summary=f"Event {index}",
|
||||
status="CONFIRMED",
|
||||
transparency="OPAQUE",
|
||||
classification="PUBLIC",
|
||||
start_at=datetime(2026, 7, 8, 9 + index, tzinfo=timezone.utc),
|
||||
all_day=False,
|
||||
attendees=[],
|
||||
categories=[],
|
||||
rdate=[],
|
||||
exdate=[],
|
||||
reminders=[],
|
||||
attachments=[],
|
||||
related_to=[],
|
||||
source_kind="ics",
|
||||
source_href=f"ics-event-{index}",
|
||||
icalendar={},
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
deleted = soft_delete_unseen_source_events(
|
||||
session,
|
||||
source=source,
|
||||
seen_hrefs={"ics-event-1"},
|
||||
batch_size=1,
|
||||
)
|
||||
|
||||
active_hrefs = {
|
||||
event.source_href
|
||||
for event in session.query(CalendarEvent).filter(CalendarEvent.deleted_at.is_(None)).all()
|
||||
}
|
||||
self.assertEqual(deleted, 2)
|
||||
self.assertEqual(active_hrefs, {"ics-event-1"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user