fix(poll): harden lifecycle policy boundaries
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
@@ -29,6 +31,8 @@ from govoplan_poll.backend.service import (
|
||||
PollError,
|
||||
create_poll,
|
||||
list_poll_lifecycle_transitions,
|
||||
poll_response,
|
||||
poll_results_are_visible,
|
||||
response_datetime,
|
||||
submit_poll_response,
|
||||
transition_poll,
|
||||
@@ -131,7 +135,7 @@ class PollLifecycleTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "not allowed"):
|
||||
engine.plan(current_status="draft", action="close")
|
||||
|
||||
def test_policy_is_injectable_without_changing_the_engine(self) -> None:
|
||||
def test_injected_policy_governs_service_transition_and_projection(self) -> None:
|
||||
rules = dict(DEFAULT_POLL_TRANSITION_POLICY.rules)
|
||||
rules["close"] = PollTransitionRule(
|
||||
action="close",
|
||||
@@ -139,10 +143,38 @@ class PollLifecycleTests(unittest.TestCase):
|
||||
target_status="closed",
|
||||
)
|
||||
custom_engine = PollTransitionEngine(PollTransitionPolicy(rules))
|
||||
poll = self._poll()
|
||||
|
||||
plan = custom_engine.plan(current_status="draft", action="close")
|
||||
projected = poll_response(poll, transition_engine=custom_engine)
|
||||
close_action = next(item for item in projected["lifecycle_actions"] if item["action"] == "close")
|
||||
result = transition_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
poll_id=poll.id,
|
||||
action="close",
|
||||
transition_engine=custom_engine,
|
||||
)
|
||||
|
||||
self.assertEqual(plan.to_status, "closed")
|
||||
self.assertTrue(close_action["available"])
|
||||
self.assertEqual(result.poll.status, "closed")
|
||||
self.assertEqual(result.transition.from_status, "draft")
|
||||
|
||||
def test_only_draft_or_open_are_valid_initial_states(self) -> None:
|
||||
for initial_status in ("closed", "decided", "archived"):
|
||||
with self.subTest(initial_status=initial_status):
|
||||
with self.assertRaises(ValidationError):
|
||||
PollCreateRequest(title="Invalid initial state", kind="yes_no", status=initial_status)
|
||||
|
||||
bypassed_schema = PollCreateRequest(title="Invalid initial state", kind="yes_no").model_copy(
|
||||
update={"status": "closed"}
|
||||
)
|
||||
with self.assertRaisesRegex(PollError, "initial status must be draft or open"):
|
||||
create_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="owner",
|
||||
payload=bypassed_schema,
|
||||
)
|
||||
|
||||
def test_reopening_preserves_responses_close_history_and_previous_decision(self) -> None:
|
||||
poll = self._poll()
|
||||
@@ -180,7 +212,9 @@ class PollLifecycleTests(unittest.TestCase):
|
||||
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")
|
||||
poll = self._poll()
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||
first = transition_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
@@ -213,7 +247,12 @@ class PollLifecycleTests(unittest.TestCase):
|
||||
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(),
|
||||
self.session.query(PollLifecycleTransition)
|
||||
.filter(
|
||||
PollLifecycleTransition.poll_id == poll.id,
|
||||
PollLifecycleTransition.action == "decide",
|
||||
)
|
||||
.count(),
|
||||
2,
|
||||
)
|
||||
|
||||
@@ -235,8 +274,9 @@ class PollLifecycleTests(unittest.TestCase):
|
||||
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
|
||||
poll = self._poll()
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||
response = PollResponse(
|
||||
tenant_id="tenant-1",
|
||||
poll_id=poll.id,
|
||||
@@ -267,13 +307,119 @@ class PollLifecycleTests(unittest.TestCase):
|
||||
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"])
|
||||
self.assertEqual(
|
||||
[item.action for item in poll.lifecycle_transitions],
|
||||
["open", "close", "archive", "unarchive"],
|
||||
)
|
||||
|
||||
def test_legacy_archive_without_restore_status_is_not_silently_unarchived(self) -> None:
|
||||
poll = self._poll(status="archived")
|
||||
def test_legacy_archive_without_origin_restores_to_draft_with_audit_marker(self) -> None:
|
||||
poll = Poll(
|
||||
tenant_id="tenant-1",
|
||||
slug="legacy-archive",
|
||||
title="Legacy archive",
|
||||
kind="yes_no",
|
||||
status="archived",
|
||||
visibility="tenant",
|
||||
result_visibility="after_close",
|
||||
workflow_steps=[],
|
||||
allow_anonymous=False,
|
||||
allow_response_update=True,
|
||||
min_choices=1,
|
||||
)
|
||||
self.session.add(poll)
|
||||
self.session.flush()
|
||||
|
||||
with self.assertRaisesRegex(PollError, "no valid status to restore"):
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="unarchive")
|
||||
restored = transition_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
poll_id=poll.id,
|
||||
action="unarchive",
|
||||
)
|
||||
|
||||
self.assertEqual(restored.poll.status, "draft")
|
||||
self.assertEqual(restored.transition.to_status, "draft")
|
||||
self.assertEqual(
|
||||
restored.transition.metadata_["legacy_restore_fallback"],
|
||||
{
|
||||
"reason": "missing_archived_from_status",
|
||||
"restored_status": "draft",
|
||||
},
|
||||
)
|
||||
|
||||
def test_reopening_clears_only_an_expired_deadline_and_preserves_close_history(self) -> None:
|
||||
poll = self._poll()
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||
closed_at = poll.closed_at
|
||||
expired_deadline = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
poll.closes_at = expired_deadline
|
||||
|
||||
reopened = transition_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
poll_id=poll.id,
|
||||
action="open",
|
||||
)
|
||||
|
||||
self.assertEqual(reopened.poll.status, "open")
|
||||
self.assertIsNone(reopened.poll.closes_at)
|
||||
self.assertEqual(response_datetime(reopened.poll.closed_at), response_datetime(closed_at))
|
||||
self.assertEqual(
|
||||
reopened.transition.metadata_["cleared_expired_closes_at"],
|
||||
expired_deadline.isoformat(),
|
||||
)
|
||||
submit_poll_response(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
poll_id=poll.id,
|
||||
payload=PollSubmitResponseRequest(
|
||||
respondent_id="participant-after-reopen",
|
||||
answers=[PollAnswerInput(option_key="yes")],
|
||||
),
|
||||
)
|
||||
|
||||
future_deadline = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
future_poll = self._poll(title="Future deadline")
|
||||
future_poll.closes_at = future_deadline
|
||||
opened = transition_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
poll_id=future_poll.id,
|
||||
action="open",
|
||||
)
|
||||
self.assertEqual(response_datetime(opened.poll.closes_at), future_deadline)
|
||||
self.assertNotIn("cleared_expired_closes_at", opened.transition.metadata_)
|
||||
|
||||
def test_archiving_draft_or_open_poll_does_not_grant_after_close_visibility(self) -> None:
|
||||
for initial_status in ("draft", "open"):
|
||||
with self.subTest(initial_status=initial_status):
|
||||
poll = self._poll(status=initial_status, title=f"Archive from {initial_status}")
|
||||
transition_poll(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
poll_id=poll.id,
|
||||
action="archive",
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
poll_results_are_visible(
|
||||
self.session,
|
||||
poll=poll,
|
||||
actor_ids=("participant",),
|
||||
)
|
||||
)
|
||||
|
||||
closed_poll = self._poll(title="Archive from closed")
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="open")
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="close")
|
||||
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="archive")
|
||||
self.assertTrue(
|
||||
poll_results_are_visible(
|
||||
self.session,
|
||||
poll=closed_poll,
|
||||
actor_ids=("participant",),
|
||||
)
|
||||
)
|
||||
|
||||
def test_generic_api_returns_action_availability_history_and_actor(self) -> None:
|
||||
poll = self._poll()
|
||||
|
||||
Reference in New Issue
Block a user