Files
govoplan-campaign/tests/test_print_delivery_idempotency.py
T

283 lines
10 KiB
Python

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