feat: pause campaign batches on SMTP failure

This commit is contained in:
2026-08-20 17:39:08 +02:00
parent 69e6588a89
commit c846c249b8
12 changed files with 385 additions and 33 deletions
+69
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -23,7 +24,9 @@ from govoplan_campaign.backend.sending.jobs import (
QueueCampaignResult,
SynchronousSendRejected,
_ensure_synchronous_send_count_allowed,
_pause_jobs_after_systemic_smtp_failure,
_preflight_synchronous_send_batch,
_synchronous_smtp_batch_manager,
queue_campaign_jobs,
send_campaign_now,
synchronous_send_candidate_jobs,
@@ -364,6 +367,72 @@ def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
provider.send_campaign_email_bytes.assert_not_called()
def test_smtp_batch_manager_preflights_combined_recipients_and_frozen_transport() -> None:
snapshot = SimpleNamespace(
uses_mail=True,
mail_profile_id="profile-1",
smtp_transport_revision="revision-1",
smtp_server_id="server-1",
smtp_credential_id="credential-1",
)
jobs = [
SimpleNamespace(
id="one",
tenant_id="tenant-1",
campaign_id="campaign-1",
delivery_channel_policy="mail",
resolved_recipients={"from": {"email": "sender@example.test"}},
),
SimpleNamespace(
id="two",
tenant_id="tenant-1",
campaign_id="campaign-1",
delivery_channel_policy="mail",
resolved_recipients={"from": {"email": "sender@example.test"}},
),
]
contexts = {
"one": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["one@example.test"]),
"two": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["two@example.test", "one@example.test"]),
}
state = SimpleNamespace(connection_count=1, reconnect_count=0)
@contextmanager
def batch(_session, **kwargs):
batch.kwargs = kwargs
yield state
provider = SimpleNamespace(campaign_smtp_batch=batch)
with patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider):
with _synchronous_smtp_batch_manager(object(), jobs=jobs, contexts=contexts) as opened: # type: ignore[arg-type]
assert opened is state
assert batch.kwargs["envelope_recipients"] == ["one@example.test", "two@example.test"]
assert batch.kwargs["expected_smtp_transport_revision"] == "revision-1"
def test_systemic_failure_pauses_only_remaining_queued_jobs() -> None:
session = Mock()
session.query.return_value.filter.return_value.update.return_value = 3
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1", status="sending")
session.get.return_value = campaign
with patch("govoplan_campaign.backend.sending.jobs.audit_event") as audit:
paused = _pause_jobs_after_systemic_smtp_failure(
session,
campaign_id="campaign-1",
exclude_job_id="failed-job",
reason_code="smtp_authentication_failed",
)
assert paused == 3
assert campaign.status == "ready_to_queue"
session.commit.assert_called_once_with()
assert audit.call_args.kwargs["details"] == {
"reason_code": "smtp_authentication_failed",
"paused_count": 3,
}
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
session = Mock()
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")