feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions

View File

@@ -0,0 +1,417 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from pydantic import ValidationError
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
JobBuildStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
SendAttempt,
)
from govoplan_campaign.backend.schemas import CampaignSendJobRequest
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SendJobResult,
send_single_campaign_job,
)
from govoplan_campaign.backend.integrations import SmtpSendError
class _Mail:
def __init__(self, result: object | None = None, error: Exception | None = None):
self.result = result or SimpleNamespace(
accepted_count=1,
refused_recipients={},
)
self.error = error
self.send = Mock()
def wait_for_rate_limit(self, **_kwargs) -> None:
return None
def send_campaign_email_bytes(self, *_args, **_kwargs):
self.send(*_args, **_kwargs)
if self.error is not None:
raise self.error
return self.result
class CampaignSingleMessageActionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
access_groups = Base.metadata.tables.get("access_groups")
if access_groups is None:
access_groups = Table(
"access_groups",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
self.engine,
tables=[
access_users,
access_groups,
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
external_id="campaign-1",
name="Campaign",
current_version_id="version-1",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={},
validation_summary={"ok": True},
build_summary={"build_token": "build-1"},
)
job = CampaignJob(
id="job-1",
tenant_id="tenant-1",
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=1,
recipient_email="recipient@example.test",
eml_local_path="/tmp/message.eml",
eml_sha256="a" * 64,
eml_size_bytes=42,
build_status=JobBuildStatus.BUILT.value,
validation_status=JobValidationStatus.READY.value,
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
delivery_channel_policy="mail",
resolved_recipients={
"from": {"email": "sender@example.test"},
"to": [{"email": "recipient@example.test"}],
},
)
session.add_all((campaign, version, job))
session.commit()
self.addCleanup(self.engine.dispose)
@staticmethod
def _delivery_context():
return SimpleNamespace(
snapshot=SimpleNamespace(
mail_profile_id="profile-1",
smtp_server_id=None,
smtp_credential_id=None,
smtp_transport_revision="revision-1",
delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60),
imap_append_sent=SimpleNamespace(enabled=False),
),
),
message_bytes=b"Subject: test\r\n\r\ncontent",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
)
def _run(
self,
session: Session,
*,
kind: str,
key: str,
mail: _Mail,
reason: str | None = None,
):
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail,
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
return send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind=kind,
idempotency_key=key,
actor_user_id=None,
reason=reason,
)
def test_test_action_is_idempotent_and_leaves_official_state_unchanged(self) -> None:
mail = _Mail()
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
job.attempt_count = 2
session.commit()
first = self._run(
session,
kind="test",
key="test-1",
mail=mail,
)
repeated = self._run(
session,
kind="test",
key="test-1",
mail=mail,
)
session.refresh(job)
self.assertEqual(first["status"], "accepted")
self.assertTrue(repeated["duplicate"])
self.assertEqual(job.send_status, JobSendStatus.FAILED_TEMPORARY.value)
self.assertEqual(job.attempt_count, 2)
self.assertEqual(mail.send.call_count, 1)
with self.assertRaisesRegex(QueueingError, "idempotency key"):
self._run(
session,
kind="single_resend",
key="test-1",
reason="Different command",
mail=mail,
)
def test_failed_resend_preserves_prior_success(self) -> None:
mail = _Mail(
error=SmtpSendError(
"Temporary delivery failure",
temporary=True,
)
)
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = job.created_at
session.commit()
result = self._run(
session,
kind="single_resend",
key="resend-failed",
reason="Recipient requested another copy",
mail=mail,
)
session.refresh(job)
self.assertEqual(result["status"], "failed_temporary")
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertIsNotNone(job.sent_at)
def test_successful_resend_completes_previously_failed_message(self) -> None:
mail = _Mail()
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
job.send_status = JobSendStatus.FAILED_PERMANENT.value
job.last_error = "old failure"
session.commit()
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs.mail_integration",
return_value=mail,
),
patch(
"govoplan_campaign.backend.sending.jobs.files_integration"
) as files,
patch(
"govoplan_campaign.backend.sending.jobs._update_campaign_after_job"
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
result = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_resend",
idempotency_key="resend-success",
actor_user_id=None,
reason="Approved operator resend",
)
session.refresh(job)
self.assertEqual(result["status"], "accepted")
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
self.assertIsNotNone(job.sent_at)
files.return_value.mark_job_attachment_uses_sent.assert_called_once()
def test_unknown_resend_is_frozen_and_replay_does_not_send_again(self) -> None:
mail = _Mail(
error=SmtpSendError(
"Unknown delivery outcome",
outcome_unknown=True,
)
)
with self.SessionLocal() as session:
first = self._run(
session,
kind="single_resend",
key="resend-unknown",
reason="Approved operator resend",
mail=mail,
)
repeated = self._run(
session,
kind="single_resend",
key="resend-unknown",
reason="Approved operator resend",
mail=mail,
)
self.assertEqual(first["status"], "outcome_unknown")
self.assertTrue(repeated["duplicate"])
self.assertEqual(mail.send.call_count, 1)
def test_initial_send_links_the_canonical_attempt_and_replay_is_inert(self) -> None:
with self.SessionLocal() as session:
job = session.get(CampaignJob, "job-1")
assert job is not None
def canonical_send(_session: Session, **_kwargs):
attempt = SendAttempt(
job_id=job.id,
attempt_number=1,
status=JobSendStatus.SMTP_ACCEPTED.value,
started_at=job.created_at,
finished_at=job.created_at,
)
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.queue_status = JobQueueStatus.DRAFT.value
job.attempt_count = 1
_session.add_all((attempt, job))
_session.commit()
return SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
),
patch(
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
return_value=self._delivery_context(),
),
patch(
"govoplan_campaign.backend.sending.jobs._prepare_single_job_for_send",
return_value="queued",
),
patch(
"govoplan_campaign.backend.sending.jobs.send_campaign_job",
side_effect=canonical_send,
) as send,
patch(
"govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"
),
patch(
"govoplan_campaign.backend.sending.jobs.audit_event"
),
):
first = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_send",
idempotency_key="initial-1",
actor_user_id=None,
)
repeated = send_single_campaign_job(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
kind="single_send",
idempotency_key="initial-1",
actor_user_id=None,
)
self.assertEqual(first["status"], "accepted")
self.assertIsNotNone(first["linked_send_attempt_id"])
self.assertTrue(repeated["duplicate"])
self.assertEqual(send.call_count, 1)
def test_request_requires_explicit_kind_key_and_resend_reason(self) -> None:
with self.assertRaises(ValidationError):
CampaignSendJobRequest.model_validate({})
with self.assertRaises(ValidationError):
CampaignSendJobRequest.model_validate(
{
"kind": "single_resend",
"idempotency_key": "resend-1",
}
)
request = CampaignSendJobRequest.model_validate(
{
"kind": "test",
"idempotency_key": "test-1",
}
)
self.assertEqual(request.kind, "test")
if __name__ == "__main__":
unittest.main()