"""E-Invoice (EN16931 / XRechnung CII) format layer + AI block suggestions — Phase L4/L5. L5 (Format-Layer): inline Rechnungsdaten → valides CII-XML. Das Verkaufsmodul kommt später und dockt über den Contract-Hook ``einvoice_data()`` an (POST /einvoice/render-for). L4: KI-Vorschlag für Block-Kompositionen. llm_complete wird in allen Tests gemockt — niemals echte LLM-Calls (Budget, Deterministik). """ from __future__ import annotations import json import uuid as uuid_mod from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch import xml.etree.ElementTree as ET 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()) 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 _invoice_payload(**overrides) -> dict: """Valid EN16931 invoice payload (2 × 100.00 net, 19% VAT).""" payload = { "invoice_number": "RE-2026-001", "issue_date": "2026-08-29", "type_code": "380", "currency": "EUR", "due_date": "2026-09-28", "delivery_date": "2026-08-29", "buyer_name": "Käufer AG", "buyer_reference": "PO-2026-42", "buyer_address": { "street": "Käuferstr. 1", "postal_code": "20095", "city": "Hamburg", "country": "DE", }, "seller_name": "Verkäufer GmbH", "seller_vat_id": "DE123456789", "seller_address": { "street": "Verkäuferweg 2", "postal_code": "10115", "city": "Berlin", "country": "DE", }, "payment_means_code": "58", "payment_terms_text": "Zahlbar innerhalb von 30 Tagen", "note": "Vielen Dank für Ihren Auftrag", "line_items": [ { "name": "Beratung", "quantity": 2, "unit": "HUR", "unit_net_price": 100.00, "vat_rate": 19, }, ], "profile": "en16931", } payload.update(overrides) return payload # ─── Unit: validation ──────────────────────────────────────────────────────── class TestEInvoiceUnitValidation: def test_validate_ok(self): from app.plugins.builtins.report_generator.einvoice import validate_einvoice_data validate_einvoice_data(_invoice_payload()) # no raise def test_missing_seller_tax_ids_raises_with_bt(self): from app.plugins.builtins.report_generator.einvoice import ( EInvoiceValidationError, validate_einvoice_data, ) payload = _invoice_payload() del payload["seller_vat_id"] with pytest.raises(EInvoiceValidationError) as exc_info: validate_einvoice_data(payload) assert any("BT-31" in m or "BT-32" in m for m in exc_info.value.missing) def test_missing_buyer_name_raises(self): from app.plugins.builtins.report_generator.einvoice import ( EInvoiceValidationError, validate_einvoice_data, ) payload = _invoice_payload(buyer_name="") with pytest.raises(EInvoiceValidationError): validate_einvoice_data(payload) def test_missing_lines_raises(self): from app.plugins.builtins.report_generator.einvoice import ( EInvoiceValidationError, validate_einvoice_data, ) with pytest.raises(EInvoiceValidationError): validate_einvoice_data(_invoice_payload(line_items=[])) def test_bad_issue_date_raises(self): from app.plugins.builtins.report_generator.einvoice import ( EInvoiceValidationError, validate_einvoice_data, ) with pytest.raises(EInvoiceValidationError): validate_einvoice_data(_invoice_payload(issue_date="29.08.2026")) def test_line_item_requires_name(self): from app.plugins.builtins.report_generator.einvoice import ( EInvoiceValidationError, validate_einvoice_data, ) payload = _invoice_payload( line_items=[{"name": "", "quantity": 1, "unit_net_price": 10, "vat_rate": 19}] ) with pytest.raises(EInvoiceValidationError): validate_einvoice_data(payload) # ─── Unit: XML rendering ───────────────────────────────────────────────────── class TestEInvoiceUnitRender: def test_render_xml_structure(self): from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml xml = render_einvoice_xml(_invoice_payload()) # well-formed XML with correct root root = ET.fromstring(xml) assert root.tag.endswith("CrossIndustryInvoice") assert "urn:cen.eu:en16931:2017" in xml # EN16931 guideline assert "RE-2026-001" in xml # BT-1 assert "20260829" in xml # BT-2 in format 102 assert "380" in xml # BT-3 assert "Verkäufer GmbH" in xml # BT-11 assert "Käufer AG" in xml # BT-10 assert "DE123456789" in xml # BT-31 assert 'schemeID="VA"' in xml # VAT registration scheme # computed sums: 200.00 net + 38.00 VAT = 238.00 grand assert "200.00" in xml assert "38.00" in xml assert "238.00" in xml assert "EUR" in xml # BT-5 def test_render_xrechnung_profile(self): from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml xml = render_einvoice_xml(_invoice_payload(profile="xrechnung")) assert "urn:xoev-de:kosit:standard:xrechnung_3.0" in xml assert "urn:cen.eu:en16931:2017" in xml # compliant base def test_render_escapes_xml_special_chars(self): from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml payload = _invoice_payload(seller_name="A&B ") xml = render_einvoice_xml(payload) assert "A&B <GmbH>" in xml # raw unescaped angle bracket must not appear in the name element assert "" not in xml def test_render_line_net_amount_supplied_used(self): from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml payload = _invoice_payload( line_items=[ { "name": "Pauschale", "quantity": 1, "unit_net_price": 99.99, "vat_rate": 7, "line_net_amount": 90.00, } ] ) xml = render_einvoice_xml(payload) assert "90.00" in xml # supplied amount wins over qty*price # tax: 90 * 7% = 6.30, grand 96.30 assert "6.30" in xml assert "96.30" in xml def test_render_delivery_date_defaults_to_issue_date(self): from app.plugins.builtins.report_generator.einvoice import render_einvoice_xml payload = _invoice_payload() del payload["delivery_date"] xml = render_einvoice_xml(payload) assert "20260829" in xml # ─── Unit: contract resolution (sales module docking point) ───────────────── class TestEInvoiceContractResolution: async def test_resolve_via_contract_hook(self, db_session): """The future sales module provides einvoice_data() — the resolver picks it up through the contract registry.""" seed = await seed_tenant_and_users(db_session) from app.plugins.builtins.report_generator import einvoice invoice_data = _invoice_payload() fake_contract = MagicMock() fake_contract.einvoice_data = AsyncMock(return_value=invoice_data) fake_plugin_registry = MagicMock() fake_plugin_registry.list_discovered.return_value = ["sales"] fake_contract_registry = MagicMock() fake_contract_registry.get_contract.return_value = fake_contract with ( patch("app.plugins.registry.get_registry", return_value=fake_plugin_registry), patch( "app.plugins.builtins.contracts.get_contract_registry", return_value=fake_contract_registry, ), ): data = await einvoice.resolve_einvoice_data( db_session, seed["tenant_a"].id, "invoice", uuid_mod.uuid4(), ) assert data is not None assert data["invoice_number"] == "RE-2026-001" async def test_resolve_without_hook_returns_none(self, db_session): """No module contributes einvoice_data → None (→ 404 for caller).""" seed = await seed_tenant_and_users(db_session) from app.plugins.builtins.report_generator import einvoice class NoHookContract: contract_name = "legacy" fake_plugin_registry = MagicMock() fake_plugin_registry.list_discovered.return_value = ["legacy"] fake_contract_registry = MagicMock() fake_contract_registry.get_contract.return_value = NoHookContract() with ( patch("app.plugins.registry.get_registry", return_value=fake_plugin_registry), patch( "app.plugins.builtins.contracts.get_contract_registry", return_value=fake_contract_registry, ), ): data = await einvoice.resolve_einvoice_data( db_session, seed["tenant_a"].id, "invoice", uuid_mod.uuid4(), ) assert data is None # ─── API: e-invoice endpoints ──────────────────────────────────────────────── @pytest.mark.asyncio class TestEInvoiceAPI: async def test_render_returns_xml_200( 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}/einvoice/render", json=_invoice_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"{resp.status_code} {resp.text}" assert resp.headers["content-type"].startswith("application/xml") assert b"CrossIndustryInvoice" in resp.content assert b"RE-2026-001" in resp.content assert "attachment" in resp.headers.get("content-disposition", "") async def test_render_missing_fields_422( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") payload = _invoice_payload() del payload["seller_vat_id"] del payload["buyer_name"] resp = await docs_client.post( f"{DOC_BASE}/einvoice/render", json=payload, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 body = resp.json() assert body["detail"]["code"] == "invalid_einvoice" missing = json.dumps(body["detail"].get("missing", [])) assert "BT-10" in missing # buyer_name assert "BT-31" in missing # vat/tax requirement async def test_validate_endpoint_ok( 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}/einvoice/validate", json=_invoice_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["valid"] is True async def test_validate_endpoint_reports_missing( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") payload = _invoice_payload() del payload["seller_vat_id"] resp = await docs_client.post( f"{DOC_BASE}/einvoice/validate", json=payload, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 assert resp.json()["detail"]["code"] == "invalid_einvoice" async def test_render_for_without_sales_module_404( self, docs_client: AsyncClient, db_session ): """render-for needs a module einvoice_data() — none exists yet → 404.""" seed = await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") resp = await docs_client.post( f"{DOC_BASE}/einvoice/render-for", json={ "entity_type": "invoice", "entity_id": str(seed["company_a"].id), }, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 assert resp.json()["detail"]["code"] == "no_data_source" async def test_viewer_cannot_render_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}/einvoice/render", json=_invoice_payload(), headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # ─── API: AI block suggestion (L4) ──────────────────────────────────────────── _SUGGEST_VALID_BLOCKS = { "blocks": [ { "id": "b1", "type": "text", "config": { "content": "Sehr geehrte Damen und Herren,\n\nhiermit offerieren wir {{name}} folgende Leistungen:" }, }, { "id": "b2", "type": "shape", "config": {"shape": "line", "width": "100%", "height": 2, "color": "#111827"}, }, ], "notes": "Anrede mit Firmenname + Trennlinie", } @pytest.mark.asyncio class TestDocumentSuggest: async def test_suggest_returns_validated_blocks( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") with patch( "app.plugins.builtins.report_generator.documents.llm_complete", AsyncMock(return_value={"content": json.dumps(_SUGGEST_VALID_BLOCKS)}), ) as mock_llm: resp = await docs_client.post( f"{DOC_BASE}/documents/suggest", json={ "prompt": "Erstelle eine Angebotsvorlage mit Anrede und Trennlinie", "entity_type": "company", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"{resp.status_code} {resp.text}" body = resp.json() assert len(body["blocks"]) == 2 assert body["blocks"][0]["type"] == "text" assert "{{name}}" in body["blocks"][0]["config"]["content"] assert body["blocks"][1]["type"] == "shape" assert body["notes"] != "" # LLM call wired through the central client (cost tracking params) assert mock_llm.await_count == 1 kwargs = mock_llm.await_args.kwargs assert kwargs["model"] == "gpt-4o-mini" assert kwargs["messages"][0]["role"] == "system" async def test_suggest_filters_invalid_blocks( self, docs_client: AsyncClient, db_session ): """Invalid AI blocks (unknown type, empty text) are filtered, valid ones survive even without an id (id gets assigned server-side).""" await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") ai_response = { "blocks": [ {"type": "text", "config": {"content": "gültiger Block"}}, {"type": "no_such_block_type", "config": {}}, {"type": "text", "config": {}}, # empty content → invalid ], "notes": "", } with patch( "app.plugins.builtins.report_generator.documents.llm_complete", AsyncMock(return_value={"content": json.dumps(ai_response)}), ): resp = await docs_client.post( f"{DOC_BASE}/documents/suggest", json={"prompt": "Angebot mit Betreffzeile"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 body = resp.json() assert len(body["blocks"]) == 1 assert body["blocks"][0]["type"] == "text" assert body["blocks"][0]["id"] # server-assigned id async def test_suggest_handles_code_fenced_json( self, docs_client: AsyncClient, db_session ): """LLMs love ```json fences — the endpoint must strip them.""" await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") fenced = "```json\n" + json.dumps(_SUGGEST_VALID_BLOCKS) + "\n```" with patch( "app.plugins.builtins.report_generator.documents.llm_complete", AsyncMock(return_value={"content": fenced}), ): resp = await docs_client.post( f"{DOC_BASE}/documents/suggest", json={"prompt": "Angebot erstellen"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert len(resp.json()["blocks"]) == 2 async def test_suggest_llm_failure_502( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") with patch( "app.plugins.builtins.report_generator.documents.llm_complete", AsyncMock(side_effect=RuntimeError("provider down")), ): resp = await docs_client.post( f"{DOC_BASE}/documents/suggest", json={"prompt": "Angebot erstellen"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 502 assert resp.json()["detail"]["code"] == "ai_unavailable" async def test_suggest_non_json_response_502( self, docs_client: AsyncClient, db_session ): await seed_tenant_and_users(db_session) await login_client(docs_client, "admin@tenanta.com") with patch( "app.plugins.builtins.report_generator.documents.llm_complete", AsyncMock(return_value={"content": "Tut mir leid, ich verstehe nicht."}), ): resp = await docs_client.post( f"{DOC_BASE}/documents/suggest", json={"prompt": "Angebot erstellen"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 502 assert resp.json()["detail"]["code"] == "invalid_ai_response" async def test_suggest_viewer_forbidden_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}/documents/suggest", json={"prompt": "Angebot erstellen"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403