164 lines
5.4 KiB
Python
164 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
|
|
from govoplan_poll.backend.mutation_plans import (
|
|
MAX_RETIREMENT_RESPONDENT_IDS,
|
|
PollMutationPlanError,
|
|
decide_existing_response_impact,
|
|
normalize_retirement_selector,
|
|
plan_poll_update,
|
|
plan_response_retirement,
|
|
)
|
|
|
|
|
|
def poll(**overrides: object) -> SimpleNamespace:
|
|
values = {
|
|
"status": "open",
|
|
"kind": "availability",
|
|
"title": "Availability",
|
|
"description": None,
|
|
"visibility": "private",
|
|
"result_visibility": "organizer",
|
|
"context_module": "scheduling",
|
|
"context_resource_type": "scheduling_request",
|
|
"context_resource_id": "request-1",
|
|
"workflow_state": "collecting",
|
|
"workflow_steps": [],
|
|
"allow_anonymous": False,
|
|
"allow_response_update": True,
|
|
"min_choices": 1,
|
|
"max_choices": 2,
|
|
"opens_at": None,
|
|
"closes_at": None,
|
|
"metadata_": {},
|
|
}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
def response(
|
|
response_id: str,
|
|
*,
|
|
deleted_at: datetime | None = None,
|
|
idempotency_key: str | None = None,
|
|
) -> SimpleNamespace:
|
|
metadata = {}
|
|
if idempotency_key is not None:
|
|
metadata["response_retirement"] = {
|
|
"idempotency_key": idempotency_key
|
|
}
|
|
return SimpleNamespace(
|
|
id=response_id,
|
|
deleted_at=deleted_at,
|
|
metadata_=metadata,
|
|
)
|
|
|
|
|
|
class PollMutationPlanTests(unittest.TestCase):
|
|
def test_poll_update_is_planned_before_mutation_and_preserves_responses(
|
|
self,
|
|
) -> None:
|
|
item = poll()
|
|
plan = plan_poll_update(
|
|
item, # type: ignore[arg-type]
|
|
{
|
|
"title": "Revised",
|
|
"context_resource_id": "request-2",
|
|
"max_choices": 1,
|
|
},
|
|
active_option_count=2,
|
|
)
|
|
|
|
self.assertEqual("Availability", item.title)
|
|
self.assertEqual("preserve", plan.response_decision.disposition)
|
|
plan.apply(item)
|
|
self.assertEqual("Revised", item.title)
|
|
self.assertEqual("request-2", item.context_resource_id)
|
|
self.assertEqual(1, item.max_choices)
|
|
|
|
def test_poll_update_rejects_invalid_window_without_mutation(self) -> None:
|
|
item = poll(opens_at=datetime.now(timezone.utc))
|
|
with self.assertRaisesRegex(
|
|
PollMutationPlanError,
|
|
"closes_at must be after opens_at",
|
|
):
|
|
plan_poll_update(
|
|
item, # type: ignore[arg-type]
|
|
{"closes_at": item.opens_at - timedelta(minutes=1)},
|
|
active_option_count=2,
|
|
)
|
|
self.assertIsNone(item.closes_at)
|
|
|
|
def test_existing_response_decision_table(self) -> None:
|
|
cases = (
|
|
("option_content", True, True, "invalidate_affected_answers"),
|
|
("option_remove", True, False, "reject"),
|
|
("option_reorder", True, False, "preserve"),
|
|
("participant_remove", True, False, "retire"),
|
|
("poll_policy_or_scope", True, False, "preserve"),
|
|
("option_remove", False, False, "preserve"),
|
|
)
|
|
for change, has_responses, allow_updates, expected in cases:
|
|
with self.subTest(change=change, has_responses=has_responses):
|
|
decision = decide_existing_response_impact(
|
|
change, # type: ignore[arg-type]
|
|
has_active_responses=has_responses,
|
|
allow_response_update=allow_updates,
|
|
)
|
|
self.assertEqual(expected, decision.disposition)
|
|
|
|
def test_retirement_selector_is_deduplicated_and_bounded(self) -> None:
|
|
selector = normalize_retirement_selector(
|
|
respondent_ids=(" person-1 ", "person-1", ""),
|
|
invitation_id=None,
|
|
reason=" participant removed ",
|
|
idempotency_key=" request:participant:removed ",
|
|
)
|
|
self.assertEqual(("person-1",), selector.respondent_ids)
|
|
self.assertEqual("participant removed", selector.reason)
|
|
|
|
with self.assertRaisesRegex(
|
|
PollMutationPlanError,
|
|
"too many participant identities",
|
|
):
|
|
normalize_retirement_selector(
|
|
respondent_ids=tuple(
|
|
f"person-{index}"
|
|
for index in range(MAX_RETIREMENT_RESPONDENT_IDS + 1)
|
|
),
|
|
invitation_id=None,
|
|
reason="participant removed",
|
|
idempotency_key="bounded",
|
|
)
|
|
|
|
def test_retirement_replay_precedes_new_active_response(self) -> None:
|
|
retired_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
already_retired = response(
|
|
"response-old",
|
|
deleted_at=retired_at,
|
|
idempotency_key="remove-1",
|
|
)
|
|
newly_submitted = response("response-new")
|
|
|
|
plan = plan_response_retirement(
|
|
(already_retired, newly_submitted),
|
|
idempotency_key="remove-1",
|
|
now=datetime.now(timezone.utc),
|
|
)
|
|
|
|
self.assertEqual("replay", plan.disposition)
|
|
self.assertEqual(("response-old",), tuple(item.id for item in plan.responses))
|
|
plan.apply(
|
|
reason="participant removed",
|
|
idempotency_key="remove-1",
|
|
metadata={},
|
|
)
|
|
self.assertIsNone(newly_submitted.deleted_at)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|