from __future__ import annotations import unittest from datetime import timedelta from types import SimpleNamespace from unittest.mock import patch from sqlalchemy import Column, String, Table, create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from govoplan_core.db.base import Base from govoplan_mail.backend.db.models import ( MailDeliveryAttempt, MailDeliveryCommand, MailDeliveryReconciliation, MailServerProfile, ) from govoplan_mail.backend.delivery_outbox import ( MailDeliveryIdempotencyConflict, delivery_command_diagnostics, dispatch_due, purge_expired, submit_delivery_command, utcnow, ) from govoplan_mail.backend.sending.smtp import SmtpSendError class MailDeliveryOutboxTests(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), ) Base.metadata.create_all( self.engine, tables=[ access_users, MailServerProfile.__table__, MailDeliveryCommand.__table__, MailDeliveryAttempt.__table__, MailDeliveryReconciliation.__table__, ], ) self.SessionLocal = sessionmaker( bind=self.engine, class_=Session, expire_on_commit=False, ) with self.SessionLocal() as session: session.add( MailServerProfile( id="profile-1", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Delivery", slug="delivery", smtp_config={"host": "smtp.example.test", "port": 25}, ) ) session.commit() self.audit = patch( "govoplan_mail.backend.delivery_outbox.audit_event" ) self.audit.start() self.addCleanup(self.audit.stop) self.addCleanup(self.engine.dispose) def _submit( self, session: Session, *, key: str = "request-1", recipients: list[str] | None = None, ) -> dict[str, object]: return submit_delivery_command( session, tenant_id="tenant-1", command_type="campaign_report", source_module="campaigns", source_resource_type="campaign", source_resource_id="campaign-1", source_version_id="version-1", idempotency_key=key, profile_id="profile-1", message_bytes=b"Subject: report\r\n\r\ncontent", envelope_from="sender@example.test", envelope_recipients=recipients or ["recipient@example.test"], from_header="Sender ", expected_smtp_transport_revision="revision-1", created_by_user_id=None, ) def test_submission_is_idempotent_and_conflicts_on_changed_intent(self) -> None: with self.SessionLocal() as session: first = self._submit(session) session.commit() repeated = self._submit(session) self.assertEqual(first["id"], repeated["id"]) self.assertTrue(repeated["duplicate"]) with self.assertRaises(MailDeliveryIdempotencyConflict): self._submit( session, recipients=["different@example.test"], ) def test_attempt_is_committed_before_effect_and_accepted_is_not_resent(self) -> None: observed: dict[str, object] = {} with self.SessionLocal() as session: command_id = str(self._submit(session)["id"]) session.commit() def provider_effect(session: Session, **_kwargs): command = session.get(MailDeliveryCommand, command_id) attempt = session.query(MailDeliveryAttempt).one() observed.update( command_status=command.status, effect_started=command.effect_started_at is not None, attempt_status=attempt.status, ) return SimpleNamespace( accepted_count=1, refused_recipients={}, ) with ( self.SessionLocal() as session, patch( "govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes", side_effect=provider_effect, ) as send, ): result = dispatch_due(session, worker_id="worker-1") repeated = dispatch_due(session, worker_id="worker-2") self.assertEqual(result["accepted"], 1) self.assertEqual(repeated["selected"], 0) self.assertEqual(send.call_count, 1) self.assertEqual( observed, { "command_status": "in_progress", "effect_started": True, "attempt_status": "in_progress", }, ) def test_partial_refusal_is_terminal_and_diagnostics_are_separate(self) -> None: with self.SessionLocal() as session: command_id = str( self._submit( session, recipients=["accepted@example.test", "blocked@example.test"], )["id"] ) session.commit() with patch( "govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes", return_value=SimpleNamespace( accepted_count=1, refused_recipients={ "blocked@example.test": { "status_code": 550, "classification": "permanent", "message": "Permanent recipient rejection", } }, ), ): result = dispatch_due(session) command = session.get(MailDeliveryCommand, command_id) assert command is not None self.assertEqual(result["partially_refused"], 1) self.assertNotIn("blocked@example.test", repr({ "status": command.status, "summary": command.refusal_summary, })) diagnostics = delivery_command_diagnostics( session, tenant_id="tenant-1", command_id=command_id, ) self.assertIn( "blocked@example.test", diagnostics["refused_recipients"], ) def test_unknown_outcome_is_never_automatically_retried(self) -> None: with self.SessionLocal() as session: command_id = str(self._submit(session)["id"]) session.commit() with patch( "govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes", side_effect=SmtpSendError( "unknown", outcome_unknown=True, ), ) as send: first = dispatch_due(session) second = dispatch_due(session) command = session.get(MailDeliveryCommand, command_id) assert command is not None self.assertEqual(first["outcome_unknown"], 1) self.assertEqual(second["selected"], 0) self.assertEqual(command.status, "outcome_unknown") self.assertEqual(send.call_count, 1) def test_audit_failure_after_acceptance_cannot_make_command_retryable(self) -> None: with self.SessionLocal() as session: command_id = str(self._submit(session)["id"]) session.commit() with ( self.SessionLocal() as session, patch( "govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes", return_value=SimpleNamespace( accepted_count=1, refused_recipients={}, ), ) as send, patch( "govoplan_mail.backend.delivery_outbox.audit_event", side_effect=RuntimeError("audit unavailable"), ), ): with self.assertRaisesRegex(RuntimeError, "audit unavailable"): dispatch_due(session) with self.SessionLocal() as session: command = session.get(MailDeliveryCommand, command_id) assert command is not None self.assertEqual(command.status, "accepted") self.assertEqual(dispatch_due(session)["selected"], 0) self.assertEqual(send.call_count, 1) def test_stale_effect_started_command_becomes_unknown_without_send(self) -> None: with self.SessionLocal() as session: command_id = str(self._submit(session)["id"]) session.commit() command = session.get(MailDeliveryCommand, command_id) assert command is not None command.status = "in_progress" command.attempt_count = 1 command.claimed_at = utcnow() - timedelta(hours=1) command.effect_started_at = utcnow() - timedelta(hours=1) session.add( MailDeliveryAttempt( command_id=command.id, attempt_number=1, status="in_progress", started_at=utcnow() - timedelta(hours=1), effect_started_at=utcnow() - timedelta(hours=1), ) ) session.commit() with patch( "govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes" ) as send: result = dispatch_due(session) self.assertEqual(result["outcome_unknown"], 1) self.assertEqual(command.status, "outcome_unknown") send.assert_not_called() def test_expired_payload_is_minimized_but_evidence_remains(self) -> None: with self.SessionLocal() as session: command_id = str(self._submit(session)["id"]) session.commit() command = session.get(MailDeliveryCommand, command_id) assert command is not None command.expires_at = utcnow() - timedelta(seconds=1) session.commit() self.assertEqual(purge_expired(session), {"purged": 1}) session.refresh(command) self.assertIsNone(command.message_encrypted) self.assertIsNone(command.envelope_recipients_encrypted) self.assertIsNotNone(command.payload_purged_at) self.assertEqual(command.message_sha256, command.message_sha256) if __name__ == "__main__": unittest.main()