1871 lines
72 KiB
Python
1871 lines
72 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from fastapi import HTTPException
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.access import PrincipalRef
|
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|
from govoplan_core.core.modules import ModuleContext
|
|
from govoplan_core.core.poll import PollCapabilityError
|
|
from govoplan_core.core.registry import PlatformRegistry
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_poll.backend.db.models import (
|
|
Poll,
|
|
PollInvitation,
|
|
PollLifecycleTransition,
|
|
PollOption,
|
|
PollParticipationSubmission,
|
|
PollResponse,
|
|
)
|
|
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
|
|
from govoplan_scheduling.backend import service as scheduling_service
|
|
from govoplan_scheduling.backend.db.models import (
|
|
SchedulingCandidateSlot,
|
|
SchedulingNotification,
|
|
SchedulingParticipant,
|
|
SchedulingRequest,
|
|
)
|
|
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
|
from govoplan_scheduling.backend.router import (
|
|
api_get_my_scheduling_availability,
|
|
api_submit_scheduling_availability,
|
|
api_update_scheduling_candidate_slot,
|
|
api_update_scheduling_request,
|
|
)
|
|
from govoplan_scheduling.backend.runtime import configure_runtime
|
|
from govoplan_scheduling.backend.schemas import (
|
|
SchedulingAvailabilityAnswerInput,
|
|
SchedulingAvailabilityResponseRequest,
|
|
SchedulingCalendarPreferences,
|
|
SchedulingCandidateSlotInput,
|
|
SchedulingCandidateSlotReconcileInput,
|
|
SchedulingCandidateSlotUpdateRequest,
|
|
SchedulingParticipantInput,
|
|
SchedulingParticipantReconcileInput,
|
|
SchedulingPublicParticipationAccessRequest,
|
|
SchedulingPublicParticipationSubmitRequest,
|
|
SchedulingRequestCreateRequest,
|
|
SchedulingRequestUpdateRequest,
|
|
)
|
|
from govoplan_scheduling.backend.security import verify_participant_password
|
|
from govoplan_scheduling.backend.service import (
|
|
SchedulingError,
|
|
SchedulingPublicParticipationError,
|
|
cancel_scheduling_request,
|
|
create_scheduling_request,
|
|
get_public_scheduling_participation,
|
|
issue_scheduling_participant_invitation,
|
|
scheduling_request_summary,
|
|
scheduling_participant_revision,
|
|
scheduling_slot_revision,
|
|
submit_scheduling_availability,
|
|
submit_public_scheduling_participation,
|
|
update_scheduling_candidate_slot,
|
|
)
|
|
|
|
|
|
class SchedulingResponseEditingTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
registry = PlatformRegistry()
|
|
registry.register(get_poll_manifest())
|
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
|
configure_runtime(registry=registry)
|
|
self.engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
Poll.__table__,
|
|
PollOption.__table__,
|
|
PollResponse.__table__,
|
|
PollInvitation.__table__,
|
|
PollParticipationSubmission.__table__,
|
|
PollLifecycleTransition.__table__,
|
|
ChangeSequenceEntry.__table__,
|
|
SchedulingRequest.__table__,
|
|
SchedulingCandidateSlot.__table__,
|
|
SchedulingParticipant.__table__,
|
|
SchedulingNotification.__table__,
|
|
],
|
|
)
|
|
self.Session = sessionmaker(bind=self.engine)
|
|
self.session: Session = self.Session()
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
Base.metadata.drop_all(
|
|
self.engine,
|
|
tables=[
|
|
SchedulingNotification.__table__,
|
|
SchedulingParticipant.__table__,
|
|
SchedulingCandidateSlot.__table__,
|
|
SchedulingRequest.__table__,
|
|
PollParticipationSubmission.__table__,
|
|
PollInvitation.__table__,
|
|
PollLifecycleTransition.__table__,
|
|
ChangeSequenceEntry.__table__,
|
|
PollResponse.__table__,
|
|
PollOption.__table__,
|
|
Poll.__table__,
|
|
],
|
|
)
|
|
self.engine.dispose()
|
|
|
|
@staticmethod
|
|
def _principal(account_id: str, *, email: str | None, scopes: set[str]) -> ApiPrincipal:
|
|
return ApiPrincipal(
|
|
principal=PrincipalRef(
|
|
account_id=account_id,
|
|
membership_id=f"membership-{account_id}",
|
|
tenant_id="tenant-1",
|
|
email=email,
|
|
display_name=account_id,
|
|
scopes=frozenset(scopes),
|
|
),
|
|
account=SimpleNamespace(id=account_id),
|
|
user=SimpleNamespace(id=f"membership-{account_id}"),
|
|
)
|
|
|
|
def _request(
|
|
self,
|
|
*,
|
|
status: str = "collecting",
|
|
allow_participant_updates: bool = True,
|
|
participants: list[SchedulingParticipantInput] | None = None,
|
|
**settings,
|
|
) -> SchedulingRequest:
|
|
request, _tokens = self._request_and_tokens(
|
|
status=status,
|
|
allow_participant_updates=allow_participant_updates,
|
|
participants=participants,
|
|
**settings,
|
|
)
|
|
return request
|
|
|
|
def _request_and_tokens(
|
|
self,
|
|
*,
|
|
status: str = "collecting",
|
|
allow_participant_updates: bool = True,
|
|
participants: list[SchedulingParticipantInput] | None = None,
|
|
**settings,
|
|
) -> tuple[SchedulingRequest, dict[str, str]]:
|
|
issue_links = bool(settings.pop("create_participant_invitations", True))
|
|
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
|
|
request, automatic_tokens = create_scheduling_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="organizer-1",
|
|
payload=SchedulingRequestCreateRequest(
|
|
title="Response editing",
|
|
status=status,
|
|
allow_participant_updates=allow_participant_updates,
|
|
calendar=SchedulingCalendarPreferences(),
|
|
slots=[
|
|
SchedulingCandidateSlotInput(
|
|
label="Monday",
|
|
start_at=start,
|
|
end_at=start + timedelta(hours=1),
|
|
),
|
|
SchedulingCandidateSlotInput(
|
|
label="Tuesday",
|
|
start_at=start + timedelta(days=1),
|
|
end_at=start + timedelta(days=1, hours=1),
|
|
),
|
|
],
|
|
participants=(
|
|
participants
|
|
if participants is not None
|
|
else [
|
|
SchedulingParticipantInput(
|
|
display_name="Alice",
|
|
email="alice@example.test",
|
|
)
|
|
]
|
|
),
|
|
**settings,
|
|
),
|
|
)
|
|
self.assertEqual(automatic_tokens, {})
|
|
tokens = (
|
|
{
|
|
participant.id: self._issue_copy(request, participant)
|
|
for participant in request.participants
|
|
}
|
|
if issue_links
|
|
else {}
|
|
)
|
|
return request, tokens
|
|
|
|
def _issue_copy(
|
|
self,
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant,
|
|
) -> str:
|
|
result = issue_scheduling_participant_invitation(
|
|
self.session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
participant_id=participant.id,
|
|
action="copy",
|
|
)
|
|
self.assertIsNotNone(result.action_url)
|
|
return str(result.action_url).rsplit("/", 1)[-1]
|
|
|
|
def _answer(
|
|
self,
|
|
request: SchedulingRequest,
|
|
*,
|
|
account_id: str,
|
|
email: str,
|
|
values: tuple[str, ...],
|
|
comment: str | None = None,
|
|
) -> None:
|
|
api_submit_scheduling_availability(
|
|
request.id,
|
|
SchedulingAvailabilityResponseRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=slot.id,
|
|
value=value,
|
|
option_revision=scheduling_slot_revision(slot),
|
|
)
|
|
for slot, value in zip(request.slots, values, strict=True)
|
|
],
|
|
comment=comment,
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(account_id, email=email, scopes={RESPOND_SCOPE}),
|
|
)
|
|
|
|
def test_response_settings_are_persisted_and_password_is_write_only(self) -> None:
|
|
request = self._request(
|
|
notify_on_answers=False,
|
|
single_choice=True,
|
|
max_participants_per_option=3,
|
|
allow_maybe=False,
|
|
allow_comments=True,
|
|
participant_email_required=True,
|
|
anonymous_password_protection_enabled=True,
|
|
anonymous_password="correct horse battery staple",
|
|
create_participant_invitations=False,
|
|
)
|
|
|
|
self.assertFalse(request.notify_on_answers)
|
|
self.assertTrue(request.single_choice)
|
|
self.assertEqual(request.max_participants_per_option, 3)
|
|
self.assertFalse(request.allow_maybe)
|
|
self.assertTrue(request.allow_comments)
|
|
self.assertTrue(request.participant_email_required)
|
|
self.assertTrue(request.anonymous_password_protection_enabled)
|
|
self.assertNotIn("correct horse", request.anonymous_password_hash or "")
|
|
self.assertTrue(
|
|
verify_participant_password(
|
|
"correct horse battery staple",
|
|
request.anonymous_password_hash,
|
|
)
|
|
)
|
|
|
|
response = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(title="Response settings, updated"),
|
|
session=self.session,
|
|
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
serialized = response.model_dump()
|
|
self.assertTrue(serialized["anonymous_password_protection_enabled"])
|
|
self.assertTrue(serialized["public_participation_policy_enforcement_available"])
|
|
self.assertIsNone(serialized["public_participation_policy_enforcement_reason"])
|
|
self.assertNotIn("anonymous_password", serialized)
|
|
self.assertNotIn("anonymous_password_hash", serialized)
|
|
|
|
api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(anonymous_password_protection_enabled=False),
|
|
session=self.session,
|
|
principal=self._principal("admin-1", email=None, scopes={ADMIN_SCOPE}),
|
|
)
|
|
self.assertFalse(request.anonymous_password_protection_enabled)
|
|
self.assertIsNone(request.anonymous_password_hash)
|
|
|
|
def test_only_organizer_or_scheduling_admin_can_edit(self) -> None:
|
|
request = self._request()
|
|
|
|
with self.assertRaises(HTTPException) as raised:
|
|
api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(title="Unauthorized change"),
|
|
session=self.session,
|
|
principal=self._principal("other-writer", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 403)
|
|
self.assertEqual(request.title, "Response editing")
|
|
|
|
def test_request_edit_preserves_answers_when_slots_are_unchanged(self) -> None:
|
|
request = self._request()
|
|
self._submit_both(request)
|
|
|
|
response = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
description=None,
|
|
location=None,
|
|
notify_on_answers=False,
|
|
),
|
|
session=self.session,
|
|
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
current = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertIsNone(response.description)
|
|
self.assertIsNone(response.location)
|
|
self.assertFalse(response.notify_on_answers)
|
|
self.assertEqual(len(current.answers), 2)
|
|
|
|
def test_single_choice_maybe_and_comment_settings_are_enforced(self) -> None:
|
|
request = self._request(
|
|
single_choice=True,
|
|
allow_maybe=False,
|
|
create_participant_invitations=False,
|
|
)
|
|
|
|
with self.assertRaises(HTTPException) as multiple:
|
|
self._answer(
|
|
request,
|
|
account_id="alice-account",
|
|
email="alice@example.test",
|
|
values=("available", "available"),
|
|
)
|
|
self.assertEqual(multiple.exception.status_code, 400)
|
|
|
|
with self.assertRaises(HTTPException) as maybe:
|
|
self._answer(
|
|
request,
|
|
account_id="alice-account",
|
|
email="alice@example.test",
|
|
values=("maybe", "unavailable"),
|
|
)
|
|
self.assertEqual(maybe.exception.status_code, 400)
|
|
|
|
with self.assertRaises(HTTPException) as comment:
|
|
self._answer(
|
|
request,
|
|
account_id="alice-account",
|
|
email="alice@example.test",
|
|
values=("available", "unavailable"),
|
|
comment="I prefer Monday",
|
|
)
|
|
self.assertEqual(comment.exception.status_code, 400)
|
|
|
|
def test_comments_round_trip_and_notifications_follow_setting(self) -> None:
|
|
request = self._request(
|
|
allow_comments=True,
|
|
notify_on_answers=False,
|
|
create_participant_invitations=False,
|
|
)
|
|
with patch("govoplan_scheduling.backend.service._emit_scheduling_center_notification") as emit:
|
|
self._answer(
|
|
request,
|
|
account_id="alice-account",
|
|
email="alice@example.test",
|
|
values=("available", "unavailable"),
|
|
comment=" Monday works best. ",
|
|
)
|
|
emit.assert_not_called()
|
|
|
|
response = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
self.assertEqual(response.comment, "Monday works best.")
|
|
poll_response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
|
|
self.assertEqual(poll_response.metadata_["comment"], "Monday works best.")
|
|
|
|
def test_capacity_is_enforced_but_own_response_can_be_edited(self) -> None:
|
|
request = self._request(
|
|
max_participants_per_option=1,
|
|
create_participant_invitations=False,
|
|
participants=[
|
|
SchedulingParticipantInput(display_name="Alice", email="alice@example.test"),
|
|
SchedulingParticipantInput(display_name="Bob", email="bob@example.test"),
|
|
],
|
|
)
|
|
self._answer(
|
|
request,
|
|
account_id="alice-account",
|
|
email="alice@example.test",
|
|
values=("available", "unavailable"),
|
|
)
|
|
|
|
with self.assertRaises(HTTPException) as full:
|
|
self._answer(
|
|
request,
|
|
account_id="bob-account",
|
|
email="bob@example.test",
|
|
values=("available", "unavailable"),
|
|
)
|
|
self.assertEqual(full.exception.status_code, 409)
|
|
|
|
self._answer(
|
|
request,
|
|
account_id="alice-account",
|
|
email="alice@example.test",
|
|
values=("unavailable", "available"),
|
|
)
|
|
self._answer(
|
|
request,
|
|
account_id="bob-account",
|
|
email="bob@example.test",
|
|
values=("available", "unavailable"),
|
|
)
|
|
|
|
summary = scheduling_request_summary(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
request_id=request.id,
|
|
)
|
|
values = {
|
|
item["option_id"]: item["values"]
|
|
for item in summary["option_results"]
|
|
}
|
|
self.assertEqual(values[request.slots[0].poll_option_id]["available"], 1)
|
|
self.assertEqual(values[request.slots[1].poll_option_id]["available"], 1)
|
|
|
|
def test_restricted_signed_participation_uses_governed_gateway(self) -> None:
|
|
request = self._request(
|
|
participant_email_required=True,
|
|
create_participant_invitations=True,
|
|
)
|
|
self.assertEqual(request.participants[0].participation_gateway, "scheduling")
|
|
|
|
with self.assertRaises(HTTPException) as update:
|
|
api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(allow_comments=True),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
self.assertEqual(update.exception.status_code, 400)
|
|
self.assertFalse(request.allow_comments)
|
|
|
|
def test_public_governed_response_is_prefilled_and_idempotent(self) -> None:
|
|
start = datetime(2026, 8, 1, 9, tzinfo=timezone.utc)
|
|
public_request, tokens = create_scheduling_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="organizer-1",
|
|
payload=SchedulingRequestCreateRequest(
|
|
title="Public governed response",
|
|
status="collecting",
|
|
single_choice=True,
|
|
allow_maybe=False,
|
|
allow_comments=True,
|
|
participant_email_required=True,
|
|
anonymous_password_protection_enabled=True,
|
|
anonymous_password="correct horse battery staple",
|
|
calendar=SchedulingCalendarPreferences(),
|
|
slots=[
|
|
SchedulingCandidateSlotInput(
|
|
label="First",
|
|
start_at=start,
|
|
end_at=start + timedelta(hours=1),
|
|
),
|
|
SchedulingCandidateSlotInput(
|
|
label="Second",
|
|
start_at=start + timedelta(days=1),
|
|
end_at=start + timedelta(days=1, hours=1),
|
|
),
|
|
],
|
|
participants=[
|
|
SchedulingParticipantInput(
|
|
display_name="Alice",
|
|
email="alice@example.test",
|
|
)
|
|
],
|
|
),
|
|
)
|
|
self.assertEqual(tokens, {})
|
|
token = self._issue_copy(public_request, public_request.participants[0])
|
|
invitation = self.session.query(PollInvitation).filter(
|
|
PollInvitation.id == public_request.participants[0].poll_invitation_id
|
|
).one()
|
|
self.assertIsNotNone(invitation.token_hash)
|
|
self.assertNotEqual(invitation.token_hash, token)
|
|
access = SchedulingPublicParticipationAccessRequest(
|
|
participant_email="ALICE@EXAMPLE.TEST",
|
|
password="correct horse battery staple",
|
|
)
|
|
initial = get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=public_request.id,
|
|
token=token,
|
|
payload=access,
|
|
client_address="192.0.2.10",
|
|
)
|
|
self.assertFalse(initial["has_response"])
|
|
self.assertNotIn("anonymous_password_hash", initial)
|
|
|
|
submit_payload = SchedulingPublicParticipationSubmitRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=public_request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(public_request.slots[0]),
|
|
),
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=public_request.slots[1].id,
|
|
value="unavailable",
|
|
option_revision=scheduling_slot_revision(public_request.slots[1]),
|
|
),
|
|
],
|
|
participant_email="alice@example.test",
|
|
password="correct horse battery staple",
|
|
comment=" First works. ",
|
|
idempotency_key="public-response-1",
|
|
)
|
|
submitted = submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=public_request.id,
|
|
token=token,
|
|
payload=submit_payload,
|
|
client_address="192.0.2.10",
|
|
)
|
|
replayed = submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=public_request.id,
|
|
token=token,
|
|
payload=submit_payload,
|
|
client_address="192.0.2.10",
|
|
)
|
|
prefilled = get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=public_request.id,
|
|
token=token,
|
|
payload=access,
|
|
client_address="192.0.2.10",
|
|
)
|
|
|
|
self.assertTrue(submitted["has_response"])
|
|
self.assertFalse(submitted["replayed"])
|
|
self.assertTrue(replayed["replayed"])
|
|
self.assertEqual(prefilled["comment"], "First works.")
|
|
self.assertEqual(len(prefilled["answers"]), 2)
|
|
self.assertEqual(
|
|
self.session.query(PollResponse).filter(
|
|
PollResponse.poll_id == public_request.poll_id
|
|
).count(),
|
|
1,
|
|
)
|
|
|
|
def test_public_password_failures_are_generic_and_throttled_before_verify(self) -> None:
|
|
start = datetime(2026, 8, 10, 9, tzinfo=timezone.utc)
|
|
request, tokens = create_scheduling_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="organizer-1",
|
|
payload=SchedulingRequestCreateRequest(
|
|
title="Protected response",
|
|
status="collecting",
|
|
anonymous_password_protection_enabled=True,
|
|
anonymous_password="correct horse battery staple",
|
|
calendar=SchedulingCalendarPreferences(),
|
|
slots=[
|
|
SchedulingCandidateSlotInput(
|
|
start_at=start,
|
|
end_at=start + timedelta(hours=1),
|
|
)
|
|
],
|
|
participants=[SchedulingParticipantInput(display_name="Guest")],
|
|
),
|
|
)
|
|
self.assertEqual(tokens, {})
|
|
token = self._issue_copy(request, request.participants[0])
|
|
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password")
|
|
|
|
for _attempt in range(20):
|
|
with self.assertRaises(SchedulingPublicParticipationError) as missing:
|
|
get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationAccessRequest(),
|
|
client_address="192.0.2.20",
|
|
)
|
|
self.assertEqual(missing.exception.retry_after_seconds, 0)
|
|
|
|
for attempt in range(10):
|
|
with self.assertRaises(SchedulingPublicParticipationError) as raised:
|
|
get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=wrong,
|
|
client_address="192.0.2.20",
|
|
)
|
|
self.assertEqual(
|
|
str(raised.exception),
|
|
"Scheduling participation link or credentials are invalid",
|
|
)
|
|
if attempt < 9:
|
|
self.assertEqual(raised.exception.retry_after_seconds, 0)
|
|
self.assertGreater(raised.exception.retry_after_seconds, 0)
|
|
|
|
with patch(
|
|
"govoplan_scheduling.backend.service.verify_participant_password"
|
|
) as verify:
|
|
with self.assertRaises(SchedulingPublicParticipationError) as blocked:
|
|
get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=wrong,
|
|
client_address="192.0.2.20",
|
|
)
|
|
verify.assert_not_called()
|
|
self.assertGreater(blocked.exception.retry_after_seconds, 0)
|
|
|
|
def test_public_email_is_bound_after_first_anonymous_response(self) -> None:
|
|
start = datetime(2026, 8, 12, 9, tzinfo=timezone.utc)
|
|
request, tokens = create_scheduling_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
user_id="organizer-1",
|
|
payload=SchedulingRequestCreateRequest(
|
|
title="Email-bound response",
|
|
status="collecting",
|
|
participant_email_required=True,
|
|
calendar=SchedulingCalendarPreferences(),
|
|
slots=[
|
|
SchedulingCandidateSlotInput(
|
|
start_at=start,
|
|
end_at=start + timedelta(hours=1),
|
|
)
|
|
],
|
|
participants=[SchedulingParticipantInput(display_name="Guest")],
|
|
),
|
|
)
|
|
participant = request.participants[0]
|
|
self.assertEqual(tokens, {})
|
|
token = self._issue_copy(request, participant)
|
|
answer = SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(request.slots[0]),
|
|
)
|
|
submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationSubmitRequest(
|
|
answers=[answer],
|
|
participant_email="first@example.test",
|
|
),
|
|
client_address="192.0.2.30",
|
|
)
|
|
|
|
with self.assertRaises(SchedulingPublicParticipationError):
|
|
submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationSubmitRequest(
|
|
answers=[answer],
|
|
participant_email="second@example.test",
|
|
),
|
|
client_address="192.0.2.30",
|
|
)
|
|
|
|
self.assertEqual(participant.email, "first@example.test")
|
|
self.assertTrue(
|
|
participant.respondent_id.startswith("scheduling-participant:")
|
|
)
|
|
self.assertEqual(
|
|
self.session.query(PollResponse).filter(
|
|
PollResponse.poll_id == request.poll_id
|
|
).count(),
|
|
1,
|
|
)
|
|
|
|
def test_public_submission_locks_scheduling_before_poll_submission(self) -> None:
|
|
request, tokens = self._request_and_tokens()
|
|
token = tokens[request.participants[0].id]
|
|
provider = scheduling_service._poll_participation_provider()
|
|
self.assertIsNotNone(provider)
|
|
events: list[str] = []
|
|
original_lock = scheduling_service._lock_scheduling_request
|
|
original_submit = provider.submit_governed_response
|
|
|
|
def recording_lock(*args, **kwargs):
|
|
self.assertTrue(kwargs["lock_participants"])
|
|
events.append("scheduling")
|
|
return original_lock(*args, **kwargs)
|
|
|
|
def recording_submit(*args, **kwargs):
|
|
self.assertEqual(events, ["scheduling"])
|
|
events.append("poll")
|
|
return original_submit(*args, **kwargs)
|
|
|
|
with (
|
|
patch.object(
|
|
scheduling_service,
|
|
"_lock_scheduling_request",
|
|
side_effect=recording_lock,
|
|
),
|
|
patch.object(
|
|
provider,
|
|
"submit_governed_response",
|
|
side_effect=recording_submit,
|
|
),
|
|
):
|
|
submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationSubmitRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(
|
|
request.slots[0]
|
|
),
|
|
)
|
|
],
|
|
participant_email="alice@example.test",
|
|
),
|
|
client_address="192.0.2.40",
|
|
)
|
|
|
|
self.assertEqual(events, ["scheduling", "poll"])
|
|
|
|
def test_scheduling_status_and_deadline_guard_all_governed_submissions(self) -> None:
|
|
request, tokens = self._request_and_tokens()
|
|
token = tokens[request.participants[0].id]
|
|
answer = SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(request.slots[0]),
|
|
)
|
|
authenticated_payload = SchedulingAvailabilityResponseRequest(
|
|
answers=[answer]
|
|
)
|
|
public_payload = SchedulingPublicParticipationSubmitRequest(
|
|
answers=[answer],
|
|
participant_email="alice@example.test",
|
|
)
|
|
|
|
# Simulate a stale Poll projection that remains open after Scheduling
|
|
# has already closed response collection.
|
|
request.status = "closed"
|
|
with self.assertRaisesRegex(
|
|
SchedulingError,
|
|
"not collecting availability",
|
|
):
|
|
submit_scheduling_availability(
|
|
self.session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
actor_ids=("alice@example.test",),
|
|
respondent_id="alice-account",
|
|
respondent_label="Alice",
|
|
payload=authenticated_payload,
|
|
)
|
|
with self.assertRaises(SchedulingPublicParticipationError):
|
|
submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=public_payload,
|
|
client_address="192.0.2.42",
|
|
)
|
|
|
|
# Likewise, invitation expiry drift must not override Scheduling's
|
|
# authoritative deadline.
|
|
request.status = "collecting"
|
|
request.deadline_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
invitation = (
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == request.participants[0].poll_invitation_id)
|
|
.one()
|
|
)
|
|
invitation.expires_at = None
|
|
with self.assertRaisesRegex(SchedulingError, "deadline has passed"):
|
|
submit_scheduling_availability(
|
|
self.session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
actor_ids=("alice@example.test",),
|
|
respondent_id="alice-account",
|
|
respondent_label="Alice",
|
|
payload=authenticated_payload,
|
|
)
|
|
with self.assertRaises(SchedulingPublicParticipationError):
|
|
submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=public_payload,
|
|
client_address="192.0.2.42",
|
|
)
|
|
|
|
self.assertEqual(
|
|
self.session.query(PollResponse)
|
|
.filter(PollResponse.poll_id == request.poll_id)
|
|
.count(),
|
|
0,
|
|
)
|
|
|
|
def test_deadline_changes_update_same_link_and_preserve_prior_response(self) -> None:
|
|
initial_deadline = datetime(2030, 8, 10, 12, tzinfo=timezone.utc)
|
|
request, tokens = self._request_and_tokens(
|
|
deadline_at=initial_deadline,
|
|
allow_comments=True,
|
|
)
|
|
participant = request.participants[0]
|
|
token = tokens[participant.id]
|
|
submit_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationSubmitRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(request.slots[0]),
|
|
)
|
|
],
|
|
participant_email="alice@example.test",
|
|
comment="Keep this response.",
|
|
),
|
|
client_address="192.0.2.41",
|
|
)
|
|
self.session.commit()
|
|
|
|
invitation_id = participant.poll_invitation_id
|
|
invitation = (
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == invitation_id)
|
|
.one()
|
|
)
|
|
token_hash = invitation.token_hash
|
|
responded_at = participant.responded_at
|
|
deadlines = (
|
|
datetime(2030, 8, 20, 12, tzinfo=timezone.utc),
|
|
datetime.now(timezone.utc) - timedelta(minutes=1),
|
|
datetime(2030, 8, 15, 12, tzinfo=timezone.utc),
|
|
None,
|
|
)
|
|
for deadline in deadlines:
|
|
response = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(deadline_at=deadline),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
projected = response.participants[0]
|
|
self.assertEqual(projected.status, "responded")
|
|
self.assertIsNone(projected.invitation_token)
|
|
self.assertEqual(projected.poll_invitation_id, invitation_id)
|
|
participant = (
|
|
self.session.query(SchedulingParticipant)
|
|
.filter(SchedulingParticipant.id == participant.id)
|
|
.populate_existing()
|
|
.one()
|
|
)
|
|
invitation = (
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == invitation_id)
|
|
.populate_existing()
|
|
.one()
|
|
)
|
|
self.assertEqual(participant.poll_invitation_id, invitation_id)
|
|
self.assertEqual(participant.responded_at, responded_at)
|
|
self.assertEqual(participant.status, "responded")
|
|
self.assertEqual(
|
|
scheduling_service.response_datetime(invitation.expires_at),
|
|
deadline,
|
|
)
|
|
self.assertEqual(invitation.token_hash, token_hash)
|
|
self.assertIsNone(invitation.revoked_at)
|
|
if deadline is not None and deadline <= datetime.now(timezone.utc):
|
|
with self.assertRaises(SchedulingPublicParticipationError):
|
|
get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationAccessRequest(
|
|
participant_email="alice@example.test"
|
|
),
|
|
client_address="192.0.2.41",
|
|
)
|
|
continue
|
|
prefilled = get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationAccessRequest(
|
|
participant_email="alice@example.test"
|
|
),
|
|
client_address="192.0.2.41",
|
|
)
|
|
self.assertTrue(prefilled["has_response"])
|
|
self.assertEqual(prefilled["comment"], "Keep this response.")
|
|
|
|
self.assertEqual(
|
|
self.session.query(PollResponse)
|
|
.filter(PollResponse.poll_id == request.poll_id)
|
|
.count(),
|
|
1,
|
|
)
|
|
|
|
def test_deadline_expiry_update_rolls_back_when_poll_rejects_it(self) -> None:
|
|
original_deadline = datetime(2030, 9, 10, 12, tzinfo=timezone.utc)
|
|
request, tokens = self._request_and_tokens(deadline_at=original_deadline)
|
|
participant = request.participants[0]
|
|
token = tokens[participant.id]
|
|
invitation_id = participant.poll_invitation_id
|
|
request_id = request.id
|
|
participant_id = participant.id
|
|
self.session.commit()
|
|
|
|
provider = scheduling_service._poll_participation_provider()
|
|
self.assertIsNotNone(provider)
|
|
with patch.object(
|
|
provider,
|
|
"update_invitation_expiry",
|
|
side_effect=PollCapabilityError("Expiry update failed"),
|
|
):
|
|
with self.assertRaises(HTTPException):
|
|
api_update_scheduling_request(
|
|
request_id,
|
|
SchedulingRequestUpdateRequest(
|
|
deadline_at=datetime(
|
|
2030,
|
|
9,
|
|
20,
|
|
12,
|
|
tzinfo=timezone.utc,
|
|
)
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
self.session.rollback()
|
|
|
|
persisted_request = (
|
|
self.session.query(SchedulingRequest)
|
|
.filter(SchedulingRequest.id == request_id)
|
|
.populate_existing()
|
|
.one()
|
|
)
|
|
persisted_participant = (
|
|
self.session.query(SchedulingParticipant)
|
|
.filter(SchedulingParticipant.id == participant_id)
|
|
.populate_existing()
|
|
.one()
|
|
)
|
|
persisted_invitation = (
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == invitation_id)
|
|
.populate_existing()
|
|
.one()
|
|
)
|
|
self.assertEqual(
|
|
scheduling_service.response_datetime(persisted_request.deadline_at),
|
|
original_deadline,
|
|
)
|
|
self.assertEqual(persisted_participant.poll_invitation_id, invitation_id)
|
|
self.assertIsNone(persisted_invitation.revoked_at)
|
|
self.assertEqual(
|
|
scheduling_service.response_datetime(persisted_invitation.expires_at),
|
|
original_deadline,
|
|
)
|
|
self.assertEqual(
|
|
get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request_id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationAccessRequest(
|
|
participant_email="alice@example.test"
|
|
),
|
|
client_address="192.0.2.41",
|
|
)["request_id"],
|
|
request_id,
|
|
)
|
|
|
|
def test_full_edit_reconciles_slots_participants_and_revokes_removed_link(self) -> None:
|
|
request = self._request(create_participant_invitations=False)
|
|
self._submit_both(request)
|
|
first_slot, removed_slot = request.slots
|
|
alice = request.participants[0]
|
|
new_start = datetime(2026, 7, 23, 9, tzinfo=timezone.utc)
|
|
|
|
response = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
slots=[
|
|
SchedulingCandidateSlotReconcileInput(
|
|
id=first_slot.id,
|
|
revision=scheduling_slot_revision(first_slot),
|
|
label=first_slot.label,
|
|
description=first_slot.description,
|
|
start_at=first_slot.start_at.replace(tzinfo=timezone.utc),
|
|
end_at=first_slot.end_at.replace(tzinfo=timezone.utc),
|
|
timezone=first_slot.timezone,
|
|
location=first_slot.location,
|
|
metadata=first_slot.metadata_ or {},
|
|
),
|
|
SchedulingCandidateSlotReconcileInput(
|
|
label="Wednesday",
|
|
start_at=new_start,
|
|
end_at=new_start + timedelta(hours=1),
|
|
),
|
|
],
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
id=alice.id,
|
|
revision=scheduling_participant_revision(alice),
|
|
respondent_id=alice.respondent_id,
|
|
display_name=alice.display_name,
|
|
email=alice.email,
|
|
participant_type=alice.participant_type,
|
|
required=alice.required,
|
|
metadata=alice.metadata_ or {},
|
|
),
|
|
SchedulingParticipantReconcileInput(
|
|
display_name="Bob",
|
|
email="BOB@EXAMPLE.TEST",
|
|
),
|
|
],
|
|
create_participant_invitations=True,
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual([slot.label for slot in response.slots], ["Monday", "Wednesday"])
|
|
self.assertIsNotNone(removed_slot.deleted_at)
|
|
bob = next(item for item in response.participants if item.display_name == "Bob")
|
|
self.assertEqual(bob.email, "bob@example.test")
|
|
self.assertIsNone(bob.poll_invitation_id)
|
|
self.assertIsNone(bob.invitation_token)
|
|
bob_model = next(
|
|
item
|
|
for item in request.participants
|
|
if item.deleted_at is None and item.display_name == "Bob"
|
|
)
|
|
self._issue_copy(request, bob_model)
|
|
bob_invitation_id = bob_model.poll_invitation_id
|
|
self.assertIsNotNone(bob_invitation_id)
|
|
current = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="ALICE@EXAMPLE.TEST",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
[(answer.slot_id, answer.value) for answer in current.answers],
|
|
[(first_slot.id, "available")],
|
|
)
|
|
|
|
retained_slots = [slot for slot in request.slots if slot.deleted_at is None]
|
|
api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
slots=[
|
|
SchedulingCandidateSlotReconcileInput(
|
|
id=slot.id,
|
|
revision=scheduling_slot_revision(slot),
|
|
label=slot.label,
|
|
description=slot.description,
|
|
start_at=slot.start_at.replace(tzinfo=timezone.utc),
|
|
end_at=slot.end_at.replace(tzinfo=timezone.utc),
|
|
timezone=slot.timezone,
|
|
location=slot.location,
|
|
metadata=slot.metadata_ or {},
|
|
)
|
|
for slot in retained_slots
|
|
],
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
id=alice.id,
|
|
revision=scheduling_participant_revision(alice),
|
|
respondent_id=alice.respondent_id,
|
|
display_name=alice.display_name,
|
|
email=alice.email,
|
|
participant_type=alice.participant_type,
|
|
required=alice.required,
|
|
metadata=alice.metadata_ or {},
|
|
)
|
|
],
|
|
create_participant_invitations=False,
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
invitation = self.session.query(PollInvitation).filter(
|
|
PollInvitation.id == bob_invitation_id
|
|
).one()
|
|
self.assertIsNotNone(invitation.revoked_at)
|
|
|
|
def test_full_edit_rejects_stale_slot_revision(self) -> None:
|
|
request = self._request(create_participant_invitations=False)
|
|
slot = request.slots[0]
|
|
|
|
with self.assertRaises(HTTPException) as raised:
|
|
api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
slots=[
|
|
SchedulingCandidateSlotReconcileInput(
|
|
id=current.id,
|
|
revision=("0" * 64 if current.id == slot.id else scheduling_slot_revision(current)),
|
|
label=current.label,
|
|
description=current.description,
|
|
start_at=current.start_at.replace(tzinfo=timezone.utc),
|
|
end_at=current.end_at.replace(tzinfo=timezone.utc),
|
|
timezone=current.timezone,
|
|
location=current.location,
|
|
metadata=current.metadata_ or {},
|
|
)
|
|
for current in request.slots
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 409)
|
|
|
|
def test_full_edit_reorders_slots_and_poll_options_without_losing_answers(self) -> None:
|
|
request = self._request(create_participant_invitations=False)
|
|
self._submit_both(request)
|
|
first_slot, second_slot = request.slots
|
|
first_slot.tentative_hold_event_id = "calendar-hold-1"
|
|
self.session.flush()
|
|
|
|
def reconcile_input(
|
|
slot: SchedulingCandidateSlot,
|
|
) -> SchedulingCandidateSlotReconcileInput:
|
|
return SchedulingCandidateSlotReconcileInput(
|
|
id=slot.id,
|
|
revision=scheduling_slot_revision(slot),
|
|
label=slot.label,
|
|
description=slot.description,
|
|
start_at=slot.start_at.replace(tzinfo=timezone.utc),
|
|
end_at=slot.end_at.replace(tzinfo=timezone.utc),
|
|
timezone=slot.timezone,
|
|
location=slot.location,
|
|
metadata=slot.metadata_ or {},
|
|
)
|
|
|
|
response = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
slots=[reconcile_input(second_slot), reconcile_input(first_slot)]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(
|
|
[(slot.id, slot.position) for slot in response.slots],
|
|
[(second_slot.id, 0), (first_slot.id, 1)],
|
|
)
|
|
poll_options = (
|
|
self.session.query(PollOption)
|
|
.filter(
|
|
PollOption.poll_id == request.poll_id,
|
|
PollOption.deleted_at.is_(None),
|
|
)
|
|
.order_by(PollOption.position.asc())
|
|
.all()
|
|
)
|
|
self.assertEqual(
|
|
[option.id for option in poll_options],
|
|
[second_slot.poll_option_id, first_slot.poll_option_id],
|
|
)
|
|
current = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
{answer.slot_id: answer.value for answer in current.answers},
|
|
{first_slot.id: "available", second_slot.id: "maybe"},
|
|
)
|
|
self.assertEqual(first_slot.tentative_hold_event_id, "calendar-hold-1")
|
|
|
|
replayed = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
slots=[reconcile_input(second_slot), reconcile_input(first_slot)]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
[(slot.id, slot.position) for slot in replayed.slots],
|
|
[(second_slot.id, 0), (first_slot.id, 1)],
|
|
)
|
|
current = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
{answer.slot_id: answer.value for answer in current.answers},
|
|
{first_slot.id: "available", second_slot.id: "maybe"},
|
|
)
|
|
|
|
def test_identity_replacement_revokes_access_retires_response_and_notifies(self) -> None:
|
|
request = self._request()
|
|
self._submit_both(request)
|
|
original = request.participants[0]
|
|
invitation_id = original.poll_invitation_id
|
|
self.assertIsNotNone(invitation_id)
|
|
active_response = (
|
|
self.session.query(PollResponse)
|
|
.filter(
|
|
PollResponse.poll_id == request.poll_id,
|
|
PollResponse.deleted_at.is_(None),
|
|
)
|
|
.one()
|
|
)
|
|
original_answers = [dict(answer) for answer in active_response.answers]
|
|
|
|
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
|
updated = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
id=original.id,
|
|
revision=scheduling_participant_revision(original),
|
|
respondent_id=original.respondent_id,
|
|
display_name="Alice Replacement",
|
|
email="replacement@example.test",
|
|
participant_type=original.participant_type,
|
|
required=original.required,
|
|
metadata=original.metadata_ or {},
|
|
)
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(updated.participants), 1)
|
|
replacement = updated.participants[0]
|
|
self.assertNotEqual(replacement.id, original.id)
|
|
self.assertEqual(replacement.email, "replacement@example.test")
|
|
self.assertIsNone(replacement.poll_invitation_id)
|
|
self.assertEqual(original.status, "removed")
|
|
self.assertIsNotNone(original.deleted_at)
|
|
self.assertEqual(
|
|
original.metadata_["participant_retirement"][
|
|
"replacement_participant_id"
|
|
],
|
|
replacement.id,
|
|
)
|
|
invitation = (
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == invitation_id)
|
|
.one()
|
|
)
|
|
self.assertIsNotNone(invitation.revoked_at)
|
|
self.assertIsNotNone(active_response.deleted_at)
|
|
self.assertEqual(active_response.answers, original_answers)
|
|
self.assertEqual(
|
|
active_response.metadata_["response_retirement"]["reason"],
|
|
"scheduling_participant_replaced",
|
|
)
|
|
self.assertEqual(
|
|
scheduling_request_summary(
|
|
self.session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
)["response_count"],
|
|
0,
|
|
)
|
|
notice = (
|
|
self.session.query(SchedulingNotification)
|
|
.filter(
|
|
SchedulingNotification.participant_id == original.id,
|
|
SchedulingNotification.event_kind == "participant_replaced",
|
|
)
|
|
.one()
|
|
)
|
|
self.assertEqual(notice.recipient, "alice@example.test")
|
|
audit_calls = [call.kwargs for call in audit.call_args_list]
|
|
replacement_audit = next(
|
|
item
|
|
for item in audit_calls
|
|
if item["action"] == "scheduling.participant_identity_replaced"
|
|
)
|
|
self.assertEqual(
|
|
replacement_audit["details"]["replacement_participant_id"],
|
|
replacement.id,
|
|
)
|
|
self.assertEqual(replacement_audit["details"]["retired_response_count"], 1)
|
|
self.assertNotIn("alice@example.test", repr(audit_calls))
|
|
self.assertNotIn("replacement@example.test", repr(audit_calls))
|
|
|
|
def test_stable_account_corrections_keep_identity_and_only_revoke_stale_link(self) -> None:
|
|
request = self._request(
|
|
participants=[
|
|
SchedulingParticipantInput(
|
|
respondent_id="alice-account",
|
|
display_name="Ailce",
|
|
email="alice@example.test",
|
|
participant_type="internal",
|
|
)
|
|
]
|
|
)
|
|
self._submit_both(request)
|
|
participant = request.participants[0]
|
|
invitation_id = participant.poll_invitation_id
|
|
self.assertIsNotNone(invitation_id)
|
|
|
|
renamed = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
id=participant.id,
|
|
revision=scheduling_participant_revision(participant),
|
|
respondent_id=participant.respondent_id,
|
|
display_name="Alice",
|
|
email=participant.email,
|
|
participant_type=participant.participant_type,
|
|
required=participant.required,
|
|
metadata=participant.metadata_ or {},
|
|
)
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(renamed.participants[0].id, participant.id)
|
|
self.assertEqual(participant.display_name, "Alice")
|
|
self.assertEqual(participant.poll_invitation_id, invitation_id)
|
|
self.assertIsNone(
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == invitation_id)
|
|
.one()
|
|
.revoked_at
|
|
)
|
|
|
|
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
|
corrected = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
id=participant.id,
|
|
revision=scheduling_participant_revision(participant),
|
|
respondent_id=participant.respondent_id,
|
|
display_name=participant.display_name,
|
|
email="alice.corrected@example.test",
|
|
participant_type=participant.participant_type,
|
|
required=participant.required,
|
|
metadata=participant.metadata_ or {},
|
|
)
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(corrected.participants[0].id, participant.id)
|
|
self.assertEqual(participant.email, "alice.corrected@example.test")
|
|
self.assertIsNone(participant.poll_invitation_id)
|
|
self.assertIsNotNone(
|
|
self.session.query(PollInvitation)
|
|
.filter(PollInvitation.id == invitation_id)
|
|
.one()
|
|
.revoked_at
|
|
)
|
|
self.assertEqual(
|
|
self.session.query(PollResponse)
|
|
.filter(
|
|
PollResponse.poll_id == request.poll_id,
|
|
PollResponse.deleted_at.is_(None),
|
|
)
|
|
.count(),
|
|
1,
|
|
)
|
|
contact_audit = next(
|
|
call.kwargs
|
|
for call in audit.call_args_list
|
|
if call.kwargs["action"] == "scheduling.participant_contact_updated"
|
|
)
|
|
self.assertTrue(contact_audit["details"]["invitation_revoked"])
|
|
|
|
def test_stale_participant_revision_is_rejected_without_revoking_access(self) -> None:
|
|
request = self._request()
|
|
participant = request.participants[0]
|
|
invitation_id = participant.poll_invitation_id
|
|
|
|
with self.assertRaises(HTTPException) as raised:
|
|
api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
id=participant.id,
|
|
revision="0" * 64,
|
|
respondent_id=participant.respondent_id,
|
|
display_name="Stale update",
|
|
email=participant.email,
|
|
participant_type=participant.participant_type,
|
|
required=participant.required,
|
|
metadata=participant.metadata_ or {},
|
|
)
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(raised.exception.status_code, 409)
|
|
self.assertEqual(participant.poll_invitation_id, invitation_id)
|
|
self.assertIsNone(participant.deleted_at)
|
|
|
|
def test_draft_edit_does_not_issue_link_for_added_participant(self) -> None:
|
|
request = self._request(
|
|
status="draft",
|
|
participants=[],
|
|
create_participant_invitations=False,
|
|
)
|
|
response = api_update_scheduling_request(
|
|
request.id,
|
|
SchedulingRequestUpdateRequest(
|
|
participants=[
|
|
SchedulingParticipantReconcileInput(
|
|
display_name="Draft participant",
|
|
email="draft@example.test",
|
|
)
|
|
],
|
|
create_participant_invitations=True,
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"organizer-1",
|
|
email=None,
|
|
scopes={WRITE_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(len(response.participants), 1)
|
|
self.assertEqual(response.participants[0].status, "draft")
|
|
self.assertIsNone(response.participants[0].poll_invitation_id)
|
|
self.assertIsNone(response.participants[0].invitation_token)
|
|
|
|
def _submit_both(self, request: SchedulingRequest) -> None:
|
|
api_submit_scheduling_availability(
|
|
request.id,
|
|
SchedulingAvailabilityResponseRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(request.slots[0]),
|
|
),
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[1].id,
|
|
value="maybe",
|
|
option_revision=scheduling_slot_revision(request.slots[1]),
|
|
),
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
|
|
def test_current_participant_response_is_prefilled_and_changed_slot_is_invalidated(self) -> None:
|
|
request = self._request()
|
|
self._submit_both(request)
|
|
alice = self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
)
|
|
|
|
before = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=alice,
|
|
)
|
|
|
|
self.assertTrue(before.has_response)
|
|
self.assertIsNotNone(before.submitted_at)
|
|
self.assertEqual(before.participant_id, request.participants[0].id)
|
|
self.assertEqual(
|
|
[(answer.slot_id, answer.value) for answer in before.answers],
|
|
[(request.slots[0].id, "available"), (request.slots[1].id, "maybe")],
|
|
)
|
|
|
|
api_update_scheduling_candidate_slot(
|
|
request.id,
|
|
request.slots[0].id,
|
|
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
|
session=self.session,
|
|
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
after = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=alice,
|
|
)
|
|
|
|
self.assertTrue(after.has_response)
|
|
self.assertEqual(
|
|
[(answer.slot_id, answer.value) for answer in after.answers],
|
|
[(request.slots[1].id, "maybe")],
|
|
)
|
|
poll_response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
|
|
self.assertEqual(
|
|
[answer["option_id"] for answer in poll_response.answers],
|
|
[request.slots[1].poll_option_id],
|
|
)
|
|
|
|
# Re-submit and repeat the exact option snapshot: an idempotent no-op
|
|
# must preserve both current answers.
|
|
self._submit_both(request)
|
|
api_update_scheduling_candidate_slot(
|
|
request.id,
|
|
request.slots[0].id,
|
|
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
|
session=self.session,
|
|
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
unchanged = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=alice,
|
|
)
|
|
self.assertEqual(len(unchanged.answers), 2)
|
|
|
|
def test_calendar_hold_rejection_leaves_slot_and_answers_unchanged(self) -> None:
|
|
request = self._request()
|
|
self._submit_both(request)
|
|
slot = request.slots[0]
|
|
original_start = slot.start_at
|
|
slot.tentative_hold_event_id = "event-1"
|
|
self.session.flush()
|
|
|
|
with self.assertRaisesRegex(SchedulingError, "tentative calendar hold"):
|
|
update_scheduling_candidate_slot(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
request_id=request.id,
|
|
slot_id=slot.id,
|
|
payload=SchedulingCandidateSlotUpdateRequest(
|
|
start_at=original_start.replace(tzinfo=timezone.utc) + timedelta(minutes=15),
|
|
),
|
|
)
|
|
|
|
self.assertEqual(slot.start_at, original_start)
|
|
response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
|
|
self.assertEqual(len(response.answers), 2)
|
|
|
|
def test_cancelling_draft_request_preserves_draft_poll(self) -> None:
|
|
request = self._request(status="draft")
|
|
|
|
cancelled = cancel_scheduling_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
request_id=request.id,
|
|
)
|
|
poll = self.session.query(Poll).filter(Poll.id == request.poll_id).one()
|
|
|
|
self.assertEqual(cancelled.status, "cancelled")
|
|
self.assertIsNotNone(cancelled.cancelled_at)
|
|
self.assertEqual(poll.status, "draft")
|
|
|
|
def test_cancellation_link_becomes_bounded_notice_without_request_details(self) -> None:
|
|
request, tokens = self._request_and_tokens()
|
|
participant = request.participants[0]
|
|
invitation = self.session.query(PollInvitation).filter(
|
|
PollInvitation.id == participant.poll_invitation_id
|
|
).one()
|
|
token = tokens[participant.id]
|
|
cancelled_at = datetime(2026, 7, 22, 12, tzinfo=timezone.utc)
|
|
|
|
with (
|
|
patch.object(scheduling_service, "_now", return_value=cancelled_at),
|
|
patch.object(
|
|
scheduling_service,
|
|
"get_settings",
|
|
return_value=SimpleNamespace(
|
|
scheduling_cancellation_notice_days=7
|
|
),
|
|
),
|
|
):
|
|
cancelled = cancel_scheduling_request(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
request_id=request.id,
|
|
)
|
|
|
|
notice_until = cancelled_at + timedelta(days=7)
|
|
self.assertEqual(
|
|
scheduling_service.response_datetime(
|
|
cancelled.cancellation_notice_until
|
|
),
|
|
notice_until,
|
|
)
|
|
self.assertEqual(
|
|
scheduling_service.response_datetime(invitation.expires_at),
|
|
notice_until,
|
|
)
|
|
|
|
with patch.object(
|
|
scheduling_service,
|
|
"_now",
|
|
return_value=cancelled_at + timedelta(days=1),
|
|
):
|
|
notice = get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationAccessRequest(),
|
|
client_address="192.0.2.30",
|
|
)
|
|
self.assertTrue(notice["cancellation_notice_only"])
|
|
self.assertEqual(notice["status"], "cancelled")
|
|
for private_field in (
|
|
"description",
|
|
"location",
|
|
"deadline_at",
|
|
"comment",
|
|
"slots",
|
|
"answers",
|
|
):
|
|
self.assertNotIn(private_field, notice)
|
|
|
|
with patch.object(
|
|
scheduling_service,
|
|
"_now",
|
|
return_value=notice_until + timedelta(seconds=1),
|
|
):
|
|
with self.assertRaises(SchedulingPublicParticipationError):
|
|
get_public_scheduling_participation(
|
|
self.session,
|
|
request_id=request.id,
|
|
token=token,
|
|
payload=SchedulingPublicParticipationAccessRequest(),
|
|
client_address="192.0.2.30",
|
|
)
|
|
with self.assertRaisesRegex(
|
|
SchedulingError,
|
|
"cancellation notice has expired",
|
|
):
|
|
issue_scheduling_participant_invitation(
|
|
self.session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
participant_id=participant.id,
|
|
action="copy",
|
|
)
|
|
|
|
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
|
|
request = self._request()
|
|
alice = self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
)
|
|
api_submit_scheduling_availability(
|
|
request.id,
|
|
SchedulingAvailabilityResponseRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=scheduling_slot_revision(request.slots[0]),
|
|
)
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=alice,
|
|
)
|
|
|
|
api_update_scheduling_candidate_slot(
|
|
request.id,
|
|
request.slots[0].id,
|
|
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
|
session=self.session,
|
|
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
current = api_get_my_scheduling_availability(
|
|
request.id,
|
|
session=self.session,
|
|
principal=alice,
|
|
)
|
|
|
|
self.assertFalse(current.has_response)
|
|
self.assertEqual(current.answers, [])
|
|
self.assertEqual(request.participants[0].status, "invited")
|
|
self.assertIsNone(request.participants[0].responded_at)
|
|
self.assertEqual(
|
|
scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)["response_count"],
|
|
0,
|
|
)
|
|
|
|
def test_option_change_is_blocked_when_response_updates_are_disabled(self) -> None:
|
|
request = self._request(allow_participant_updates=False)
|
|
self._submit_both(request)
|
|
original_label = request.slots[0].label
|
|
|
|
with self.assertRaisesRegex(SchedulingError, "response updates are disabled"):
|
|
update_scheduling_candidate_slot(
|
|
self.session,
|
|
tenant_id="tenant-1",
|
|
request_id=request.id,
|
|
slot_id=request.slots[0].id,
|
|
payload=SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
|
)
|
|
|
|
self.assertEqual(request.slots[0].label, original_label)
|
|
response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
|
|
self.assertEqual(len(response.answers), 2)
|
|
|
|
def test_stale_option_revision_is_rejected_without_writing_a_response(self) -> None:
|
|
request = self._request()
|
|
stale_revision = scheduling_slot_revision(request.slots[0])
|
|
api_update_scheduling_candidate_slot(
|
|
request.id,
|
|
request.slots[0].id,
|
|
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
|
|
session=self.session,
|
|
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
|
|
)
|
|
|
|
with self.assertRaises(HTTPException) as stale:
|
|
api_submit_scheduling_availability(
|
|
request.id,
|
|
SchedulingAvailabilityResponseRequest(
|
|
answers=[
|
|
SchedulingAvailabilityAnswerInput(
|
|
slot_id=request.slots[0].id,
|
|
value="available",
|
|
option_revision=stale_revision,
|
|
)
|
|
]
|
|
),
|
|
session=self.session,
|
|
principal=self._principal(
|
|
"alice-account",
|
|
email="alice@example.test",
|
|
scopes={RESPOND_SCOPE},
|
|
),
|
|
)
|
|
|
|
self.assertEqual(stale.exception.status_code, 409)
|
|
self.assertEqual(self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).count(), 0)
|
|
|
|
def test_slot_inputs_require_aware_datetimes_and_known_timezones(self) -> None:
|
|
with self.assertRaises(ValidationError):
|
|
SchedulingCandidateSlotInput(
|
|
start_at=datetime(2026, 7, 20, 9),
|
|
end_at=datetime(2026, 7, 20, 10),
|
|
)
|
|
with self.assertRaisesRegex(ValidationError, "valid IANA timezone"):
|
|
SchedulingCandidateSlotInput(
|
|
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
|
|
end_at=datetime(2026, 7, 20, 10, tzinfo=timezone.utc),
|
|
timezone="Mars/Olympus_Mons",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|