|
|
@@ -652,6 +652,14 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
|
|
|
effective_at_sequence=4,
|
|
|
)
|
|
|
await self._record_validation("child-a", candidate)
|
|
|
+ peer = await self.service.manage(
|
|
|
+ "child-a",
|
|
|
+ operation="create",
|
|
|
+ content={"text": "peer"},
|
|
|
+ parent_refs=[],
|
|
|
+ effective_at_sequence=5,
|
|
|
+ )
|
|
|
+ await self._record_validation("child-a", peer)
|
|
|
action = CandidateReviewAction(
|
|
|
action="adopt",
|
|
|
candidate_ref=candidate,
|
|
|
@@ -660,7 +668,7 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
|
|
|
first = await self.service.apply_review_actions(
|
|
|
"root-a",
|
|
|
"child-a",
|
|
|
- report_refs=[candidate],
|
|
|
+ report_refs=[candidate, peer],
|
|
|
actions=[action],
|
|
|
effective_at_sequence=10,
|
|
|
command_id="adoption-review-call",
|
|
|
@@ -668,7 +676,7 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
|
|
|
recovered = await self.service.apply_review_actions(
|
|
|
"root-a",
|
|
|
"child-a",
|
|
|
- report_refs=[candidate],
|
|
|
+ report_refs=[candidate, peer],
|
|
|
actions=[action],
|
|
|
effective_at_sequence=999,
|
|
|
command_id="adoption-review-call",
|
|
|
@@ -678,12 +686,12 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
|
|
|
self.assertEqual(1, len(self.repository.adoptions))
|
|
|
with self.assertRaisesRegex(
|
|
|
CandidateStateError,
|
|
|
- "review payload changed",
|
|
|
+ "review batch payload changed",
|
|
|
):
|
|
|
await self.service.apply_review_actions(
|
|
|
"root-a",
|
|
|
"child-a",
|
|
|
- report_refs=[candidate],
|
|
|
+ report_refs=[candidate, peer],
|
|
|
actions=[action.model_copy(update={
|
|
|
"reason": "tampered decision payload",
|
|
|
})],
|
|
|
@@ -691,6 +699,74 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
|
|
|
command_id="adoption-review-call",
|
|
|
)
|
|
|
self.assertEqual(1, self.repository.adoption_calls)
|
|
|
+ with self.assertRaisesRegex(
|
|
|
+ CandidateStateError,
|
|
|
+ "review batch payload changed",
|
|
|
+ ):
|
|
|
+ await self.service.apply_review_actions(
|
|
|
+ "root-a",
|
|
|
+ "child-a",
|
|
|
+ report_refs=[candidate, peer],
|
|
|
+ actions=[
|
|
|
+ action,
|
|
|
+ CandidateReviewAction(
|
|
|
+ action="discard",
|
|
|
+ candidate_ref=peer,
|
|
|
+ reason="injected tail action",
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ effective_at_sequence=1_001,
|
|
|
+ command_id="adoption-review-call",
|
|
|
+ )
|
|
|
+ self.assertEqual("proposed", (
|
|
|
+ CandidateLedger.model_validate(
|
|
|
+ await self.store.get_candidate_ledger("root-a")
|
|
|
+ ).current_state(peer)
|
|
|
+ ))
|
|
|
+
|
|
|
+ async def test_review_batch_replay_rejects_deleted_and_reordered_actions(self):
|
|
|
+ await self._create_child("child-a")
|
|
|
+ candidates = []
|
|
|
+ for sequence, text in ((4, "A"), (5, "B")):
|
|
|
+ candidate = await self.service.manage(
|
|
|
+ "child-a",
|
|
|
+ operation="create",
|
|
|
+ content={"text": text},
|
|
|
+ parent_refs=[],
|
|
|
+ effective_at_sequence=sequence,
|
|
|
+ )
|
|
|
+ await self._record_validation("child-a", candidate)
|
|
|
+ candidates.append(candidate)
|
|
|
+ actions = [
|
|
|
+ CandidateReviewAction(
|
|
|
+ action="discard",
|
|
|
+ candidate_ref=candidate,
|
|
|
+ reason=f"discard {candidate.candidate_id}",
|
|
|
+ )
|
|
|
+ for candidate in candidates
|
|
|
+ ]
|
|
|
+ await self.service.apply_review_actions(
|
|
|
+ "root-a",
|
|
|
+ "child-a",
|
|
|
+ report_refs=candidates,
|
|
|
+ actions=actions,
|
|
|
+ effective_at_sequence=10,
|
|
|
+ command_id="two-action-review",
|
|
|
+ )
|
|
|
+
|
|
|
+ for altered in (actions[:1], list(reversed(actions))):
|
|
|
+ with self.assertRaisesRegex(
|
|
|
+ CandidateStateError,
|
|
|
+ "review batch payload changed",
|
|
|
+ ):
|
|
|
+ await self.service.apply_review_actions(
|
|
|
+ "root-a",
|
|
|
+ "child-a",
|
|
|
+ report_refs=candidates,
|
|
|
+ actions=altered,
|
|
|
+ effective_at_sequence=999,
|
|
|
+ command_id="two-action-review",
|
|
|
+ )
|
|
|
|
|
|
async def test_descendant_adoption_blocks_ancestor_rewind_without_mapping(self):
|
|
|
await self._create_child("child-a")
|
|
|
@@ -790,7 +866,7 @@ class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
|
|
|
async def fail_finalize(root_trace_id, ledger):
|
|
|
nonlocal calls
|
|
|
calls += 1
|
|
|
- if calls == 2:
|
|
|
+ if calls == 3:
|
|
|
raise OSError("crash before framework finalize")
|
|
|
await original_replace(root_trace_id, ledger)
|
|
|
|