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