Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""Mock accepted exceptions using real frozen jobs/EML, never new live effects."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from test_incremental_review_persistence import review, _save
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||
from govoplan_campaign.backend.db.models import CampaignJob
|
||||
from govoplan_campaign.backend.dev import mock_campaign
|
||||
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frozen(review, tmp_path):
|
||||
review.version.raw_json = {**review.version.raw_json, "server": {"mail_profile_id": "profile-1"}}
|
||||
jobs = review.session.query(CampaignJob).order_by(CampaignJob.entry_index).all()
|
||||
for job in jobs:
|
||||
job.subject = f"Frozen subject {job.entry_index}"
|
||||
job.resolved_recipients = {"from": {"email": "sender@example.test"}, "to": [{"email": f"recipient-{job.entry_index}@example.test"}]}
|
||||
job.issues_snapshot = [{**item, "severity": "warning", "message": "Explicit attachment exception"} for item in job.issues_snapshot]
|
||||
message = EmailMessage()
|
||||
message["From"] = "sender@example.test"
|
||||
message["To"] = f"recipient-{job.entry_index}@example.test"
|
||||
message["Subject"] = job.subject
|
||||
message["Date"] = "Mon, 07 Sep 2026 09:00:00 +0200"
|
||||
message["Message-ID"] = f"<frozen-{job.entry_index}@example.test>"
|
||||
message.set_content(f"Frozen reviewed body {job.entry_index}")
|
||||
payload = message.as_bytes(policy=policy.SMTP)
|
||||
path = tmp_path / f"message-{job.entry_index}.eml"
|
||||
path.write_bytes(payload)
|
||||
job.eml_local_path = str(path)
|
||||
job.eml_size_bytes = len(payload)
|
||||
job.eml_sha256 = hashlib.sha256(payload).hexdigest()
|
||||
job.message_id_header = str(message["Message-ID"])
|
||||
review.session.flush()
|
||||
payload, digest = create_execution_snapshot(review.version, mail_profile_id="profile-1",
|
||||
smtp_transport_revision="smtp-1", imap_transport_revision="imap-1", delivery=DeliveryConfig(),
|
||||
jobs=jobs, build_summary=review.version.build_summary)
|
||||
review.version.execution_snapshot = payload
|
||||
review.version.execution_snapshot_hash = digest
|
||||
review.session.commit()
|
||||
_save(review, ids=(1, 2), complete=True)
|
||||
mailbox = Mock()
|
||||
mailbox.consume_fail_next_smtp.return_value = False
|
||||
mailbox.consume_fail_next_imap.return_value = False
|
||||
mailbox.get_failures.return_value = {}
|
||||
mailbox.record_smtp_delivery.return_value = SimpleNamespace(id="mock-1")
|
||||
mailbox.record_imap_append.return_value = SimpleNamespace(id="mock-imap-1")
|
||||
mailbox.list_records.return_value = []
|
||||
with (
|
||||
patch.object(mock_campaign, "_mock_mailbox", return_value=mailbox),
|
||||
patch("govoplan_campaign.backend.sending.execution.files_integration", return_value=SimpleNamespace(available=False)),
|
||||
patch("govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed", return_value=SimpleNamespace(policy_hash="archive-policy")),
|
||||
patch("govoplan_campaign.backend.sending.execution.profile_delivery_summary", return_value={"smtp_transport_revision": "smtp-1", "imap_transport_revision": "imap-1"}) as transport,
|
||||
):
|
||||
yield SimpleNamespace(**vars(review), jobs=jobs, mailbox=mailbox, transport=transport)
|
||||
|
||||
|
||||
def _run(frozen, **options):
|
||||
return mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", version_id="version-1",
|
||||
use_reviewed_build=True, include_needs_review=False, send=True, **options)
|
||||
|
||||
|
||||
def test_mock_reuses_reviewed_frozen_bytes_and_decisions_without_reasking_or_mutating_campaign(frozen):
|
||||
original = copy.deepcopy(frozen.version.editor_state)
|
||||
revision = frozen.version.edit_revision
|
||||
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=AssertionError("Do not rebuild reviewed messages")):
|
||||
result = _run(frozen)
|
||||
assert result["send"]["sent_count"] == 2 and result["send"]["skipped_count"] == 0
|
||||
assert result["use_reviewed_build"] is True and result["build"]["review_satisfied"] is True
|
||||
assert [step["status"] for step in result["steps"]] == ["ok", "ok", "ok", "ok"]
|
||||
assert frozen.mailbox.record_smtp_delivery.call_count == 2
|
||||
captured = frozen.mailbox.record_smtp_delivery.call_args_list[0].args[0]
|
||||
assert str(captured["Message-ID"]) == "<frozen-1@example.test>"
|
||||
assert "Frozen reviewed body 1" in captured.get_content()
|
||||
frozen.session.expire_all()
|
||||
assert frozen.version.editor_state == original and frozen.version.edit_revision == revision
|
||||
assert all(job.send_status == "not_queued" for job in frozen.jobs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["partial", "build", "issue", "reason_evidence", "configuration", "snapshot", "bytes", "transport", "imap"])
|
||||
def test_invalid_frozen_review_stops_before_mock_capture_or_mailbox_clear(frozen, mutation):
|
||||
if mutation == "partial":
|
||||
state = copy.deepcopy(frozen.version.editor_state)
|
||||
state["review_send"]["inspection_complete"] = False
|
||||
frozen.version.editor_state = state
|
||||
elif mutation == "build":
|
||||
frozen.version.build_summary = {"build_token": "different-build", "built_count": 2}
|
||||
elif mutation == "issue":
|
||||
frozen.jobs[1].issues_snapshot = [{"code": "new-hard-block", "source": "attachments", "behavior": "block", "severity": "error", "message": "Missing required attachment"}]
|
||||
elif mutation == "reason_evidence":
|
||||
state = copy.deepcopy(frozen.version.editor_state)
|
||||
state["review_send"]["issue_decisions"][0]["issue_fingerprint"] = "f" * 64
|
||||
frozen.version.editor_state = state
|
||||
elif mutation == "configuration":
|
||||
frozen.version.raw_json = {**frozen.version.raw_json, "template": {"subject": "Unreviewed changed subject"}}
|
||||
elif mutation == "snapshot":
|
||||
frozen.version.execution_snapshot = None
|
||||
elif mutation == "bytes":
|
||||
from pathlib import Path
|
||||
Path(frozen.jobs[1].eml_local_path).write_bytes(b"changed bytes")
|
||||
else:
|
||||
frozen.transport.return_value = {"smtp_transport_revision": "changed" if mutation == "transport" else "smtp-1", "imap_transport_revision": "changed" if mutation == "imap" else "imap-1"}
|
||||
frozen.session.flush()
|
||||
with pytest.raises((mock_campaign.MockCampaignSendError, ExecutionSnapshotError, CampaignPersistenceError)):
|
||||
_run(frozen, clear_mailbox=True)
|
||||
frozen.mailbox.record_smtp_delivery.assert_not_called()
|
||||
frozen.mailbox.record_imap_append.assert_not_called()
|
||||
frozen.mailbox.clear_records.assert_not_called()
|
||||
|
||||
|
||||
def test_policy_dropped_frozen_message_stays_skipped_even_with_ask_evidence(frozen):
|
||||
excluded = frozen.jobs[1]
|
||||
excluded.validation_status = "excluded"
|
||||
excluded.send_status = "skipped"
|
||||
excluded.imap_status = "skipped"
|
||||
payload, digest = create_execution_snapshot(frozen.version, mail_profile_id="profile-1", smtp_transport_revision="smtp-1",
|
||||
imap_transport_revision="imap-1", delivery=DeliveryConfig(), jobs=frozen.jobs, build_summary=frozen.version.build_summary)
|
||||
frozen.version.execution_snapshot = payload
|
||||
frozen.version.execution_snapshot_hash = digest
|
||||
state = copy.deepcopy(frozen.version.editor_state)
|
||||
state["review_send"]["reviewed_message_keys"] = ["entry-1"]
|
||||
state["review_send"]["issue_decisions"] = state["review_send"]["issue_decisions"][:1]
|
||||
frozen.version.editor_state = state
|
||||
frozen.session.commit()
|
||||
result = _run(frozen)
|
||||
assert result["send"]["sent_count"] == 1 and result["send"]["skipped_count"] == 1
|
||||
assert frozen.mailbox.record_smtp_delivery.call_count == 1
|
||||
|
||||
|
||||
def test_default_authoring_mock_still_uses_transient_preview(frozen):
|
||||
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=RuntimeError("authoring preview path")):
|
||||
with pytest.raises(RuntimeError, match="authoring preview path"):
|
||||
mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", send=False)
|
||||
Reference in New Issue
Block a user