Implement governed hybrid campaign delivery
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user