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"

Printed notice

").hexdigest(), output_size_bytes=len(b"

Printed notice

"), artifact=TemplateArtifactRef( kind="bounded_download", filename="printed-notice.html", content_type="text/html", size_bytes=len(b"

Printed notice

"), sha256=hashlib.sha256(b"

Printed notice

").hexdigest(), download_path="/api/v1/templates/renders/render-1/download", ), payload=b"

Printed notice

", ) 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"

Printable output

" 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