"""Documents Generator tests (Phase L1-L3) — letterheads, print templates, block registry, module contributions, preview/render, RBAC, isolation. Contract-Muster (like importexport_entities): plugins contribute ``document_blocks()``, ``document_placeholders(entity_type)`` and ``document_data(db, tenant_id, entity_id, entity_type)``. contacts is the first contributor (W4a-style contribution, #359 philosophy). """ from __future__ import annotations import uuid as uuid_mod from collections.abc import AsyncGenerator import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession from app.core.db import close_engine, reset_engine_for_testing from app.core.permission_registry import init_permission_registry from app.core.service_container import get_container from app.main import create_app from app.plugins.builtins.permissions import PermissionsPlugin from app.plugins.builtins.report_generator import ReportGeneratorPlugin from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import ( ORIGIN_HEADER, login_client, seed_tenant_and_users, ) DOC_BASE = "/api/v1/reports" @pytest_asyncio.fixture async def docs_app(engine: AsyncEngine, redis_client): """App with permissions + report_generator installed & activated.""" reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) init_permission_registry(active_plugin_names={"permissions", "report_generator"}) container = get_container() await container.initialize() registry.register_plugin(PermissionsPlugin()) registry.register_plugin(ReportGeneratorPlugin()) # contacts contributes document placeholders/data via its contract — # register (not install) so list_discovered() includes it and the # contract registry can lazy-load contacts.contracts. from app.plugins.builtins.contacts.plugin import ContactsPlugin registry.register_plugin(ContactsPlugin()) reset_plugin_service_for_testing(registry) _sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with _sf() as session: await registry.install(session, "permissions") await registry.activate(session, "permissions") await registry.install(session, "report_generator") await registry.activate(session, "report_generator") await session.commit() yield app await close_engine() @pytest_asyncio.fixture async def docs_client(docs_app) -> AsyncGenerator[AsyncClient, None]: transport = ASGITransport(app=docs_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c def _letterhead_payload(**overrides) -> dict: payload = { "name": "Standard Briefpapier", "description": "Firmenbriefkopf mit Logo", "config": { "page": { "size": "A4", "orientation": "portrait", "margins": {"top": 25, "right": 20, "bottom": 25, "left": 20}, }, "header": { "enabled": True, "blocks": [ { "id": "h1", "type": "text", "config": {"content": "Meine Firma GmbH"}, } ], }, "footer": {"enabled": True, "blocks": []}, "watermark": {"enabled": False}, }, "is_default": False, } payload.update(overrides) return payload def _template_payload(letterhead_id: str | None = None, **overrides) -> dict: payload = { "name": "Angebot", "description": "Angebot mit Briefpapier", "letterhead_id": letterhead_id, "entity_type": "company", "blocks": [ { "id": "b1", "type": "text", "config": {"content": "Sehr geehrte Damen und Herren,\n\nFirma: {{name}}"}, }, { "id": "b2", "type": "shape", "config": {"shape": "line", "width": "100%", "height": 1, "color": "#111827"}, }, ], "output_format": "pdf", } payload.update(overrides) return payload # ─── Letterhead CRUD ───────────────────────────────────────────────────────── @pytest.mark.asyncio class TestLetterheadCRUD: async def test_create_letterhead_201( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 201, f"{resp.status_code} {resp.text}" body = resp.json() assert body["name"] == "Standard Briefpapier" assert body["config"]["header"]["enabled"] is True assert body["id"] async def test_create_letterhead_empty_name_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(name=""), headers=ORIGIN_HEADER, ) assert resp.status_code == 422 async def test_list_letterheads_tenant_scoped( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 201 lh_a = resp.json()["id"] # Tenant B admin creates their own letterhead await login_client(docs_client, "admin@tenantb.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(name="Tenant B Kopf"), headers=ORIGIN_HEADER, ) assert resp.status_code == 201 resp = await docs_client.get(f"{DOC_BASE}/letterheads", headers=ORIGIN_HEADER) assert resp.status_code == 200 items = resp.json()["items"] ids = [i["id"] for i in items] assert lh_a not in ids # cross-tenant isolation assert len(items) == 1 assert items[0]["name"] == "Tenant B Kopf" async def test_get_letterhead_404(self, docs_client: AsyncClient, db_session): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.get( f"{DOC_BASE}/letterheads/{uuid_mod.uuid4()}", headers=ORIGIN_HEADER ) assert resp.status_code == 404 async def test_update_letterhead( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) lh_id = resp.json()["id"] resp = await docs_client.put( f"{DOC_BASE}/letterheads/{lh_id}", json={"name": "Neuer Name"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["name"] == "Neuer Name" async def test_delete_letterhead_soft( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) lh_id = resp.json()["id"] resp = await docs_client.delete( f"{DOC_BASE}/letterheads/{lh_id}", headers=ORIGIN_HEADER ) assert resp.status_code == 204 resp = await docs_client.get( f"{DOC_BASE}/letterheads/{lh_id}", headers=ORIGIN_HEADER ) assert resp.status_code == 404 async def test_viewer_cannot_manage_403( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "viewer@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # ─── Print Template CRUD ───────────────────────────────────────────────────── @pytest.mark.asyncio class TestPrintTemplateCRUD: async def test_create_template_201( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 201, f"{resp.status_code} {resp.text}" body = resp.json() assert body["name"] == "Angebot" assert len(body["blocks"]) == 2 assert body["entity_type"] == "company" async def test_create_template_with_letterhead_ref( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) lh_id = resp.json()["id"] resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload(letterhead_id=lh_id), headers=ORIGIN_HEADER, ) assert resp.status_code == 201 assert resp.json()["letterhead_id"] == lh_id async def test_create_template_invalid_block_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload(blocks=[{"id": "x", "type": "not_a_block"}]), headers=ORIGIN_HEADER, ) assert resp.status_code == 422 assert "invalid_block" in resp.text async def test_create_template_text_block_requires_content_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload( blocks=[{"id": "x", "type": "text", "config": {}}] ), headers=ORIGIN_HEADER, ) assert resp.status_code == 422 async def test_create_template_bad_shape_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload( blocks=[ { "id": "x", "type": "shape", "config": {"shape": "triangle"}, } ] ), headers=ORIGIN_HEADER, ) assert resp.status_code == 422 async def test_list_and_update_and_delete_template( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload(), headers=ORIGIN_HEADER, ) tid = resp.json()["id"] resp = await docs_client.get(f"{DOC_BASE}/print-templates", headers=ORIGIN_HEADER) assert resp.status_code == 200 assert any(t["id"] == tid for t in resp.json()["items"]) resp = await docs_client.put( f"{DOC_BASE}/print-templates/{tid}", json={"name": "Angebot v2"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["name"] == "Angebot v2" resp = await docs_client.delete( f"{DOC_BASE}/print-templates/{tid}", headers=ORIGIN_HEADER ) assert resp.status_code == 204 resp = await docs_client.get( f"{DOC_BASE}/print-templates/{tid}", headers=ORIGIN_HEADER ) assert resp.status_code == 404 # ─── Block Registry + Module Contributions ────────────────────────────────── @pytest.mark.asyncio class TestBlockRegistry: async def test_document_blocks_contains_builtin_types( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.get(f"{DOC_BASE}/document-blocks", headers=ORIGIN_HEADER) assert resp.status_code == 200 types = {b["type"] for b in resp.json()} assert { "text", "image", "shape", "table", "spacer", "divider", "placeholder", } <= types # builtin entries carry a label for the palette UI assert all(b.get("label") for b in resp.json()) async def test_document_placeholders_contains_contacts_contribution( self, docs_client: AsyncClient, db_session ): """contacts plugin contributes placeholders via contract (L1).""" await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.get( f"{DOC_BASE}/document-placeholders", headers=ORIGIN_HEADER ) assert resp.status_code == 200 data = resp.json() assert "contact" in data, f"contact placeholders missing: {list(data)}" keys = [p["key"] for p in data["contact"]] assert "firstname" in keys assert "displayname" in keys # every placeholder has label + example for the editor UI assert all(p.get("label") and p.get("example") is not None for p in data["contact"]) # ─── Preview (HTML) ───────────────────────────────────────────────────────── @pytest.mark.asyncio class TestPreview: async def test_preview_renders_blocks_with_data( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/documents/preview", json={ "blocks": [ { "id": "b1", "type": "text", "config": {"content": "Hallo {{displayname}}!"}, } ], "letterhead_config": None, "data": {"displayname": "Test AG"}, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"{resp.status_code} {resp.text}" html = resp.json()["html"] assert "Hallo Test AG!" in html async def test_preview_with_letterhead_header( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/documents/preview", json={ "blocks": [ { "id": "b1", "type": "text", "config": {"content": "Inhalt"}, } ], "letterhead_config": _letterhead_payload()["config"], "data": {}, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 html = resp.json()["html"] assert "Meine Firma GmbH" in html assert "@page" in html or "running(" in html async def test_preview_placeholder_defaults_without_data( self, docs_client: AsyncClient, db_session ): """Missing data keys fall back to placeholder example values.""" await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/documents/preview", json={ "blocks": [ { "id": "b1", "type": "text", "config": {"content": "{{firstname}}"}, } ], "letterhead_config": None, "entity_type": "contact", "data": None, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 # must render the example value, not crash with StrictUndefined assert "{{firstname}}" not in resp.json()["html"] async def test_preview_invalid_block_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/documents/preview", json={ "blocks": [{"id": "x", "type": "nope"}], "letterhead_config": None, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 # ─── Render (PDF) ──────────────────────────────────────────────────────────── @pytest.mark.asyncio class TestRender: async def _create_template(self, client: AsyncClient, blocks=None) -> str: payload = _template_payload() if blocks is not None: payload["blocks"] = blocks resp = await client.post( f"{DOC_BASE}/print-templates", json=payload, headers=ORIGIN_HEADER, ) assert resp.status_code == 201, f"{resp.status_code} {resp.text}" return resp.json()["id"] async def test_render_pdf_bytes( self, docs_client: AsyncClient, db_session ): seed = await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") tid = await self._create_template(docs_client) resp = await docs_client.post( f"{DOC_BASE}/print-templates/{tid}/render", json={ "entity_type": "company", "entity_id": str(seed["company_a"].id), }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"{resp.status_code} {resp.text}" assert resp.headers["content-type"].startswith("application/pdf") assert resp.content.startswith(b"%PDF") async def test_render_with_letterhead_pdf( self, docs_client: AsyncClient, db_session ): seed = await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) lh_id = resp.json()["id"] resp = await docs_client.post( f"{DOC_BASE}/print-templates", json=_template_payload(letterhead_id=lh_id), headers=ORIGIN_HEADER, ) tid = resp.json()["id"] resp = await docs_client.post( f"{DOC_BASE}/print-templates/{tid}/render", json={ "entity_type": "company", "entity_id": str(seed["company_a"].id), }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.content.startswith(b"%PDF") async def test_render_unknown_entity_404( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") tid = await self._create_template(docs_client) resp = await docs_client.post( f"{DOC_BASE}/print-templates/{tid}/render", json={"entity_type": "company", "entity_id": str(uuid_mod.uuid4())}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 async def test_render_template_not_found_404( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates/{uuid_mod.uuid4()}/render", json={"entity_type": "company", "entity_id": str(uuid_mod.uuid4())}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 async def test_render_viewer_forbidden_403( self, docs_client: AsyncClient, db_session ): seed = await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") tid = await self._create_template(docs_client) await login_client(docs_client, "viewer@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/print-templates/{tid}/render", json={ "entity_type": "company", "entity_id": str(seed["company_a"].id), }, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 async def test_render_image_block_missing_asset_no_crash( self, docs_client: AsyncClient, db_session ): """image block referencing a non-existent asset renders empty.""" seed = await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") tid = await self._create_template( docs_client, blocks=[ { "id": "img1", "type": "image", "config": {"asset_id": str(uuid_mod.uuid4()), "width": 100}, }, { "id": "t1", "type": "text", "config": {"content": "{{name}}"}, }, ], ) resp = await docs_client.post( f"{DOC_BASE}/print-templates/{tid}/render", json={ "entity_type": "company", "entity_id": str(seed["company_a"].id), }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.content.startswith(b"%PDF") # ─── Assets (Logo/Bild-Upload) ─────────────────────────────────────────────── @pytest.mark.asyncio class TestAssets: async def test_upload_logo_asset_201( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) lh_id = resp.json()["id"] # minimal valid PNG (1x1 transparent) png_bytes = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" "0000000d49444154789c626001000000ffff03000006000557bfabd40000000049454e44ae426082" ) resp = await docs_client.post( f"{DOC_BASE}/letterheads/{lh_id}/assets", files={"file": ("logo.png", png_bytes, "image/png")}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201, f"{resp.status_code} {resp.text}" body = resp.json() assert body["filename"] == "logo.png" assert body["mime_type"] == "image/png" assert body["id"] async def test_upload_non_image_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads", json=_letterhead_payload(), headers=ORIGIN_HEADER, ) lh_id = resp.json()["id"] resp = await docs_client.post( f"{DOC_BASE}/letterheads/{lh_id}/assets", files={"file": ("notes.txt", b"not an image", "text/plain")}, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 async def test_upload_to_foreign_letterhead_404( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/letterheads/{uuid_mod.uuid4()}/assets", files={"file": ("x.png", b"x", "image/png")}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 # ─── Contacts Contract Contribution (Unit) ─────────────────────────────────── @pytest.mark.asyncio class TestContactsDocumentContribution: async def test_document_placeholders_contact(self): from app.plugins.builtins.contacts.contracts import ContactsContract placeholders = ContactsContract.document_placeholders("contact") assert isinstance(placeholders, list) keys = [p["key"] for p in placeholders] assert "firstname" in keys and "email" in keys async def test_document_placeholders_unknown_entity_empty(self): from app.plugins.builtins.contacts.contracts import ContactsContract assert ContactsContract.document_placeholders("warehouse") == [] async def test_document_data_loads_entity(self, db_session): seed = await seed_tenant_and_users(db_session) from app.plugins.builtins.contacts.contracts import ContactsContract data = await ContactsContract.document_data( db_session, seed["tenant_a"].id, seed["company_a"].id, "company", ) assert data["name"] == "Company Alpha" async def test_document_data_unknown_entity_empty(self, db_session): seed = await seed_tenant_and_users(db_session) from app.plugins.builtins.contacts.contracts import ContactsContract data = await ContactsContract.document_data( db_session, seed["tenant_a"].id, uuid_mod.uuid4(), "company", ) assert data == {}