from __future__ import annotations import unittest from types import SimpleNamespace from fastapi import HTTPException 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.db.base import Base from govoplan_poll.backend.db.models import ( Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse, ) from govoplan_poll.backend.router import api_poll_lifecycle, api_transition_poll from govoplan_poll.backend.schemas import ( PollAnswerInput, PollCreateRequest, PollOptionInput, PollSubmitResponseRequest, PollTransitionRequest, ) from govoplan_poll.backend.service import ( PollError, create_poll, list_poll_lifecycle_transitions, response_datetime, submit_poll_response, transition_poll, ) from govoplan_poll.backend.transitions import ( DEFAULT_POLL_TRANSITION_POLICY, PollTransitionEngine, PollTransitionPolicy, PollTransitionRule, ) WRITE_SCOPE = "poll:poll:write" class PollLifecycleTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all( self.engine, tables=[ Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__, PollLifecycleTransition.__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=[ PollLifecycleTransition.__table__, PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__, ], ) self.engine.dispose() def _poll(self, *, status: str = "draft", title: str = "Decision") -> Poll: return create_poll( self.session, tenant_id="tenant-1", user_id="owner", payload=PollCreateRequest( title=title, kind="single_choice", status=status, visibility="tenant", options=[ PollOptionInput(key="yes", label="Yes"), PollOptionInput(key="no", label="No"), ], ), ) @staticmethod def _principal() -> ApiPrincipal: return ApiPrincipal( principal=PrincipalRef( account_id="owner", membership_id="owner-membership", tenant_id="tenant-1", scopes=frozenset({WRITE_SCOPE}), display_name="Owner", ), account=SimpleNamespace(id="owner"), user=SimpleNamespace(id="owner-membership"), ) def test_default_policy_exposes_every_allowed_and_rejected_transition(self) -> None: expected = { "draft": {"open", "archive"}, "open": {"draft", "close", "archive"}, "closed": {"open", "decide", "archive"}, "decided": {"open", "decide", "archive"}, "archived": {"unarchive"}, } engine = PollTransitionEngine() for status, allowed in expected.items(): archived_from_status = "closed" if status == "archived" else None availability = engine.available_actions( current_status=status, archived_from_status=archived_from_status, ) self.assertEqual({item.action for item in availability if item.available}, allowed) for item in availability: if item.available: self.assertIsNone(item.reason) else: self.assertTrue(item.reason) with self.assertRaisesRegex(ValueError, "not allowed"): engine.plan(current_status="draft", action="close") def test_policy_is_injectable_without_changing_the_engine(self) -> None: rules = dict(DEFAULT_POLL_TRANSITION_POLICY.rules) rules["close"] = PollTransitionRule( action="close", source_statuses=frozenset({"draft", "open"}), target_status="closed", ) custom_engine = PollTransitionEngine(PollTransitionPolicy(rules)) plan = custom_engine.plan(current_status="draft", action="close") self.assertEqual(plan.to_status, "closed") def test_reopening_preserves_responses_close_history_and_previous_decision(self) -> None: poll = self._poll() transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open") response = submit_poll_response( self.session, tenant_id="tenant-1", poll_id=poll.id, payload=PollSubmitResponseRequest( respondent_id="participant", answers=[PollAnswerInput(option_key="yes")], ), ) transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close") first_closed_at = poll.closed_at transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="decide", option_key="yes", ) decided_at = poll.decided_at decided_option_id = poll.decided_option_id transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open") self.assertEqual(poll.status, "open") self.assertEqual(response_datetime(poll.closed_at), response_datetime(first_closed_at)) self.assertEqual(response_datetime(poll.decided_at), response_datetime(decided_at)) self.assertEqual(poll.decided_option_id, decided_option_id) self.assertEqual(response.deleted_at, None) self.assertEqual(response.answers[0]["option_key"], "yes") history = list_poll_lifecycle_transitions(self.session, tenant_id="tenant-1", poll_id=poll.id) self.assertEqual([item.action for item in history], ["open", "close", "decide", "open"]) def test_direct_redecision_is_audited_and_exact_retry_is_idempotent(self) -> None: poll = self._poll(status="closed") first = transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="decide", option_key="yes", idempotency_key="decision-1", ) replay = transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="decide", option_key="yes", idempotency_key="decision-1", ) second = transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="decide", option_key="no", idempotency_key="decision-2", ) self.assertFalse(first.replayed) self.assertTrue(replay.replayed) self.assertEqual(replay.transition.id, first.transition.id) self.assertFalse(second.replayed) self.assertEqual(second.transition.previous_decision_option_id, first.transition.decision_option_id) self.assertEqual(poll.decided_option_id, second.transition.decision_option_id) self.assertEqual( self.session.query(PollLifecycleTransition).filter(PollLifecycleTransition.poll_id == poll.id).count(), 2, ) with self.assertRaisesRegex(PollError, "different poll transition"): transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="decide", option_key="yes", idempotency_key="decision-2", ) def test_repeated_exact_transition_without_an_identity_is_rejected(self) -> None: poll = self._poll() transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open") with self.assertRaisesRegex(PollError, "not allowed"): transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open") def test_archive_and_unarchive_restore_status_and_append_audit_history(self) -> None: poll = self._poll(status="closed") poll.closed_at = poll.created_at response = PollResponse( tenant_id="tenant-1", poll_id=poll.id, respondent_id="participant", answers=[{"option_key": "yes"}], submitted_at=poll.created_at, ) self.session.add(response) self.session.flush() archived = transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="archive", idempotency_key="archive-1", ) restored = transition_poll( self.session, tenant_id="tenant-1", poll_id=poll.id, action="unarchive", idempotency_key="unarchive-1", ) self.assertEqual(archived.transition.to_status, "archived") self.assertEqual(restored.transition.to_status, "closed") self.assertEqual(poll.status, "closed") self.assertIsNone(poll.archived_from_status) self.assertIsNone(response.deleted_at) self.assertEqual([item.action for item in poll.lifecycle_transitions], ["archive", "unarchive"]) def test_legacy_archive_without_restore_status_is_not_silently_unarchived(self) -> None: poll = self._poll(status="archived") with self.assertRaisesRegex(PollError, "no valid status to restore"): transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="unarchive") def test_generic_api_returns_action_availability_history_and_actor(self) -> None: poll = self._poll() principal = self._principal() opened = api_transition_poll( poll.id, PollTransitionRequest(action="open"), idempotency_key="open-api-1", session=self.session, principal=principal, ) replay = api_transition_poll( poll.id, PollTransitionRequest(action="open"), idempotency_key="open-api-1", session=self.session, principal=principal, ) lifecycle = api_poll_lifecycle(poll.id, session=self.session, principal=principal) self.assertEqual(opened.poll.status, "open") self.assertFalse(opened.replayed) self.assertTrue(replay.replayed) self.assertEqual(len(lifecycle.history), 1) self.assertEqual(lifecycle.history[0].actor_user_id, "owner-membership") self.assertEqual( {item.action for item in lifecycle.actions if item.available}, {"draft", "close", "archive"}, ) draft = self._poll(title="Draft cannot close") with self.assertRaises(HTTPException) as rejected: api_transition_poll( draft.id, PollTransitionRequest(action="close"), idempotency_key=None, session=self.session, principal=principal, ) self.assertEqual(rejected.exception.status_code, 400) if __name__ == "__main__": unittest.main()