from __future__ import annotations import unittest from types import SimpleNamespace from unittest.mock import patch from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy from govoplan_campaign.backend.db.models import JobSendStatus from govoplan_campaign.backend.sending.jobs import ( SendJobResult, _MailChannelOutcome, _final_multichannel_status, _send_claimed_multichannel_job, ) from govoplan_campaign.backend.sending.postbox_delivery import ( PostboxChannelOutcome, ) def _context(): return SimpleNamespace( message_bytes=b"message", snapshot=SimpleNamespace( delivery=SimpleNamespace( postbox=SimpleNamespace(classification="internal") ) ), ) def _job(): return SimpleNamespace(id="job-1") class _Session: def __init__(self, job) -> None: self.job = job def get(self, _model, _id): return self.job def add(self, _value) -> None: return None def commit(self) -> None: return None class PostboxFallbackOrchestrationTests(unittest.TestCase): def test_mail_unknown_never_starts_postbox_fallback(self) -> None: job = _job() expected = SendJobResult( job_id=job.id, status=JobSendStatus.OUTCOME_UNKNOWN.value, attempt_number=1, ) with ( patch( "govoplan_campaign.backend.sending.jobs._deliver_mail_channel", return_value=_MailChannelOutcome(outcome_unknown=True), ), patch( "govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts", return_value=PostboxChannelOutcome(), ), patch( "govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes" ) as deliver_postbox, patch( "govoplan_campaign.backend.sending.jobs._finalize_multichannel_job", return_value=expected, ), ): result = _send_claimed_multichannel_job( _Session(job), # type: ignore[arg-type] job=job, # type: ignore[arg-type] claim_token="claim-1", context=_context(), # type: ignore[arg-type] channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX, use_rate_limit=False, enqueue_imap_task=False, ) self.assertIs(result, expected) deliver_postbox.assert_not_called() def test_mail_preacceptance_rejection_starts_postbox_fallback(self) -> None: job = _job() postbox_outcome = PostboxChannelOutcome(accepted=1) with ( patch( "govoplan_campaign.backend.sending.jobs._deliver_mail_channel", return_value=_MailChannelOutcome(rejected_permanent=True), ), patch( "govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts", return_value=PostboxChannelOutcome(), ), patch( "govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes", return_value=postbox_outcome, ) as deliver_postbox, patch( "govoplan_campaign.backend.sending.jobs._finalize_multichannel_job", return_value=SendJobResult( job_id=job.id, status=JobSendStatus.POSTBOX_ACCEPTED.value, attempt_number=1, ), ), ): _send_claimed_multichannel_job( _Session(job), # type: ignore[arg-type] job=job, # type: ignore[arg-type] claim_token="claim-1", context=_context(), # type: ignore[arg-type] channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX, use_rate_limit=False, enqueue_imap_task=False, ) deliver_postbox.assert_called_once() def test_accepted_postbox_fallback_prevents_later_mail_retry(self) -> None: job = _job() with ( patch( "govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts", return_value=PostboxChannelOutcome( accepted=1, rejected_temporary=1, ), ), patch( "govoplan_campaign.backend.sending.jobs._deliver_mail_channel" ) as deliver_mail, patch( "govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes", return_value=PostboxChannelOutcome(accepted=2), ) as deliver_postbox, patch( "govoplan_campaign.backend.sending.jobs._finalize_multichannel_job", return_value=SendJobResult( job_id=job.id, status=JobSendStatus.POSTBOX_ACCEPTED.value, attempt_number=3, ), ), ): _send_claimed_multichannel_job( _Session(job), # type: ignore[arg-type] job=job, # type: ignore[arg-type] claim_token="claim-1", context=_context(), # type: ignore[arg-type] channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX, use_rate_limit=False, enqueue_imap_task=False, ) deliver_mail.assert_not_called() deliver_postbox.assert_called_once() def test_unknown_postbox_fallback_prevents_every_later_effect(self) -> None: job = _job() prior = PostboxChannelOutcome(outcome_unknown=1) with ( patch( "govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts", return_value=prior, ), patch( "govoplan_campaign.backend.sending.jobs._deliver_mail_channel" ) as deliver_mail, patch( "govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes" ) as deliver_postbox, patch( "govoplan_campaign.backend.sending.jobs._finalize_multichannel_job", return_value=SendJobResult( job_id=job.id, status=JobSendStatus.OUTCOME_UNKNOWN.value, attempt_number=1, ), ) as finalize, ): _send_claimed_multichannel_job( _Session(job), # type: ignore[arg-type] job=job, # type: ignore[arg-type] claim_token="claim-1", context=_context(), # type: ignore[arg-type] channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX, use_rate_limit=False, enqueue_imap_task=False, ) deliver_mail.assert_not_called() deliver_postbox.assert_not_called() self.assertIs(finalize.call_args.kwargs["postbox"], prior) def test_postbox_acceptance_stops_mail_fallback(self) -> None: job = _job() with ( patch( "govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes", return_value=PostboxChannelOutcome( accepted=1, rejected_permanent=1, ), ), patch( "govoplan_campaign.backend.sending.jobs._deliver_mail_channel" ) as deliver_mail, patch( "govoplan_campaign.backend.sending.jobs._finalize_multichannel_job", return_value=SendJobResult( job_id=job.id, status=JobSendStatus.PARTIALLY_ACCEPTED.value, attempt_number=2, ), ), ): _send_claimed_multichannel_job( _Session(job), # type: ignore[arg-type] job=job, # type: ignore[arg-type] claim_token="claim-1", context=_context(), # type: ignore[arg-type] channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL, use_rate_limit=False, enqueue_imap_task=False, ) deliver_mail.assert_not_called() def test_all_postbox_rejections_start_mail_fallback(self) -> None: job = _job() with ( patch( "govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes", return_value=PostboxChannelOutcome( rejected_temporary=1, rejected_permanent=1, ), ), patch( "govoplan_campaign.backend.sending.jobs._deliver_mail_channel", return_value=_MailChannelOutcome(accepted=True), ) as deliver_mail, patch( "govoplan_campaign.backend.sending.jobs._finalize_multichannel_job", return_value=SendJobResult( job_id=job.id, status=JobSendStatus.SMTP_ACCEPTED.value, attempt_number=1, ), ), ): _send_claimed_multichannel_job( _Session(job), # type: ignore[arg-type] job=job, # type: ignore[arg-type] claim_token="claim-1", context=_context(), # type: ignore[arg-type] channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL, use_rate_limit=False, enqueue_imap_task=False, ) deliver_mail.assert_called_once() def test_explicit_dual_delivery_reports_partial_and_unknown(self) -> None: self.assertEqual( JobSendStatus.PARTIALLY_ACCEPTED.value, _final_multichannel_status( channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX, mail=_MailChannelOutcome(accepted=True), postbox=PostboxChannelOutcome(rejected_permanent=1), ), ) self.assertEqual( JobSendStatus.OUTCOME_UNKNOWN.value, _final_multichannel_status( channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX, mail=_MailChannelOutcome(accepted=True), postbox=PostboxChannelOutcome(outcome_unknown=1), ), ) if __name__ == "__main__": unittest.main()