feat(L1-L3): Dokumente-Generator — Briefpapier+Block-System+Drag&Drop-Editor+Renderer
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- Briefpapier (letterheads): Seiten-Setup (A4/A5/Letter, Ränder), Header/Footer-Blöcke, Wasserzeichen, Logo-Upload (DocumentAsset, data:-URI-only) - Druckvorlagen (print_templates): Block-Komposition mit Briefpapier-Ref + entity_type - Block-Registry (document_blocks.py): text/image/shape(line/rect/circle)/table/spacer/divider/placeholder/pagebreak + Modul-Beiträge via document_blocks()-Contract - Renderer (document_renderer.py): Blocks→HTML→PDF via WeasyPrint (SSRF-Sandbox data:-URI-only), @page-Frame mit running header/footer, Placeholder-Beispiel-Defaults gegen StrictUndefined - Contract-Beitrag contacts: document_placeholders/document_data (#359-Muster wie importexport_entities) - 13 neue Endpoints in documents.py: Letterhead-CRUD, Template-CRUD, Assets, document-blocks, document-placeholders, preview (HTML), render (PDF) - Migration: Plugin-SQL 0003 (idempotent) + Alembic 0143 (Dual-Path, RLS fail-closed crm_api) - Frontend: api/documents.ts, Settings→Dokumente (settings_pages), BlockEditor (@dnd-kit Palette/Canvas/Config/Live-Preview-iframe), LetterheadEditor, PrintTemplateEditor, DocumentGenerationDialog (global, ContactDetailPage-Integration) - i18n de/en, api-documentation.md, plugin-development-guide.md, PROGRESS.md Verifikation: 32/32 neue Tests + 9/9 Regressionen, tsc exit 0, Build OK 2.79s, Alembic-Fresh-DB 0143 mit RLS bewiesen, ruff clean
This commit is contained in:
@@ -0,0 +1,759 @@
|
||||
"""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 == {}
|
||||
Reference in New Issue
Block a user