Implement governed hybrid campaign delivery
This commit is contained in:
@@ -139,7 +139,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
|
||||
delivery=DeliveryConfig(),
|
||||
)
|
||||
|
||||
assert payload["snapshot_version"] == "7"
|
||||
assert payload["snapshot_version"] == "8"
|
||||
assert payload["mail_profile_id"] == "profile-1"
|
||||
assert "smtp" not in payload
|
||||
assert "imap" not in payload
|
||||
|
||||
@@ -11,6 +11,7 @@ from govoplan_campaign.backend.db.models import JobSendStatus
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
SendJobResult,
|
||||
_MailChannelOutcome,
|
||||
_PrintChannelOutcome,
|
||||
_final_multichannel_status,
|
||||
_send_claimed_multichannel_job,
|
||||
)
|
||||
@@ -49,6 +50,71 @@ class _Session:
|
||||
|
||||
|
||||
class PostboxFallbackOrchestrationTests(unittest.TestCase):
|
||||
def test_mail_unknown_never_starts_print_fallback(self) -> None:
|
||||
job = SimpleNamespace(id="job-1", print_status="ready")
|
||||
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._deliver_print_channel"
|
||||
) as deliver_print,
|
||||
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_PRINT,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
self.assertIs(result, expected)
|
||||
deliver_print.assert_not_called()
|
||||
|
||||
def test_mail_preacceptance_rejection_starts_print_fallback(self) -> None:
|
||||
job = SimpleNamespace(id="job-1", print_status="ready")
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
|
||||
return_value=_MailChannelOutcome(rejected_permanent=True),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_print_channel",
|
||||
return_value=_PrintChannelOutcome(accepted=True),
|
||||
) as deliver_print,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.PRINT_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_PRINT,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
deliver_print.assert_called_once()
|
||||
|
||||
def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
|
||||
job = _job()
|
||||
expected = SendJobResult(
|
||||
@@ -385,6 +451,12 @@ def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
|
||||
PostboxChannelOutcome(rejected_permanent=1),
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.PRINT,
|
||||
None,
|
||||
PostboxChannelOutcome(),
|
||||
JobSendStatus.PRINT_ACCEPTED.value,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_accepted_delivery_decision_table(
|
||||
@@ -393,7 +465,13 @@ def test_accepted_delivery_decision_table(
|
||||
postbox: PostboxChannelOutcome,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
|
||||
print_output = _PrintChannelOutcome(accepted=True) if policy == DeliveryChannelPolicy.PRINT else None
|
||||
assert _final_multichannel_status(
|
||||
channel_policy=policy,
|
||||
mail=mail,
|
||||
postbox=postbox,
|
||||
print_output=print_output,
|
||||
) == expected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -180,6 +180,42 @@ def test_postbox_only_campaign_does_not_require_mail() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_print_only_campaign_requires_templates_and_an_explicit_target_not_mail() -> None:
|
||||
config = CampaignConfig.model_validate(
|
||||
{
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "campaign-print", "name": "Printed notice", "mode": "send"},
|
||||
"template": {"subject": "Notice", "text": "Printed body", "body_mode": "text"},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "recipient-1",
|
||||
"channel_policy": "print",
|
||||
"print_target": {
|
||||
"channel": "postal",
|
||||
"target": "Example Street 1",
|
||||
"target_key": "postal:example-street-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"channel_policy": "print",
|
||||
"print": {"template_id": "template-1", "template_revision": 2},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
available = validate_campaign_config(config, templates_available=True)
|
||||
unavailable = validate_campaign_config(config, templates_available=False)
|
||||
|
||||
assert "missing_mail_profile" not in {issue.code for issue in available.issues}
|
||||
assert "missing_sender" not in {issue.code for issue in available.issues}
|
||||
assert "print_template_missing" not in {issue.code for issue in available.issues}
|
||||
assert "print_target_missing" not in {issue.code for issue in available.issues}
|
||||
assert "templates_unavailable" in {issue.code for issue in unavailable.issues}
|
||||
|
||||
|
||||
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
|
||||
config = _config()
|
||||
integration = _PostboxIntegration()
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
JobPrintStatus,
|
||||
PrintOutputAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.messages.models import MessageValidationStatus
|
||||
from govoplan_campaign.backend.persistence.campaigns import _resolve_built_print_outputs
|
||||
from govoplan_campaign.backend.routes import versions as version_routes
|
||||
from govoplan_campaign.backend.sending.jobs import _deliver_print_channel
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.templates import TemplateArtifactRef, TemplateRenderResult
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def test_print_acceptance_is_idempotent_per_frozen_artifact() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
PrintOutputAttempt.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
with Session(engine) as session:
|
||||
job = CampaignJob(
|
||||
id="job-print-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
campaign_version_id="version-1",
|
||||
entry_index=1,
|
||||
entry_id="entry-1",
|
||||
recipient_email=None,
|
||||
subject="Printable notice",
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
queue_status="draft",
|
||||
send_status="not_queued",
|
||||
print_status=JobPrintStatus.READY.value,
|
||||
delivery_channel_policy="print",
|
||||
resolved_attachments=[],
|
||||
issues_snapshot=[],
|
||||
resolved_print_output={
|
||||
"render_id": "render-1",
|
||||
"output_sha256": "a" * 64,
|
||||
"template_id": "template-1",
|
||||
"template_revision_id": "revision-1",
|
||||
"template_hash": "b" * 64,
|
||||
"input_hash": "c" * 64,
|
||||
"recipient_key": "recipient-1",
|
||||
"item_index": 0,
|
||||
"route": {"channel": "postal", "target_key": "postal:1"},
|
||||
},
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
first = _deliver_print_channel(session, job=job)
|
||||
second = _deliver_print_channel(session, job=job)
|
||||
|
||||
assert first.accepted is True
|
||||
assert second.accepted is True
|
||||
assert session.get(CampaignJob, job.id).print_attempt_count == 1
|
||||
attempts = session.query(PrintOutputAttempt).all()
|
||||
assert len(attempts) == 1
|
||||
assert attempts[0].artifact_sha256 == "a" * 64
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_print_build_uses_one_deterministic_template_render_request() -> None:
|
||||
config = CampaignConfig.model_validate(
|
||||
{
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "campaign-1", "name": "Printed notice", "mode": "send"},
|
||||
"fields": [{"name": "case_number", "type": "string"}],
|
||||
"template": {"subject": "Notice", "text": "Body", "body_mode": "text"},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "entry-1",
|
||||
"name": "Ada",
|
||||
"channel_policy": "print",
|
||||
"fields": {"case_number": "C-1"},
|
||||
"print_target": {
|
||||
"channel": "postal",
|
||||
"target": "Example Street 1",
|
||||
"target_key": "postal:example-street-1",
|
||||
},
|
||||
"distribution_source": {
|
||||
"list_id": "list-1",
|
||||
"list_revision": 3,
|
||||
"expansion_hash": "expansion-1",
|
||||
"recipient_key": "recipient-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"channel_policy": "print",
|
||||
"print": {
|
||||
"template_id": "template-1",
|
||||
"template_revision": 4,
|
||||
"output_format": "html",
|
||||
"persist_to_files": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
draft = SimpleNamespace(
|
||||
entry_index=1,
|
||||
entry_id="entry-1",
|
||||
delivery_channel_policy="print",
|
||||
validation_status=MessageValidationStatus.READY,
|
||||
)
|
||||
built = [SimpleNamespace(draft=draft)]
|
||||
requests = []
|
||||
stored = {}
|
||||
|
||||
class Storage:
|
||||
def put_bytes(self, key, data, **_kwargs):
|
||||
stored[key] = data
|
||||
|
||||
class Templates:
|
||||
def render(self, _session, _principal, *, request):
|
||||
requests.append(request)
|
||||
return TemplateRenderResult(
|
||||
render_id="render-1",
|
||||
template_id="template-1",
|
||||
revision_id="revision-4",
|
||||
revision=4,
|
||||
template_hash="b" * 64,
|
||||
input_hash="c" * 64,
|
||||
renderer_version="templates-1",
|
||||
output_format="html",
|
||||
content_type="text/html",
|
||||
filename="printed-notice.html",
|
||||
item_count=1,
|
||||
page_count=1,
|
||||
output_sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
|
||||
output_size_bytes=len(b"<p>Printed notice</p>"),
|
||||
artifact=TemplateArtifactRef(
|
||||
kind="bounded_download",
|
||||
filename="printed-notice.html",
|
||||
content_type="text/html",
|
||||
size_bytes=len(b"<p>Printed notice</p>"),
|
||||
sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
|
||||
download_path="/api/v1/templates/renders/render-1/download",
|
||||
),
|
||||
payload=b"<p>Printed notice</p>",
|
||||
)
|
||||
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
campaign_id="campaign-1",
|
||||
version_number=2,
|
||||
)
|
||||
principal = SimpleNamespace(account_id="account-1")
|
||||
storage = Storage()
|
||||
with patch(
|
||||
"govoplan_campaign.backend.persistence.campaigns.templates_integration",
|
||||
return_value=Templates(),
|
||||
):
|
||||
first = _resolve_built_print_outputs(
|
||||
object(), # type: ignore[arg-type]
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
build_id="build-1",
|
||||
version=version, # type: ignore[arg-type]
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
config=config,
|
||||
built_messages=built,
|
||||
entries_by_index={1: config.entries.inline[0]},
|
||||
)
|
||||
second = _resolve_built_print_outputs(
|
||||
object(), # type: ignore[arg-type]
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
build_id="build-1",
|
||||
version=version, # type: ignore[arg-type]
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
config=config,
|
||||
built_messages=built,
|
||||
entries_by_index={1: config.entries.inline[0]},
|
||||
)
|
||||
|
||||
assert requests[0].idempotency_key == requests[1].idempotency_key
|
||||
assert requests[0].items[0]["case_number"] == "C-1"
|
||||
assert requests[0].persist_to_files is True
|
||||
assert first == second
|
||||
assert first[1]["artifact"]["storage_key"] in stored
|
||||
assert first[1]["artifact"]["download_path"] == (
|
||||
"/api/v1/campaigns/campaign-1/versions/version-1/print-output/download"
|
||||
)
|
||||
assert first[1]["route"]["target_key"] == "postal:example-street-1"
|
||||
|
||||
|
||||
class _RoutePrincipal:
|
||||
tenant_id = "tenant-1"
|
||||
account_id = "account-1"
|
||||
user = SimpleNamespace(id="user-1")
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in {"campaigns:campaign:read", "campaigns:recipient:read"}
|
||||
|
||||
|
||||
def test_print_download_is_authorized_by_campaign_and_hash_checked() -> None:
|
||||
payload = b"<p>Printable output</p>"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
build_summary={
|
||||
"print_output": {
|
||||
"output_sha256": digest,
|
||||
"template_id": "template-1",
|
||||
"template_revision_id": "revision-1",
|
||||
"artifact": {
|
||||
"kind": "bounded_download",
|
||||
"filename": "letters.html",
|
||||
"content_type": "text/html",
|
||||
"storage_key": "campaign-artifacts/tenant-1/letters.html",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
storage = SimpleNamespace(get_bytes=lambda _key: payload)
|
||||
with (
|
||||
patch.object(version_routes, "_get_campaign_for_principal"),
|
||||
patch.object(
|
||||
version_routes,
|
||||
"get_campaign_version_for_tenant",
|
||||
return_value=version,
|
||||
),
|
||||
patch.object(version_routes, "_object_storage", return_value=storage),
|
||||
patch.object(version_routes, "audit_from_principal") as audit,
|
||||
):
|
||||
response = version_routes.download_print_output(
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
session=Mock(),
|
||||
principal=_RoutePrincipal(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert response.body == payload
|
||||
assert response.headers["x-content-sha256"] == digest
|
||||
audit.assert_called_once()
|
||||
|
||||
|
||||
def test_print_download_requires_recipient_read_authority() -> None:
|
||||
principal = _RoutePrincipal()
|
||||
principal.has = lambda scope: scope == "campaigns:campaign:read" # type: ignore[method-assign]
|
||||
with (
|
||||
patch.object(version_routes, "_get_campaign_for_principal"),
|
||||
pytest.raises(HTTPException) as denied,
|
||||
):
|
||||
version_routes.download_print_output(
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
session=Mock(),
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
assert denied.value.status_code == 403
|
||||
@@ -57,6 +57,14 @@ def _job() -> SimpleNamespace:
|
||||
}
|
||||
],
|
||||
resolved_recipients={"to": [{"email": "person@example.test"}]},
|
||||
resolved_print_output={
|
||||
"output_sha256": "print-sha256",
|
||||
"artifact": {
|
||||
"filename": "letters.html",
|
||||
"storage_key": "campaign/private/letters.html",
|
||||
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -238,6 +246,13 @@ def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
|
||||
assert "claimed_at" not in job_payload
|
||||
assert "smtp_started_at" not in job_payload
|
||||
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
|
||||
assert job_payload["resolved_print_output"] == {
|
||||
"output_sha256": "print-sha256",
|
||||
"artifact": {
|
||||
"filename": "letters.html",
|
||||
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
|
||||
},
|
||||
}
|
||||
assert "claim_token" not in attempts["smtp"][0]
|
||||
assert "claim_token" not in attempts["imap"][0]
|
||||
assert "smtp_response" not in attempts["smtp"][0]
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 65
|
||||
assert len(actual) == 70
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user