401 lines
14 KiB
Python
401 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
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),
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
|
|
@pytest.mark.parametrize(
|
|
("mail", "postbox"),
|
|
[
|
|
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome()),
|
|
(_MailChannelOutcome(accepted=True), PostboxChannelOutcome(outcome_unknown=1)),
|
|
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome(accepted=1)),
|
|
],
|
|
)
|
|
def test_outcome_unknown_has_precedence_for_every_delivery_policy(
|
|
policy: DeliveryChannelPolicy,
|
|
mail: _MailChannelOutcome,
|
|
postbox: PostboxChannelOutcome,
|
|
) -> None:
|
|
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == JobSendStatus.OUTCOME_UNKNOWN.value
|
|
|
|
|
|
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
|
|
@pytest.mark.parametrize(
|
|
("mail", "postbox", "expected"),
|
|
[
|
|
(
|
|
_MailChannelOutcome(rejected_permanent=True),
|
|
PostboxChannelOutcome(rejected_permanent=1),
|
|
JobSendStatus.FAILED_PERMANENT.value,
|
|
),
|
|
(
|
|
_MailChannelOutcome(rejected_temporary=True),
|
|
PostboxChannelOutcome(rejected_permanent=1),
|
|
JobSendStatus.FAILED_TEMPORARY.value,
|
|
),
|
|
(
|
|
_MailChannelOutcome(rejected_permanent=True),
|
|
PostboxChannelOutcome(rejected_temporary=1),
|
|
JobSendStatus.FAILED_TEMPORARY.value,
|
|
),
|
|
],
|
|
)
|
|
def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
|
|
policy: DeliveryChannelPolicy,
|
|
mail: _MailChannelOutcome,
|
|
postbox: PostboxChannelOutcome,
|
|
expected: str,
|
|
) -> None:
|
|
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("policy", "mail", "postbox", "expected"),
|
|
[
|
|
(
|
|
DeliveryChannelPolicy.MAIL,
|
|
_MailChannelOutcome(accepted=True),
|
|
PostboxChannelOutcome(),
|
|
JobSendStatus.SMTP_ACCEPTED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.POSTBOX,
|
|
None,
|
|
PostboxChannelOutcome(accepted=1),
|
|
JobSendStatus.POSTBOX_ACCEPTED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.POSTBOX,
|
|
None,
|
|
PostboxChannelOutcome(accepted=1, rejected_permanent=1),
|
|
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
|
_MailChannelOutcome(accepted=True),
|
|
PostboxChannelOutcome(accepted=1),
|
|
JobSendStatus.DELIVERED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
|
_MailChannelOutcome(accepted=True),
|
|
PostboxChannelOutcome(rejected_permanent=1),
|
|
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
|
_MailChannelOutcome(rejected_permanent=True),
|
|
PostboxChannelOutcome(accepted=1),
|
|
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
|
_MailChannelOutcome(rejected_permanent=True),
|
|
PostboxChannelOutcome(accepted=1),
|
|
JobSendStatus.POSTBOX_ACCEPTED.value,
|
|
),
|
|
(
|
|
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
|
_MailChannelOutcome(accepted=True),
|
|
PostboxChannelOutcome(rejected_permanent=1),
|
|
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
|
),
|
|
],
|
|
)
|
|
def test_accepted_delivery_decision_table(
|
|
policy: DeliveryChannelPolicy,
|
|
mail: _MailChannelOutcome | None,
|
|
postbox: PostboxChannelOutcome,
|
|
expected: str,
|
|
) -> None:
|
|
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|