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()