feat(campaign): bound synchronous delivery
This commit is contained in:
171
tests/test_synchronous_delivery_policy.py
Normal file
171
tests/test_synchronous_delivery_policy.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
JobBuildStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
SynchronousSendRejected,
|
||||
_ensure_synchronous_send_count_allowed,
|
||||
_preflight_synchronous_send_batch,
|
||||
synchronous_send_candidate_jobs,
|
||||
)
|
||||
|
||||
|
||||
class _PolicySession:
|
||||
def __init__(self, settings: dict[str, object] | None = None) -> None:
|
||||
self.tenant = SimpleNamespace(settings=settings or {})
|
||||
|
||||
def get(self, _model, _id):
|
||||
return self.tenant
|
||||
|
||||
|
||||
def _version() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="version-1",
|
||||
build_summary={"build_token": "build-1"},
|
||||
editor_state={
|
||||
"review_send": {
|
||||
"build_token": "build-1",
|
||||
"inspection_complete": True,
|
||||
"reviewed_message_keys": ["reviewed"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _job(job_id: str, **overrides: object) -> SimpleNamespace:
|
||||
values: dict[str, object] = {
|
||||
"id": job_id,
|
||||
"entry_id": job_id,
|
||||
"entry_index": 1,
|
||||
"build_status": JobBuildStatus.BUILT.value,
|
||||
"validation_status": JobValidationStatus.READY.value,
|
||||
"queue_status": JobQueueStatus.DRAFT.value,
|
||||
"send_status": JobSendStatus.NOT_QUEUED.value,
|
||||
"eml_local_path": f"{job_id}.eml",
|
||||
"eml_storage_key": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_synchronous_policy_defaults_to_25_and_tenant_can_only_narrow() -> None:
|
||||
default = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={},
|
||||
)
|
||||
assert default.max_recipient_jobs == DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS == 25
|
||||
assert default.source == "deployment_default"
|
||||
|
||||
narrowed = effective_synchronous_send_policy(
|
||||
_PolicySession(
|
||||
{"campaign_delivery_policy": {"synchronous_send_max_recipients": 10}}
|
||||
), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "40"},
|
||||
)
|
||||
assert narrowed.max_recipient_jobs == 10
|
||||
assert narrowed.source == "tenant"
|
||||
|
||||
ceiling = effective_synchronous_send_policy(
|
||||
_PolicySession(
|
||||
{"campaign_delivery_policy": {"synchronous_send_max_recipients": 100}}
|
||||
), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "40"},
|
||||
)
|
||||
assert ceiling.max_recipient_jobs == 40
|
||||
assert ceiling.source == "deployment_ceiling"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, -1, 501, "2.5", "unbounded"])
|
||||
def test_invalid_synchronous_policy_fails_closed(value: object) -> None:
|
||||
with pytest.raises(CampaignDeliveryPolicyError):
|
||||
effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": value}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
|
||||
def test_candidate_count_uses_exact_built_and_reviewed_job_states() -> None:
|
||||
jobs = [
|
||||
_job("ready"),
|
||||
_job("queued", queue_status=JobQueueStatus.QUEUED.value, send_status=JobSendStatus.QUEUED.value),
|
||||
_job("reviewed", validation_status=JobValidationStatus.NEEDS_REVIEW.value),
|
||||
_job("blocked", validation_status=JobValidationStatus.BLOCKED.value),
|
||||
_job("failed", send_status=JobSendStatus.FAILED_TEMPORARY.value),
|
||||
_job("missing-eml", eml_local_path=None),
|
||||
]
|
||||
|
||||
candidates = synchronous_send_candidate_jobs(_version(), jobs) # type: ignore[arg-type]
|
||||
|
||||
assert [job.id for job in candidates] == ["ready", "queued", "reviewed"]
|
||||
|
||||
|
||||
def test_limit_and_zero_count_reject_before_delivery() -> None:
|
||||
policy = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
|
||||
)
|
||||
with pytest.raises(SynchronousSendRejected, match="No eligible") as empty:
|
||||
_ensure_synchronous_send_count_allowed(0, policy=policy)
|
||||
assert empty.value.reason == "no_eligible_recipient_jobs"
|
||||
|
||||
with pytest.raises(SynchronousSendRejected, match="Queue it") as oversized:
|
||||
_ensure_synchronous_send_count_allowed(3, policy=policy)
|
||||
assert oversized.value.audit_details()["eligible_recipient_job_count"] == 3
|
||||
assert oversized.value.audit_details()["synchronous_send_policy"]["max_recipient_jobs"] == 2
|
||||
|
||||
|
||||
def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
|
||||
jobs = [_job("one"), _job("two")]
|
||||
contexts = {
|
||||
"one": SimpleNamespace(snapshot=SimpleNamespace(smtp_transport_revision="revision-1")),
|
||||
"two": SimpleNamespace(snapshot=SimpleNamespace(smtp_transport_revision="revision-1")),
|
||||
}
|
||||
policy = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={},
|
||||
)
|
||||
provider = Mock()
|
||||
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs._preflight_send_campaign_job", return_value=None) as state_preflight,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
|
||||
side_effect=lambda _session, job: contexts[job.id],
|
||||
) as input_preflight,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.profile_delivery_summary",
|
||||
return_value={"smtp_transport_revision": "revision-1"},
|
||||
),
|
||||
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider),
|
||||
):
|
||||
result = _preflight_synchronous_send_batch(
|
||||
object(), # type: ignore[arg-type]
|
||||
version=_version(), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
assert list(result) == ["one", "two"]
|
||||
assert state_preflight.call_count == 2
|
||||
assert input_preflight.call_count == 2
|
||||
provider.send_campaign_email_bytes.assert_not_called()
|
||||
Reference in New Issue
Block a user