Module Package Release / publish-packages (push) Successful in 14s
Release v0.1.24. Coordinated integrity review: GovOPlaN/govoplan-core#298.
414 lines
17 KiB
Python
414 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.orm import Query, Session, sessionmaker
|
|
|
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
|
from govoplan_calendar.backend.db.models import CalendarEvent
|
|
from govoplan_calendar.backend.schemas import (
|
|
CalendarCollectionCreateRequest,
|
|
CalendarEventCreateRequest,
|
|
CalendarEventOccurrenceUpdateRequest,
|
|
CalendarViewPreferencesUpdateRequest,
|
|
)
|
|
from govoplan_calendar.backend.service import (
|
|
CalendarError,
|
|
_bounded_occurrence_candidates,
|
|
OCCURRENCE_DETAIL_FIELDS,
|
|
create_calendar,
|
|
create_event,
|
|
delete_event,
|
|
delete_event_occurrence,
|
|
get_calendar_view_preferences,
|
|
list_event_occurrences,
|
|
list_freebusy,
|
|
update_calendar_view_preferences,
|
|
update_event_occurrence,
|
|
)
|
|
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 CalendarRecurrenceAndPreferenceTests(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="Calendar"),
|
|
)
|
|
self.session.flush()
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
Base.metadata.drop_all(bind=self.engine)
|
|
self.engine.dispose()
|
|
|
|
def recurring_master(self) -> CalendarEvent:
|
|
return create_event(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
payload=CalendarEventCreateRequest(
|
|
calendar_id=self.calendar.id,
|
|
uid="series@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
|
|
),
|
|
rrule={"FREQ": "WEEKLY", "COUNT": "3"},
|
|
),
|
|
)
|
|
|
|
def test_freebusy_returns_all_1001_hourly_occurrences(self) -> None:
|
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
|
create_event(
|
|
self.session, tenant_id="tenant-1", user_id=None,
|
|
payload=CalendarEventCreateRequest(
|
|
calendar_id=self.calendar.id, uid="dense@example.test", summary="Dense series",
|
|
start_at=start, end_at=start + timedelta(minutes=1),
|
|
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
|
|
),
|
|
)
|
|
self.session.commit()
|
|
busy = list_freebusy(
|
|
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
|
|
start_at=start, end_at=start + timedelta(days=43),
|
|
)
|
|
self.assertEqual(1001, len(busy))
|
|
self.assertEqual(start + timedelta(hours=1000), busy[-1]["start_at"])
|
|
|
|
def test_freebusy_over_budget_fails_without_partial_busy_slots(self) -> None:
|
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
|
create_event(
|
|
self.session, tenant_id="tenant-1", user_id=None,
|
|
payload=CalendarEventCreateRequest(
|
|
calendar_id=self.calendar.id, uid="excessive@example.test", summary="Excessive series",
|
|
start_at=start, end_at=start + timedelta(seconds=1),
|
|
rrule={"FREQ": "MINUTELY", "COUNT": "10001"},
|
|
),
|
|
)
|
|
self.session.commit()
|
|
with self.assertRaisesRegex(CalendarError, "could not complete"):
|
|
list_freebusy(
|
|
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
|
|
start_at=start, end_at=start + timedelta(days=8),
|
|
)
|
|
self.assertEqual(1, self.session.query(CalendarEvent).count())
|
|
|
|
def test_full_expansion_byte_cap_and_lightweight_complete_freebusy(self) -> None:
|
|
from govoplan_calendar.backend import service
|
|
|
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
|
create_event(
|
|
self.session, tenant_id="tenant-1", user_id=None,
|
|
payload=CalendarEventCreateRequest(
|
|
calendar_id=self.calendar.id, uid="heavy@example.test", summary="Heavy series",
|
|
description="x" * 65536, start_at=start, end_at=start + timedelta(minutes=1),
|
|
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
|
|
),
|
|
)
|
|
self.session.commit()
|
|
calendar_id = self.calendar.id
|
|
self.session.expunge_all()
|
|
with patch.object(service, "expanded_event_response", wraps=service.expanded_event_response) as expand:
|
|
with self.assertRaisesRegex(CalendarError, "response exceeds its byte limit"):
|
|
list_event_occurrences(self.session, tenant_id="tenant-1", start_at=start, end_at=start + timedelta(days=43), limit=1)
|
|
self.assertLess(expand.call_count, 70)
|
|
statements = []
|
|
loaded = []
|
|
def capture(conn, cursor, statement, parameters, context, executemany):
|
|
statements.append(statement)
|
|
def capture_loaded(session, instance):
|
|
if isinstance(instance, CalendarEvent):
|
|
loaded.append(instance)
|
|
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
|
|
sqlalchemy_event.listen(self.session, "loaded_as_persistent", capture_loaded)
|
|
try:
|
|
with patch.object(service, "event_response", side_effect=AssertionError("Full event data is unnecessary for availability")):
|
|
busy = list_freebusy(self.session, tenant_id="tenant-1", calendar_ids=[calendar_id], start_at=start, end_at=start + timedelta(days=43))
|
|
self.assertEqual(1001, len(busy))
|
|
self.assertEqual([], loaded)
|
|
for field in ("description", "raw_ics", "icalendar", "metadata", "attendees", "attachments"):
|
|
self.assertNotIn(f"calendar_events.{field}", "\n".join(statements))
|
|
finally:
|
|
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
|
|
sqlalchemy_event.remove(self.session, "loaded_as_persistent", capture_loaded)
|
|
|
|
def test_aggregate_sql_projection_hides_over_budget_values_before_driver_decoding(self) -> None:
|
|
from govoplan_calendar.backend import service
|
|
|
|
first = self.recurring_master()
|
|
second = create_event(
|
|
self.session, tenant_id="tenant-1", user_id=None,
|
|
payload=CalendarEventCreateRequest(
|
|
calendar_id=self.calendar.id, uid="second@example.test", summary="Second",
|
|
start_at=first.start_at, end_at=first.end_at, rrule={"FREQ": "DAILY", "COUNT": "2"},
|
|
),
|
|
)
|
|
first.description = second.description = "x" * 1500
|
|
self.session.commit()
|
|
self.session.expunge_all()
|
|
captured = []
|
|
def capture(conn, cursor, statement, parameters, context, executemany):
|
|
captured.append((statement, parameters))
|
|
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
|
|
try:
|
|
with patch.object(service, "MAX_OCCURRENCE_PROJECTION_BYTES", 3000), patch.object(service, "expand_events_occurrences") as worker:
|
|
with self.assertRaisesRegex(CalendarError, "candidate projection exceeds its byte limit"):
|
|
list_event_occurrences(
|
|
self.session, tenant_id="tenant-1", start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
|
)
|
|
worker.assert_not_called()
|
|
finally:
|
|
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
|
|
self.assertEqual(1, len(captured))
|
|
statement, parameters = captured[0]
|
|
self.assertIn("sum(", statement)
|
|
self.assertIn("OVER (ORDER BY", statement)
|
|
with self.engine.connect() as connection:
|
|
raw = list(connection.exec_driver_sql(statement, parameters))
|
|
self.assertEqual(2, len(raw))
|
|
self.assertIsNotNone(raw[0][1])
|
|
self.assertTrue(all(value is None for value in raw[1][1:]))
|
|
|
|
def test_sql_projection_compiles_postgresql_without_binary_json_casts(self) -> None:
|
|
statements = []
|
|
session = Session(bind=create_mock_engine("postgresql://", lambda *args, **kwargs: None))
|
|
def inspect_query(query):
|
|
statements.append(str(query.statement.compile(dialect=postgresql.dialect())))
|
|
return iter(())
|
|
with patch.object(Query, "__iter__", inspect_query):
|
|
self.assertEqual([], _bounded_occurrence_candidates(
|
|
session.query(CalendarEvent).filter(CalendarEvent.tenant_id == "tenant-1").limit(2001),
|
|
OCCURRENCE_DETAIL_FIELDS, [4096],
|
|
))
|
|
self.assertIn("octet_length(CAST(calendar_events.icalendar AS TEXT))", statements[0])
|
|
self.assertIn("CASE WHEN", statements[0])
|
|
self.assertIn("OVER (ORDER BY", statements[0])
|
|
self.assertNotIn("BYTEA", statements[0])
|
|
|
|
def test_candidate_budget_is_not_bypassed_by_response_limit(self) -> None:
|
|
self.recurring_master()
|
|
self.session.commit()
|
|
with patch("govoplan_calendar.backend.service.MAX_OCCURRENCE_CANDIDATES", 0):
|
|
with self.assertRaisesRegex(CalendarError, "Too many recurring series"):
|
|
list_event_occurrences(
|
|
self.session, tenant_id="tenant-1", limit=1,
|
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
|
)
|
|
|
|
def test_admission_rejection_precedes_database_projection(self) -> None:
|
|
from govoplan_core.security.bounded_process import ProcessBudgetError
|
|
|
|
with patch("govoplan_core.security.bounded_process.bounded_operation_admission", side_effect=ProcessBudgetError("busy")):
|
|
with patch.object(self.session, "query") as query:
|
|
with self.assertRaisesRegex(CalendarError, "could not complete"):
|
|
list_event_occurrences(
|
|
self.session, tenant_id="tenant-1",
|
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
|
)
|
|
query.assert_not_called()
|
|
|
|
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
|
|
self,
|
|
) -> None:
|
|
master = self.recurring_master()
|
|
override = update_event_occurrence(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
series_event_id=master.id,
|
|
payload=CalendarEventOccurrenceUpdateRequest(
|
|
recurrence_id="20260715T090000Z",
|
|
summary="Moved planning",
|
|
start_at=datetime(
|
|
2026, 7, 15, 13, 0, tzinfo=timezone.utc
|
|
),
|
|
end_at=datetime(
|
|
2026, 7, 15, 14, 30, tzinfo=timezone.utc
|
|
),
|
|
),
|
|
)
|
|
cancelled = delete_event_occurrence(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
series_event_id=master.id,
|
|
recurrence_id="20260722T090000Z",
|
|
)
|
|
self.session.commit()
|
|
|
|
events = list_event_occurrences(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(
|
|
[event["summary"] for event in events],
|
|
["Planning", "Moved planning"],
|
|
)
|
|
self.assertEqual(events[1]["id"], override.id)
|
|
self.assertEqual(events[1]["series_event_id"], master.id)
|
|
self.assertTrue(events[1]["is_override"])
|
|
self.assertEqual(
|
|
events[1]["start_at"],
|
|
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(cancelled.status, "CANCELLED")
|
|
|
|
busy = list_freebusy(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(len(busy), 2)
|
|
self.assertEqual(
|
|
busy[1]["start_at"],
|
|
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
|
|
)
|
|
|
|
def test_deleting_series_removes_its_overrides(self) -> None:
|
|
master = self.recurring_master()
|
|
update_event_occurrence(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
series_event_id=master.id,
|
|
payload=CalendarEventOccurrenceUpdateRequest(
|
|
recurrence_id="20260715T090000Z",
|
|
summary="Override",
|
|
),
|
|
)
|
|
|
|
delete_event(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
event_id=master.id,
|
|
)
|
|
|
|
active = (
|
|
self.session.query(CalendarEvent)
|
|
.filter(CalendarEvent.deleted_at.is_(None))
|
|
.count()
|
|
)
|
|
self.assertEqual(active, 0)
|
|
|
|
def test_all_day_occurrence_retains_date_recurrence_id(self) -> None:
|
|
master = create_event(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
payload=CalendarEventCreateRequest(
|
|
calendar_id=self.calendar.id,
|
|
uid="all-day-series@example.test",
|
|
summary="All-day planning",
|
|
start_at=datetime(
|
|
2026, 7, 8, tzinfo=timezone.utc
|
|
),
|
|
end_at=datetime(
|
|
2026, 7, 9, tzinfo=timezone.utc
|
|
),
|
|
all_day=True,
|
|
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
|
|
),
|
|
)
|
|
|
|
events = list_event_occurrences(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(
|
|
[event["recurrence_id"] for event in events],
|
|
["20260708", "20260715"],
|
|
)
|
|
|
|
override = update_event_occurrence(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id=None,
|
|
series_event_id=master.id,
|
|
payload=CalendarEventOccurrenceUpdateRequest(
|
|
recurrence_id=events[1]["recurrence_id"],
|
|
summary="Moved all-day planning",
|
|
),
|
|
)
|
|
self.assertEqual(override.recurrence_id, "20260715")
|
|
|
|
def test_calendar_preferences_have_defaults_and_durable_user_overrides(
|
|
self,
|
|
) -> None:
|
|
defaults = get_calendar_view_preferences(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
)
|
|
self.assertTrue(defaults["dim_weekends"])
|
|
self.assertEqual(defaults["overridden_fields"], [])
|
|
|
|
updated = update_calendar_view_preferences(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
payload=CalendarViewPreferencesUpdateRequest(
|
|
dim_weekends=False,
|
|
workday_start_hour=8,
|
|
workday_end_hour=18,
|
|
),
|
|
)
|
|
self.session.commit()
|
|
reloaded = get_calendar_view_preferences(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
)
|
|
|
|
self.assertEqual(updated, reloaded)
|
|
self.assertFalse(reloaded["dim_weekends"])
|
|
self.assertEqual(reloaded["workday_start_hour"], 8)
|
|
self.assertEqual(reloaded["workday_end_hour"], 18)
|
|
self.assertEqual(
|
|
set(reloaded["overridden_fields"]),
|
|
{"dim_weekends", "workday_start_hour", "workday_end_hour"},
|
|
)
|
|
|
|
def test_calendar_preferences_reject_inverted_workday(self) -> None:
|
|
with self.assertRaisesRegex(CalendarError, "end hour"):
|
|
update_calendar_view_preferences(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
payload=CalendarViewPreferencesUpdateRequest(
|
|
workday_start_hour=18,
|
|
workday_end_hour=8,
|
|
),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|