418 lines
15 KiB
Python
418 lines
15 KiB
Python
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()
|