Files

760 lines
27 KiB
Python
Raw Permalink Normal View History

"""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 == {}