286 lines
10 KiB
Python
286 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
|
from govoplan_calendar.backend.capabilities import (
|
|
SqlCalendarExternalProfileProvider,
|
|
SqlCalendarInvitationProvider,
|
|
SqlCalendarSchedulingProvider,
|
|
)
|
|
from govoplan_calendar.backend.db.models import CalendarEvent
|
|
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest
|
|
from govoplan_calendar.backend.service import create_calendar
|
|
from govoplan_core.core.calendar import (
|
|
CalendarCapabilityError,
|
|
CalendarEventRequest,
|
|
CalendarExternalProfileRequest,
|
|
CalendarInvitationAttendeeRequest,
|
|
CalendarInvitationRequest,
|
|
)
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.tenancy.scope import create_scope_tables
|
|
from govoplan_tenancy.backend.db.models import Tenant
|
|
|
|
|
|
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),
|
|
),
|
|
)
|
|
|
|
|
|
class CalendarExternalProfileCapabilityTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite:///:memory:")
|
|
create_scope_tables(self.engine)
|
|
Base.metadata.create_all(bind=self.engine)
|
|
self.Session = sessionmaker(bind=self.engine)
|
|
self.session = self.Session()
|
|
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
|
self.calendar = create_calendar(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
payload=CalendarCollectionCreateRequest(name="Open-Xchange"),
|
|
)
|
|
self.provider = SqlCalendarExternalProfileProvider()
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
Base.metadata.drop_all(bind=self.engine)
|
|
self.engine.dispose()
|
|
|
|
def test_open_xchange_profile_uses_caldav_and_retains_mapping_refs(self) -> None:
|
|
configured = self.provider.configure_profile(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=CalendarExternalProfileRequest(
|
|
profile_kind="open-xchange",
|
|
calendar_id=self.calendar.id,
|
|
endpoint_url="https://groupware.example.test/caldav/team/",
|
|
connector_profile_ref="connector:ox-main",
|
|
identity_mapping_ref="idm:ox-main",
|
|
resource_calendar_ref="ox-resource:room-42",
|
|
),
|
|
)
|
|
|
|
source = self.calendar.metadata_["sync_source_id"]
|
|
self.session.refresh(self.calendar)
|
|
self.assertEqual(configured.source_id, source)
|
|
self.assertEqual("caldav", configured.transport_kind)
|
|
self.assertEqual("resource", self.calendar.owner_type)
|
|
self.assertEqual("ox-resource:room-42", self.calendar.owner_id)
|
|
self.assertEqual("open_xchange", self.calendar.metadata_["integration_profile"])
|
|
|
|
def test_unsupported_profile_is_rejected(self) -> None:
|
|
with self.assertRaisesRegex(CalendarCapabilityError, "Unsupported"):
|
|
self.provider.configure_profile(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=CalendarExternalProfileRequest(
|
|
profile_kind="unknown",
|
|
calendar_id=self.calendar.id,
|
|
endpoint_url="https://groupware.example.test/caldav/team/",
|
|
),
|
|
)
|
|
|
|
|
|
class CalendarInvitationCapabilityTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite:///:memory:")
|
|
create_scope_tables(self.engine)
|
|
Base.metadata.create_all(bind=self.engine)
|
|
self.Session = sessionmaker(bind=self.engine)
|
|
self.session = self.Session()
|
|
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
|
self.calendar = create_calendar(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
payload=CalendarCollectionCreateRequest(name="Invitations"),
|
|
)
|
|
self.provider = SqlCalendarInvitationProvider()
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
Base.metadata.drop_all(bind=self.engine)
|
|
self.engine.dispose()
|
|
|
|
def request(self, *, summary: str = "Planning") -> CalendarInvitationRequest:
|
|
return CalendarInvitationRequest(
|
|
correlation_id="campaign-1:recipient-1",
|
|
source_module="campaigns",
|
|
source_resource_type="campaign_recipient",
|
|
source_resource_id="recipient-1",
|
|
calendar_id=self.calendar.id,
|
|
summary=summary,
|
|
start_at=datetime(2026, 8, 5, 9, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 8, 5, 10, tzinfo=timezone.utc),
|
|
organizer={
|
|
"value": "mailto:organizer@example.test",
|
|
"params": {"CN": ["Organizer"]},
|
|
},
|
|
attendees=(
|
|
CalendarInvitationAttendeeRequest(
|
|
address="ada@example.test",
|
|
name="Ada",
|
|
),
|
|
),
|
|
)
|
|
|
|
def test_upsert_and_response_reconciliation_preserve_correlation(self) -> None:
|
|
created = self.provider.upsert_invitation(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=self.request(),
|
|
)
|
|
response = self.provider.record_response(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
correlation_id=created.correlation_id,
|
|
attendee_address="ADA@example.test",
|
|
participation_status="accepted",
|
|
evidence={"transport": "ical-reply"},
|
|
)
|
|
updated = self.provider.upsert_invitation(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=self.request(summary="Updated planning"),
|
|
)
|
|
loaded = self.provider.get_invitation(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
correlation_id=created.correlation_id,
|
|
)
|
|
|
|
self.assertEqual(created.event_id, updated.event_id)
|
|
self.assertEqual("ACCEPTED", response.attendees[0]["params"]["PARTSTAT"][0])
|
|
self.assertEqual("ACCEPTED", updated.attendees[0]["params"]["PARTSTAT"][0])
|
|
self.assertEqual(updated, loaded)
|
|
self.assertFalse(updated.recurrence_supported)
|
|
self.assertTrue(updated.degraded_reasons)
|
|
|
|
def test_invitation_render_batch_lookup_and_summary(self) -> None:
|
|
request = self.request()
|
|
payload = self.provider.render_invitation(request)
|
|
created = self.provider.upsert_invitation(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=request,
|
|
)
|
|
|
|
self.assertIn("METHOD:REQUEST", payload)
|
|
self.assertIn(f"UID:{created.uid}", payload)
|
|
self.assertEqual(
|
|
created,
|
|
self.provider.get_invitations(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
correlation_ids=(created.correlation_id,),
|
|
)[created.correlation_id],
|
|
)
|
|
summary = self.provider.summarize_invitations(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
source_module="campaigns",
|
|
source_resource_type="campaign_recipient",
|
|
source_resource_id="recipient-1",
|
|
)
|
|
self.assertEqual(1, summary["invitation_count"])
|
|
self.assertEqual(1, summary["pending_response_count"])
|
|
calendars = self.provider.list_calendars(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
)
|
|
self.assertEqual((self.calendar.id,), tuple(item.id for item in calendars))
|
|
self.assertTrue(calendars[0].writable)
|
|
|
|
def test_icalendar_reply_updates_attendee_status(self) -> None:
|
|
created = self.provider.upsert_invitation(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=self.request(),
|
|
)
|
|
reply = "\r\n".join(
|
|
(
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"METHOD:REPLY",
|
|
"BEGIN:VEVENT",
|
|
f"UID:{created.uid}",
|
|
"DTSTART:20260805T090000Z",
|
|
"ATTENDEE;CN=Ada;PARTSTAT=TENTATIVE:mailto:ada@example.test",
|
|
"END:VEVENT",
|
|
"END:VCALENDAR",
|
|
"",
|
|
)
|
|
)
|
|
|
|
recorded = self.provider.record_icalendar_reply(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
icalendar=reply,
|
|
evidence={"profile_id": "mail-profile-1", "uid": "42"},
|
|
)
|
|
sequence_after_first_reply = self.session.get(
|
|
CalendarEvent,
|
|
created.event_id,
|
|
).sequence
|
|
replayed = self.provider.record_icalendar_reply(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
icalendar=reply,
|
|
evidence={"profile_id": "mail-profile-1", "uid": "42"},
|
|
)
|
|
|
|
self.assertEqual(1, len(recorded))
|
|
self.assertEqual(1, len(replayed))
|
|
self.assertEqual(
|
|
"TENTATIVE",
|
|
recorded[0].attendees[0]["params"]["PARTSTAT"][0],
|
|
)
|
|
self.assertEqual(
|
|
sequence_after_first_reply,
|
|
self.session.get(CalendarEvent, created.event_id).sequence,
|
|
)
|
|
|
|
def test_reply_must_match_an_existing_attendee(self) -> None:
|
|
created = self.provider.upsert_invitation(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
request=self.request(),
|
|
)
|
|
with self.assertRaisesRegex(CalendarCapabilityError, "not an attendee"):
|
|
self.provider.record_response(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
uid=created.uid,
|
|
attendee_address="mallory@example.test",
|
|
participation_status="DECLINED",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|