Adopt recovery ledger for Campaign builds

This commit is contained in:
2026-08-03 03:03:00 +02:00
parent c6bbdae2e1
commit dd09b06c47
9 changed files with 592 additions and 103 deletions
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
from govoplan_core.core.recovery import RecoveryMode
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_StoredEmlArtifact,
_build_storage_expectations,
_delete_storage_keys,
_verify_build_storage_manifest,
_verify_storage_keys_absent,
)
from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
assert _campaign_build_recovery_plan({}).mode == RecoveryMode.COMPENSATION
assert (
_campaign_build_recovery_plan(
{"delivery": {"print": {"persist_to_files": True}}}
).mode
== RecoveryMode.FORWARD_RECOVERY
)
assert (
_campaign_build_recovery_plan(
{"delivery": {"print": {"persist_to_files": False}}}
).mode
== RecoveryMode.COMPENSATION
)
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path)
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
key = "campaign-artifacts/tenant/campaign/version/build/00000001.eml"
storage.put_bytes(key, payload)
artifact = _StoredEmlArtifact(
storage_key=key,
size_bytes=len(payload),
sha256=hashlib.sha256(payload).hexdigest(),
message_id_header="<build@example.test>",
)
evidence = _verify_build_storage_manifest(
storage,
_build_storage_expectations(
stored_eml_by_index={1: artifact},
print_outputs_by_index={},
),
)
assert evidence["object_count"] == 1
assert evidence["total_bytes"] == len(payload)
storage.put_bytes(key, b"tampered")
with pytest.raises(CampaignPersistenceError, match="does not match"):
_verify_build_storage_manifest(
storage,
_build_storage_expectations(
stored_eml_by_index={1: artifact},
print_outputs_by_index={},
),
)
def test_compensation_requires_verified_object_absence(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path)
key = "campaign-artifacts/tenant/campaign/version/build/object"
storage.put_bytes(key, b"payload")
assert _delete_storage_keys(storage, [key]) == []
assert _verify_storage_keys_absent(storage, [key]) == (True, 0)
class _UnremovableStorage:
name = "unremovable"
def delete(self, _key: str) -> None:
from govoplan_core.core.object_storage import StorageBackendError
raise StorageBackendError("unavailable")
def exists(self, _key: str) -> bool:
return True
def test_failed_compensation_remains_observable() -> None:
storage = _UnremovableStorage()
key = "campaign-artifacts/tenant/campaign/version/build/object"
assert _delete_storage_keys(storage, [key]) == [key] # type: ignore[arg-type]
assert _verify_storage_keys_absent(storage, [key]) == (False, 1) # type: ignore[arg-type]
+1
View File
@@ -289,6 +289,7 @@ def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryabl
imap_claimed_at=datetime.now(timezone.utc),
imap_claim_token="claim-1",
last_error="unknown",
delivery_provenance={},
)
attempt = SimpleNamespace(
id="attempt-1",
+1 -1
View File
@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 70
assert len(actual) == 71
assert not [operation for operation, count in Counter(actual).items() if count > 1]