from __future__ import annotations import hashlib from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from govoplan_core.core.object_storage import ( LocalFilesystemStorageBackend, StorageBackendError, ) from govoplan_campaign.backend.persistence.campaigns import ( CampaignPersistenceError, _persist_built_eml_artifacts, ) from govoplan_campaign.backend.retention import _apply_eml_retention from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job class _FailingStorage: name = "test" def __init__(self) -> None: self.objects: dict[str, bytes] = {} self.deleted: list[str] = [] def put_bytes(self, key: str, data: bytes, **_kwargs) -> None: if self.objects: raise StorageBackendError("object service unavailable") self.objects[key] = data def delete(self, key: str) -> None: self.deleted.append(key) self.objects.pop(key, None) def test_partial_campaign_artifact_write_is_compensated(tmp_path: Path) -> None: first = tmp_path / "first.eml" second = tmp_path / "second.eml" first.write_bytes(b"Message-ID: \r\n\r\nfirst") second.write_bytes(b"Message-ID: \r\n\r\nsecond") messages = [ SimpleNamespace( draft=SimpleNamespace( entry_index=1, eml_path=str(first), eml_size_bytes=None, ) ), SimpleNamespace( draft=SimpleNamespace( entry_index=2, eml_path=str(second), eml_size_bytes=None, ) ), ] storage = _FailingStorage() with pytest.raises(CampaignPersistenceError, match="could not be persisted"): _persist_built_eml_artifacts( storage=storage, # type: ignore[arg-type] tenant_id="tenant-1", campaign_id="campaign-1", version_id="version-1", build_id="build-1", built_messages=messages, ) assert storage.objects == {} assert len(storage.deleted) == 1 def test_worker_loads_and_verifies_campaign_eml_from_object_storage( tmp_path: Path, ) -> None: payload = b"Message-ID: \r\n\r\nbody" storage = LocalFilesystemStorageBackend(tmp_path) storage.put_bytes("campaign-artifacts/message.eml", payload) job = SimpleNamespace( id="job-1", eml_storage_key="campaign-artifacts/message.eml", eml_local_path=None, eml_size_bytes=len(payload), eml_sha256=hashlib.sha256(payload).hexdigest(), message_id_header="", ) with patch( "govoplan_campaign.backend.sending.jobs.configured_storage_backend", return_value=storage, ): assert _load_eml_bytes_for_job(job) == payload def test_retention_preserves_reference_when_object_store_is_unavailable() -> None: job = SimpleNamespace( campaign_id="campaign-1", updated_at=datetime.now(timezone.utc) - timedelta(days=10), queue_status="draft", send_status="smtp_accepted", imap_status="appended", eml_local_path=None, eml_storage_key="campaign-artifacts/message.eml", ) session = MagicMock() session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [ job ] storage = MagicMock() storage.exists.side_effect = StorageBackendError("object service unavailable") result = _apply_eml_retention( session, dry_run=False, now=datetime.now(timezone.utc), policy_for_campaign_id=lambda _campaign_id: SimpleNamespace( generated_eml_retention_days=1 ), storage=storage, ) assert result["delete_failed"] == 1 assert result["metadata_cleared"] == 0 assert job.eml_storage_key == "campaign-artifacts/message.eml" session.add.assert_not_called()