feat(tests): fixture sweep, anonymized real-invoice regression corpus, CI
CI / test (push) Canceled after 0s
CI / docker (push) Canceled after 0s

- 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:
m3ta-chiron
2026-09-11 16:06:27 +02:00
parent 0cf359d201
commit ccf8ad67fa
5 changed files with 459 additions and 0 deletions
+31
View File
@@ -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 .
+11
View File
@@ -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) | | 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) | | 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 ## Profile Coverage
- **MINIMUM**: Not covered (future addition) - **MINIMUM**: Not covered (future addition)
@@ -0,0 +1,245 @@
<?xml version="1.0"?>
<rsm:CrossIndustryInvoice xmlns:a="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>4711</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">20260903</udt:DateTimeString>
</ram:IssueDateTime>
<ram:IncludedNote>
<ram:Content>Beispielwerkstatt Musterland e.V.
Werkstatt Musterstadt/Musterdorf
Musterstraße 11
01234 Musterstadt
Deutschland
Steuernummer: 1112223334444</ram:Content>
<ram:SubjectCode>REG</ram:SubjectCode>
</ram:IncludedNote>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:SellerAssignedID>ART-0001</ram:SellerAssignedID>
<ram:BuyerAssignedID>KUNDE-0001</ram:BuyerAssignedID>
<ram:Name>Befestigungsset Typ M100</ram:Name>
<ram:Description>KUNDE-0001</ram:Description>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>BEST-000001</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>75.85</ram:ChargeAmount>
<ram:BasisQuantity unitCode="C62">100</ram:BasisQuantity>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">140</ram:BilledQuantity>
<ram:ActualDeliverySupplyChainEvent>
<ram:OccurrenceDateTime>
<udt:DateTimeString format="102">20260804</udt:DateTimeString>
</ram:OccurrenceDateTime>
</ram:ActualDeliverySupplyChainEvent>
<ram:DeliveryNoteReferencedDocument>
<ram:IssuerAssignedID>LS-000001</ram:IssuerAssignedID>
<ram:LineID>1</ram:LineID>
<ram:FormattedIssueDateTime>
<qdt:DateTimeString format="102">20260804</qdt:DateTimeString>
</ram:FormattedIssueDateTime>
</ram:DeliveryNoteReferencedDocument>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>7</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeAllowanceCharge>
<ram:ChargeIndicator>
<udt:Indicator>true</udt:Indicator>
</ram:ChargeIndicator>
<ram:ActualAmount>0</ram:ActualAmount>
<ram:Reason>GK-Zuschlag</ram:Reason>
</ram:SpecifiedTradeAllowanceCharge>
<ram:SpecifiedTradeAllowanceCharge>
<ram:ChargeIndicator>
<udt:Indicator>false</udt:Indicator>
</ram:ChargeIndicator>
<ram:ActualAmount>0</ram:ActualAmount>
<ram:ReasonCode>71</ram:ReasonCode>
<ram:Reason>Mengenrabatt</ram:Reason>
</ram:SpecifiedTradeAllowanceCharge>
<ram:SpecifiedTradeAllowanceCharge>
<ram:ChargeIndicator>
<udt:Indicator>false</udt:Indicator>
</ram:ChargeIndicator>
<ram:ActualAmount>0</ram:ActualAmount>
<ram:Reason>Kundenrabatt</ram:Reason>
</ram:SpecifiedTradeAllowanceCharge>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>106.19</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>2</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:SellerAssignedID>ART-0001</ram:SellerAssignedID>
<ram:BuyerAssignedID>KUNDE-0001</ram:BuyerAssignedID>
<ram:Name>Befestigungsset Typ M100</ram:Name>
<ram:Description>KUNDE-0001</ram:Description>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>BEST-000001</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>75.85</ram:ChargeAmount>
<ram:BasisQuantity unitCode="C62">100</ram:BasisQuantity>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">50</ram:BilledQuantity>
<ram:ActualDeliverySupplyChainEvent>
<ram:OccurrenceDateTime>
<udt:DateTimeString format="102">20260804</udt:DateTimeString>
</ram:OccurrenceDateTime>
</ram:ActualDeliverySupplyChainEvent>
<ram:DeliveryNoteReferencedDocument>
<ram:IssuerAssignedID>LS-000001</ram:IssuerAssignedID>
<ram:LineID>2</ram:LineID>
<ram:FormattedIssueDateTime>
<qdt:DateTimeString format="102">20260804</qdt:DateTimeString>
</ram:FormattedIssueDateTime>
</ram:DeliveryNoteReferencedDocument>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>7</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeAllowanceCharge>
<ram:ChargeIndicator>
<udt:Indicator>true</udt:Indicator>
</ram:ChargeIndicator>
<ram:ActualAmount>0</ram:ActualAmount>
<ram:Reason>GK-Zuschlag</ram:Reason>
</ram:SpecifiedTradeAllowanceCharge>
<ram:SpecifiedTradeAllowanceCharge>
<ram:ChargeIndicator>
<udt:Indicator>false</udt:Indicator>
</ram:ChargeIndicator>
<ram:ActualAmount>0</ram:ActualAmount>
<ram:ReasonCode>71</ram:ReasonCode>
<ram:Reason>Mengenrabatt</ram:Reason>
</ram:SpecifiedTradeAllowanceCharge>
<ram:SpecifiedTradeAllowanceCharge>
<ram:ChargeIndicator>
<udt:Indicator>false</udt:Indicator>
</ram:ChargeIndicator>
<ram:ActualAmount>0</ram:ActualAmount>
<ram:Reason>Kundenrabatt</ram:Reason>
</ram:SpecifiedTradeAllowanceCharge>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>37.93</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:ID>10001</ram:ID>
<ram:Name>Beispielwerkstatt Musterland e.V.
Werkstatt Musterstadt/Musterdorf</ram:Name>
<ram:DefinedTradeContact>
<ram:PersonName>Erika Mustermann</ram:PersonName>
<ram:EmailURIUniversalCommunication>
<ram:URIID>rechnung@beispielwerkstatt.example</ram:URIID>
</ram:EmailURIUniversalCommunication>
</ram:DefinedTradeContact>
<ram:PostalTradeAddress>
<ram:PostcodeCode>01234</ram:PostcodeCode>
<ram:LineOne>Musterstraße 11</ram:LineOne>
<ram:CityName>Musterstadt</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">1112223334444</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>Beispiel Kauf GmbH</ram:Name>
<ram:PostalTradeAddress>
<ram:PostcodeCode>05432</ram:PostcodeCode>
<ram:LineOne>Musterweg 2</ram:LineOne>
<ram:CityName>Musterdorf</ram:CityName>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">DE 123 456 789</ram:ID>
</ram:SpecifiedTaxRegistration>
</ram:BuyerTradeParty>
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>BEST-000001</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery>
<ram:ShipToTradeParty>
<ram:PostalTradeAddress>
<ram:CountryID>DE</ram:CountryID>
</ram:PostalTradeAddress>
</ram:ShipToTradeParty>
<ram:ActualDeliverySupplyChainEvent>
<ram:OccurrenceDateTime>
<udt:DateTimeString format="102">20260903</udt:DateTimeString>
</ram:OccurrenceDateTime>
</ram:ActualDeliverySupplyChainEvent>
</ram:ApplicableHeaderTradeDelivery>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>58</ram:TypeCode>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>DE89370400440532013000</ram:IBANID>
<ram:AccountName>Beispielwerkstatt Musterland e.V.
Werkstatt Musterstadt/Musterdorf</ram:AccountName>
</ram:PayeePartyCreditorFinancialAccount>
<ram:PayeeSpecifiedCreditorFinancialInstitution>
<ram:BICID>MARKDEF1100</ram:BICID>
</ram:PayeeSpecifiedCreditorFinancialInstitution>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>10.09</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>144.12</ram:BasisAmount>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>7</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradePaymentTerms>
<ram:Description>zahlbar innerhalb 14 Tage ab Rechnungsdatum netto</ram:Description>
<ram:DueDateDateTime>
<udt:DateTimeString format="102">20260917</udt:DateTimeString>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>144.12</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount>144.12</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="EUR">10.09</ram:TaxTotalAmount>
<ram:RoundingAmount>0</ram:RoundingAmount>
<ram:GrandTotalAmount>154.21</ram:GrandTotalAmount>
<ram:TotalPrepaidAmount>0</ram:TotalPrepaidAmount>
<ram:DuePayableAmount>154.21</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
+88
View File
@@ -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)})"
)
+84
View File
@@ -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 == []