322 lines
11 KiB
Python
322 lines
11 KiB
Python
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()
|