- 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
85 lines
3.0 KiB
Python
85 lines
3.0 KiB
Python
"""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 == []
|