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
This commit is contained in:
@@ -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 == []
|
||||
Reference in New Issue
Block a user