From ccf8ad67fa86423278ddec9f4dff5ea6927b3889 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Fri, 11 Sep 2026 16:06:27 +0200 Subject: [PATCH] feat(tests): fixture sweep, anonymized real-invoice regression corpus, CI - test_fixture_sweep: extract+validate every fixture PDF on every run; known extractor/validator gaps are catalogued explicitly and the sweep fails on any deviation from that catalogue - tests/fixtures/real/: anonymized XML from real production invoices that revealed bugs (verified to contain no real-world data); first entry locks in the FC-tax-number/basis-quantity/spaced-VAT-ID regressions - CI workflow: uv sync --frozen from lockfile + pytest + docker build --- .github/workflows/ci.yml | 31 +++ tests/fixtures/MANIFEST.md | 11 + .../extended_fc_steuernummer_basisqty.xml | 245 ++++++++++++++++++ tests/test_fixture_sweep.py | 88 +++++++ tests/test_real_invoice_regressions.py | 84 ++++++ 5 files changed, 459 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/fixtures/real/extended_fc_steuernummer_basisqty.xml create mode 100644 tests/test_fixture_sweep.py create mode 100644 tests/test_real_invoice_regressions.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2408d6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies from lockfile + run: uv sync --extra dev --frozen + + - name: Run tests + run: uv run --extra dev pytest -q + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t zugferd-service:ci . diff --git a/tests/fixtures/MANIFEST.md b/tests/fixtures/MANIFEST.md index de7aa4d..0cbf1ad 100644 --- a/tests/fixtures/MANIFEST.md +++ b/tests/fixtures/MANIFEST.md @@ -18,6 +18,17 @@ This directory contains sample PDFs for testing ZUGFeRD extraction and validatio | ORDER-X_EX01_ORDER_FULL_DATA-COMFORT.pdf | ORDER-X | Order-X format (related to ZUGFeRD but for orders) | | EmptyPDFA1.pdf | None | Empty PDF/A-1 document - no ZUGFeRD data (negative test case) | +## Real-World Invoices (anonymized) - `real/` + +Anonymized XML from real production invoices that once revealed service bugs. +Each file must contain **no real names, addresses, tax numbers, bank details, +or document numbers** (verified at creation time). Never commit original +invoice PDFs here. + +| Filename | Profile | Origin bug | +|-----------|----------|------------| +| extended_fc_steuernummer_basisqty.xml | EXTENDED | FC-only tax registration ignored, unit price with basis quantity 100 misread, spaced VAT ID rejected, contact email dropped (2026-09) | + ## Profile Coverage - **MINIMUM**: Not covered (future addition) diff --git a/tests/fixtures/real/extended_fc_steuernummer_basisqty.xml b/tests/fixtures/real/extended_fc_steuernummer_basisqty.xml new file mode 100644 index 0000000..a8956a6 --- /dev/null +++ b/tests/fixtures/real/extended_fc_steuernummer_basisqty.xml @@ -0,0 +1,245 @@ + + + + + urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended + + + + 4711 + 380 + + 20260903 + + + Beispielwerkstatt Musterland e.V. +Werkstatt Musterstadt/Musterdorf +Musterstraße 11 +01234 Musterstadt +Deutschland +Steuernummer: 1112223334444 + REG + + + + + + 1 + + + ART-0001 + KUNDE-0001 + Befestigungsset Typ M100 + KUNDE-0001 + + + + BEST-000001 + + + 75.85 + 100 + + + + 140 + + + 20260804 + + + + LS-000001 + 1 + + 20260804 + + + + + + VAT + S + 7 + + + + true + + 0 + GK-Zuschlag + + + + false + + 0 + 71 + Mengenrabatt + + + + false + + 0 + Kundenrabatt + + + 106.19 + + + + + + 2 + + + ART-0001 + KUNDE-0001 + Befestigungsset Typ M100 + KUNDE-0001 + + + + BEST-000001 + + + 75.85 + 100 + + + + 50 + + + 20260804 + + + + LS-000001 + 2 + + 20260804 + + + + + + VAT + S + 7 + + + + true + + 0 + GK-Zuschlag + + + + false + + 0 + 71 + Mengenrabatt + + + + false + + 0 + Kundenrabatt + + + 37.93 + + + + + + 10001 + Beispielwerkstatt Musterland e.V. +Werkstatt Musterstadt/Musterdorf + + Erika Mustermann + + rechnung@beispielwerkstatt.example + + + + 01234 + Musterstraße 11 + Musterstadt + DE + + + 1112223334444 + + + + Beispiel Kauf GmbH + + 05432 + Musterweg 2 + Musterdorf + DE + + + DE 123 456 789 + + + + BEST-000001 + + + + + + DE + + + + + 20260903 + + + + + EUR + + 58 + + DE89370400440532013000 + Beispielwerkstatt Musterland e.V. +Werkstatt Musterstadt/Musterdorf + + + MARKDEF1100 + + + + 10.09 + VAT + 144.12 + S + 7 + + + zahlbar innerhalb 14 Tage ab Rechnungsdatum netto + + 20260917 + + + + 144.12 + 144.12 + 10.09 + 0 + 154.21 + 0 + 154.21 + + + + \ No newline at end of file diff --git a/tests/test_fixture_sweep.py b/tests/test_fixture_sweep.py new file mode 100644 index 0000000..9e020ab --- /dev/null +++ b/tests/test_fixture_sweep.py @@ -0,0 +1,88 @@ +"""Regression sweep over all invoice fixtures. + +Every fixture PDF is extracted and validated end-to-end on every test run. +Known limitations of extractor and validator are catalogued explicitly in +KNOWN_GAPS and VALIDATION_EXCLUDED below. The sweep fails when: + +- any fixture stops extracting (exception), +- a fixture that used to be clean produces new critical errors, +- a fixture with known gaps produces more/different errors than catalogued, +- a gap was fixed but its catalogue entry was not removed. + +So: fix a gap, then remove its entry — the sweep enforces honest bookkeeping. +""" + +from pathlib import Path + +import pytest + +from src.extractor import extract_zugferd +from src.models import ValidateRequest +from src.validator import validate_invoice + +FIXTURES = Path(__file__).parent / "fixtures" +ALL_CHECKS = ["pflichtfelder", "betraege", "ustid", "pdf_abgleich"] + +EXPECTED_NON_ZUGFERD = {"EmptyPDFA1.pdf"} + +VALIDATION_EXCLUDED = { + "ORDER-X_EX01_ORDER_FULL_DATA-COMFORT.pdf": ( + "ORDER-X order document, not an invoice" + ), + "zugferd_invoice.pdf": ( + "ZUGFeRD 1.0 uses a different XML namespace (not supported)" + ), +} + +KNOWN_GAPS: dict[str, set[str]] = { + "EN16931_1_Teilrechnung.pdf": { + "line_items[3].line_total", + "totals.net", + }, + "MustangBeispiel20221026.pdf": { + "totals.net", + }, + "validAvoir_FR_type380_BASICWL.pdf": { + "line_items", + "totals.net", + "vat_id", + }, + "zugferd_2p1_EXTENDED_PDFA-3A.pdf": { + "totals.net", + }, +} + + +def _pdf_files() -> list[str]: + return sorted(path.name for path in FIXTURES.glob("*.pdf")) + + +@pytest.mark.parametrize("filename", _pdf_files()) +def test_fixture_sweep(filename: str) -> None: + """Extract and validate one fixture; deviations from the catalogue fail.""" + pdf_bytes = (FIXTURES / filename).read_bytes() + result = extract_zugferd(pdf_bytes) + + if filename in EXPECTED_NON_ZUGFERD: + assert result.is_zugferd is False + return + + assert result.is_zugferd is True, f"{filename}: expected ZUGFeRD XML" + assert result.xml_data is not None + + if filename in VALIDATION_EXCLUDED: + return + + request = ValidateRequest( + xml_data=result.xml_data.model_dump(), + pdf_text=result.pdf_text, + checks=ALL_CHECKS, + ) + validation = validate_invoice(request) + critical_fields = {error.field for error in validation.errors} + expected_fields = KNOWN_GAPS.get(filename, set()) + + assert critical_fields == expected_fields, ( + f"{filename}: critical fields changed — update KNOWN_GAPS " + f"(new state: {sorted(critical_fields)})" + ) diff --git a/tests/test_real_invoice_regressions.py b/tests/test_real_invoice_regressions.py new file mode 100644 index 0000000..26a7a1d --- /dev/null +++ b/tests/test_real_invoice_regressions.py @@ -0,0 +1,84 @@ +"""Regression tests from real-world invoices (anonymized). + +Every test in this module originated from a production bug. When a new real +invoice reveals a service bug: anonymize the invoice XML into +tests/fixtures/real/, verify it contains no real-world data, and add a test +here that locks in the correct behavior. +""" + +from io import BytesIO +from pathlib import Path + +import pytest + +from pypdf import PdfWriter + +from src.extractor import extract_zugferd +from src.models import ValidateRequest +from src.validator import validate_invoice + +REAL_FIXTURES = Path(__file__).parent / "fixtures" / "real" +ALL_CHECKS = ["pflichtfelder", "betraege", "ustid", "pdf_abgleich"] + + +def _pdf_with_attachment(xml_bytes: bytes) -> bytes: + """Wrap invoice XML in a minimal PDF (no visual content).""" + writer = PdfWriter() + writer.add_blank_page(width=72, height=72) + writer.add_attachment("factur-x.xml", xml_bytes) + output = BytesIO() + writer.write(output) + return output.getvalue() + + +class TestFcTaxNumberBasisQuantityInvoice: + """Anonymized EXTENDED invoice: FC-only tax registration (no VAT ID), + unit price with basis quantity 100, spaced buyer VAT ID, seller email + in DefinedTradeContact. + + Regressions locked in (found 2026-09 on a real workshop invoice): + supplier tax_number never extracted, unit price off by factor 100, + spaced VAT ID rejected as invalid format, contact email dropped. + """ + + XML_PATH = REAL_FIXTURES / "extended_fc_steuernummer_basisqty.xml" + + def test_extraction(self) -> None: + pdf_bytes = _pdf_with_attachment(self.XML_PATH.read_bytes()) + result = extract_zugferd(pdf_bytes) + + assert result.is_zugferd is True + data = result.xml_data + + assert data.invoice_number == "4711" + assert data.supplier.tax_number == "1112223334444" + assert data.supplier.vat_id is None + assert data.supplier.email == "rechnung@beispielwerkstatt.example" + assert data.buyer.vat_id == "DE 123 456 789" + + assert len(data.line_items) == 2 + first, second = data.line_items + assert first.quantity == 140 + assert first.unit_price == pytest.approx(0.7585) + assert first.line_total == pytest.approx(106.19) + assert second.quantity == 50 + assert second.unit_price == pytest.approx(0.7585) + assert second.line_total == pytest.approx(37.93) + + assert data.totals.net == pytest.approx(144.12) + assert data.totals.vat_total == pytest.approx(10.09) + assert data.totals.gross == pytest.approx(154.21) + + def test_validation_all_checks_pass(self) -> None: + pdf_bytes = _pdf_with_attachment(self.XML_PATH.read_bytes()) + result = extract_zugferd(pdf_bytes) + + request = ValidateRequest( + xml_data=result.xml_data.model_dump(), + pdf_text=result.pdf_text, + checks=ALL_CHECKS, + ) + validation = validate_invoice(request) + + assert validation.is_valid is True + assert validation.errors == []