Files
govoplan-poll/tests/test_mutation_plans.py

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()