feat: harden campaign delivery and editing
This commit is contained in:
417
tests/test_single_message_actions.py
Normal file
417
tests/test_single_message_actions.py
Normal 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()
|
||||
Reference in New Issue
Block a user