283 lines
10 KiB
Python
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
|