test(fixtures): add ZUGFeRD sample PDFs and feat(models): add Pydantic models
- Download 11 official ZUGFeRD sample PDFs - Cover profiles: BASIC, BASIC WL, EN16931, EXTENDED, XRechnung - Add non-ZUGFeRD PDF for negative testing - Create MANIFEST.md documenting all samples - Implement all Pydantic models from spec - Add 28 TDD tests for models - All tests pass
This commit is contained in:
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
# ZUGFeRD Test Fixture Manifest
|
||||
|
||||
This directory contains sample PDFs for testing ZUGFeRD extraction and validation.
|
||||
|
||||
## Files
|
||||
|
||||
| Filename | Profile | Description |
|
||||
|-----------|----------|-------------|
|
||||
| EN16931_1_Teilrechnung.pdf | EN16931 | Official FeRD test invoice - partial invoice (Teilrechnung) with full UN/CEFACT data |
|
||||
| EN16931_Einfach.pdf | EN16931 | Official FeRD test invoice - simple invoice (Einfach) with UN/CEFACT data |
|
||||
| attributeBasedXMP_zugferd_2p0_EN16931_Einfach.pdf | EN16931 | ZUGFeRD 2.0 EN16931 profile using attribute-based XMP metadata |
|
||||
| zugferd_invoice.pdf | ZUGFeRD 1.0 | Basic ZUGFeRD v1.0 invoice (likely BASIC or COMFORT profile) |
|
||||
| validAvoir_FR_type380_BASICWL.pdf | BASIC WL | French credit note (avoir) with BASIC WL profile |
|
||||
| zugferd_2p1_EXTENDED_PDFA-3A.pdf | EXTENDED | ZUGFeRD 2.1 EXTENDED profile with PDF/A-3A conformance |
|
||||
| validXRechnung.pdf | XRechnung | German XRechnung format (similar to EN16931 but German profile) |
|
||||
| ZTESTZUGFERD_1_INVDSS_012015738820PDF-1.pdf | ZUGFeRD 1.0 | Historical ZUGFeRD v1.0 test invoice from 2015 |
|
||||
| MustangBeispiel20221026.pdf | EN16931 | Modern sample from Mustang project (October 2022) |
|
||||
| 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) |
|
||||
|
||||
## Profile Coverage
|
||||
|
||||
- **MINIMUM**: Not covered (future addition)
|
||||
- **BASIC**: Covered by `zugferd_invoice.pdf`
|
||||
- **BASIC WL**: Covered by `validAvoir_FR_type380_BASICWL.pdf`
|
||||
- **EN16931**: Covered by multiple samples
|
||||
- **EXTENDED**: Covered by `zugferd_2p1_EXTENDED_PDFA-3A.pdf`
|
||||
- **None (negative test)**: Covered by `EmptyPDFA1.pdf`
|
||||
|
||||
## Version Coverage
|
||||
|
||||
- ZUGFeRD 1.0: `ZTESTZUGFERD_1_INVDSS_012015738820PDF-1.pdf`, `zugferd_invoice.pdf`
|
||||
- ZUGFeRD 2.0: `attributeBasedXMP_zugferd_2p0_EN16931_Einfach.pdf`
|
||||
- ZUGFeRD 2.1: `zugferd_2p1_EXTENDED_PDFA-3A.pdf`
|
||||
- XRechnung: `validXRechnung.pdf`
|
||||
|
||||
## Source URLs
|
||||
|
||||
- ZUGFeRD Mustang project: https://github.com/ZUGFeRD/mustangproject
|
||||
- Library test resources: `library/src/test/resources/`
|
||||
- Validator test resources: `validator/src/test/resources/`
|
||||
- CLI test resources: `Mustang-CLI/src/test/resources/`
|
||||
- FeRD test invoices: https://www.ferd-net.de/download/testrechnungen (URL was 404 - samples obtained from Mustang project)
|
||||
- factur-x library tests: https://github.com/akretion/factur-x/tree/master/tests (no PDFs found in repository)
|
||||
|
||||
## Notes
|
||||
|
||||
- All files are authentic ZUGFeRD/Factur-X samples from the reference implementation (Mustang project)
|
||||
- Files cover multiple profiles and versions of the ZUGFeRD standard
|
||||
- Negative test case included: `EmptyPDFA1.pdf` is a valid PDF/A-1 but contains no ZUGFeRD XML data
|
||||
- ORDER-X sample included for completeness, though it's a different but related format
|
||||
- File sizes range from 38KB to 684KB
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,512 @@
|
||||
"""Tests for Pydantic models."""
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests will fail initially, then pass after models are implemented
|
||||
|
||||
|
||||
class TestExtractionMeta:
|
||||
"""Test ExtractionMeta model."""
|
||||
|
||||
def test_minimal_extraction_meta(self):
|
||||
"""Test ExtractionMeta with minimal required fields."""
|
||||
from src.models import ExtractionMeta
|
||||
|
||||
meta = ExtractionMeta(pages=1, extraction_time_ms=234)
|
||||
assert meta.pages == 1
|
||||
assert meta.extraction_time_ms == 234
|
||||
assert meta.xml_attachment_name is None
|
||||
|
||||
def test_full_extraction_meta(self):
|
||||
"""Test ExtractionMeta with all fields."""
|
||||
from src.models import ExtractionMeta
|
||||
|
||||
meta = ExtractionMeta(
|
||||
pages=2, xml_attachment_name="factur-x.xml", extraction_time_ms=456
|
||||
)
|
||||
assert meta.pages == 2
|
||||
assert meta.xml_attachment_name == "factur-x.xml"
|
||||
assert meta.extraction_time_ms == 456
|
||||
|
||||
|
||||
class TestSupplier:
|
||||
"""Test Supplier model."""
|
||||
|
||||
def test_minimal_supplier(self):
|
||||
"""Test Supplier with minimal required fields."""
|
||||
from src.models import Supplier
|
||||
|
||||
supplier = Supplier(name="ACME GmbH")
|
||||
assert supplier.name == "ACME GmbH"
|
||||
assert supplier.street is None
|
||||
assert supplier.vat_id is None
|
||||
|
||||
def test_full_supplier(self):
|
||||
"""Test Supplier with all fields."""
|
||||
from src.models import Supplier
|
||||
|
||||
supplier = Supplier(
|
||||
name="ACME GmbH",
|
||||
street="Musterstraße 42",
|
||||
postal_code="12345",
|
||||
city="Musterstadt",
|
||||
country="DE",
|
||||
vat_id="DE123456789",
|
||||
email="info@acme.de",
|
||||
)
|
||||
assert supplier.name == "ACME GmbH"
|
||||
assert supplier.street == "Musterstraße 42"
|
||||
assert supplier.postal_code == "12345"
|
||||
assert supplier.city == "Musterstadt"
|
||||
assert supplier.country == "DE"
|
||||
assert supplier.vat_id == "DE123456789"
|
||||
assert supplier.email == "info@acme.de"
|
||||
|
||||
|
||||
class TestBuyer:
|
||||
"""Test Buyer model."""
|
||||
|
||||
def test_minimal_buyer(self):
|
||||
"""Test Buyer with minimal required fields."""
|
||||
from src.models import Buyer
|
||||
|
||||
buyer = Buyer(name="Customer AG")
|
||||
assert buyer.name == "Customer AG"
|
||||
assert buyer.street is None
|
||||
|
||||
def test_full_buyer(self):
|
||||
"""Test Buyer with all fields."""
|
||||
from src.models import Buyer
|
||||
|
||||
buyer = Buyer(
|
||||
name="Customer AG",
|
||||
street="Kundenweg 7",
|
||||
postal_code="54321",
|
||||
city="Kundenstadt",
|
||||
country="DE",
|
||||
vat_id="DE987654321",
|
||||
)
|
||||
assert buyer.name == "Customer AG"
|
||||
assert buyer.street == "Kundenweg 7"
|
||||
assert buyer.vat_id == "DE987654321"
|
||||
|
||||
|
||||
class TestVatBreakdown:
|
||||
"""Test VatBreakdown model."""
|
||||
|
||||
def test_vat_breakdown(self):
|
||||
"""Test VatBreakdown model."""
|
||||
from src.models import VatBreakdown
|
||||
|
||||
vat = VatBreakdown(rate=19.0, base=99.90, amount=18.98)
|
||||
assert vat.rate == 19.0
|
||||
assert vat.base == 99.90
|
||||
assert vat.amount == 18.98
|
||||
|
||||
|
||||
class TestPaymentTerms:
|
||||
"""Test PaymentTerms model."""
|
||||
|
||||
def test_minimal_payment_terms(self):
|
||||
"""Test PaymentTerms with minimal fields."""
|
||||
from src.models import PaymentTerms
|
||||
|
||||
terms = PaymentTerms()
|
||||
assert terms.iban is None
|
||||
assert terms.bic is None
|
||||
assert terms.account_holder is None
|
||||
|
||||
def test_full_payment_terms(self):
|
||||
"""Test PaymentTerms with all fields."""
|
||||
from src.models import PaymentTerms
|
||||
|
||||
terms = PaymentTerms(
|
||||
iban="DE89370400440532013000", bic="DEUTDEFF", account_holder="ACME GmbH"
|
||||
)
|
||||
assert terms.iban == "DE89370400440532013000"
|
||||
assert terms.bic == "DEUTDEFF"
|
||||
assert terms.account_holder == "ACME GmbH"
|
||||
|
||||
|
||||
class TestTotals:
|
||||
"""Test Totals model."""
|
||||
|
||||
def test_totals_minimal(self):
|
||||
"""Test Totals with required fields only."""
|
||||
from src.models import Totals
|
||||
|
||||
totals = Totals(line_total_sum=99.90, net=99.90, vat_total=18.98, gross=118.88)
|
||||
assert totals.line_total_sum == 99.90
|
||||
assert totals.net == 99.90
|
||||
assert totals.vat_total == 18.98
|
||||
assert totals.gross == 118.88
|
||||
assert totals.vat_breakdown == []
|
||||
|
||||
def test_totals_with_vat_breakdown(self):
|
||||
"""Test Totals with VAT breakdown."""
|
||||
from src.models import Totals, VatBreakdown
|
||||
|
||||
totals = Totals(
|
||||
line_total_sum=99.90,
|
||||
net=99.90,
|
||||
vat_total=18.98,
|
||||
gross=118.88,
|
||||
vat_breakdown=[VatBreakdown(rate=19.0, base=99.90, amount=18.98)],
|
||||
)
|
||||
assert len(totals.vat_breakdown) == 1
|
||||
assert totals.vat_breakdown[0].rate == 19.0
|
||||
|
||||
|
||||
class TestLineItem:
|
||||
"""Test LineItem model."""
|
||||
|
||||
def test_minimal_line_item(self):
|
||||
"""Test LineItem with minimal required fields."""
|
||||
from src.models import LineItem
|
||||
|
||||
item = LineItem(
|
||||
position=1,
|
||||
description="Widget",
|
||||
quantity=10.0,
|
||||
unit="Stück",
|
||||
unit_price=9.99,
|
||||
line_total=99.90,
|
||||
)
|
||||
assert item.position == 1
|
||||
assert item.description == "Widget"
|
||||
assert item.quantity == 10.0
|
||||
assert item.unit == "Stück"
|
||||
assert item.unit_price == 9.99
|
||||
assert item.line_total == 99.90
|
||||
assert item.article_number is None
|
||||
assert item.vat_rate is None
|
||||
|
||||
def test_full_line_item(self):
|
||||
"""Test LineItem with all fields."""
|
||||
from src.models import LineItem
|
||||
|
||||
item = LineItem(
|
||||
position=1,
|
||||
article_number="ART-001",
|
||||
article_number_buyer="KUN-001",
|
||||
description="Premium Widget",
|
||||
quantity=5.0,
|
||||
unit="Stück",
|
||||
unit_price=19.99,
|
||||
line_total=99.95,
|
||||
vat_rate=19.0,
|
||||
vat_amount=18.99,
|
||||
)
|
||||
assert item.article_number == "ART-001"
|
||||
assert item.article_number_buyer == "KUN-001"
|
||||
assert item.vat_rate == 19.0
|
||||
assert item.vat_amount == 18.99
|
||||
|
||||
|
||||
class TestXmlData:
|
||||
"""Test XmlData model."""
|
||||
|
||||
def test_minimal_xml_data(self):
|
||||
"""Test XmlData with minimal required fields."""
|
||||
from src.models import XmlData, Supplier, Buyer, Totals
|
||||
|
||||
data = XmlData(
|
||||
invoice_number="RE-2025-001234",
|
||||
invoice_date="2025-02-04",
|
||||
supplier={"name": "ACME GmbH"},
|
||||
buyer={"name": "Customer AG"},
|
||||
line_items=[],
|
||||
totals={"line_total_sum": 0.0, "net": 0.0, "vat_total": 0.0, "gross": 0.0},
|
||||
)
|
||||
assert data.invoice_number == "RE-2025-001234"
|
||||
assert data.invoice_date == "2025-02-04"
|
||||
assert data.due_date is None
|
||||
assert data.notes is None
|
||||
|
||||
def test_full_xml_data(self):
|
||||
"""Test XmlData with all fields."""
|
||||
from src.models import XmlData, Supplier, Buyer, LineItem, Totals, VatBreakdown
|
||||
|
||||
data = XmlData(
|
||||
invoice_number="RE-2025-001234",
|
||||
invoice_date="2025-02-04",
|
||||
due_date="2025-03-04",
|
||||
supplier=Supplier(name="ACME GmbH", vat_id="DE123456789"),
|
||||
buyer=Buyer(name="Customer AG", vat_id="DE987654321"),
|
||||
line_items=[
|
||||
LineItem(
|
||||
position=1,
|
||||
description="Widget",
|
||||
quantity=10.0,
|
||||
unit="Stück",
|
||||
unit_price=9.99,
|
||||
line_total=99.90,
|
||||
)
|
||||
],
|
||||
totals=Totals(
|
||||
line_total_sum=99.90,
|
||||
net=99.90,
|
||||
vat_breakdown=[VatBreakdown(rate=19.0, base=99.90, amount=18.98)],
|
||||
vat_total=18.98,
|
||||
gross=118.88,
|
||||
),
|
||||
currency="EUR",
|
||||
notes="Payment due within 30 days",
|
||||
)
|
||||
assert data.invoice_number == "RE-2025-001234"
|
||||
assert data.due_date == "2025-03-04"
|
||||
assert data.currency == "EUR"
|
||||
assert data.notes == "Payment due within 30 days"
|
||||
assert len(data.line_items) == 1
|
||||
|
||||
|
||||
class TestExtractResponse:
|
||||
"""Test ExtractResponse model."""
|
||||
|
||||
def test_extract_response_zugferd(self):
|
||||
"""Test ExtractResponse with ZUGFeRD data."""
|
||||
from src.models import (
|
||||
ExtractResponse,
|
||||
XmlData,
|
||||
ExtractionMeta,
|
||||
Supplier,
|
||||
Buyer,
|
||||
LineItem,
|
||||
Totals,
|
||||
VatBreakdown,
|
||||
)
|
||||
|
||||
response = ExtractResponse(
|
||||
is_zugferd=True,
|
||||
zugferd_profil="EN16931",
|
||||
xml_raw="<?xml version='1.0'?>...",
|
||||
xml_data=XmlData(
|
||||
invoice_number="RE-2025-001234",
|
||||
invoice_date="2025-02-04",
|
||||
supplier=Supplier(name="ACME GmbH"),
|
||||
buyer=Buyer(name="Customer AG"),
|
||||
line_items=[
|
||||
LineItem(
|
||||
position=1,
|
||||
description="Widget",
|
||||
quantity=10.0,
|
||||
unit="Stück",
|
||||
unit_price=9.99,
|
||||
line_total=99.90,
|
||||
)
|
||||
],
|
||||
totals=Totals(
|
||||
line_total_sum=99.90,
|
||||
net=99.90,
|
||||
vat_breakdown=[VatBreakdown(rate=19.0, base=99.90, amount=18.98)],
|
||||
vat_total=18.98,
|
||||
gross=118.88,
|
||||
),
|
||||
),
|
||||
pdf_text="Rechnung\n...",
|
||||
extraction_meta=ExtractionMeta(
|
||||
pages=1, xml_attachment_name="factur-x.xml", extraction_time_ms=234
|
||||
),
|
||||
)
|
||||
assert response.is_zugferd is True
|
||||
assert response.zugferd_profil == "EN16931"
|
||||
assert response.xml_raw is not None
|
||||
assert response.xml_data is not None
|
||||
assert response.pdf_text is not None
|
||||
assert response.extraction_meta.pages == 1
|
||||
|
||||
def test_extract_response_non_zugferd(self):
|
||||
"""Test ExtractResponse for non-ZUGFeRD PDF."""
|
||||
from src.models import ExtractResponse, ExtractionMeta
|
||||
|
||||
response = ExtractResponse(
|
||||
is_zugferd=False,
|
||||
pdf_text="Invoice text from PDF...",
|
||||
extraction_meta=ExtractionMeta(pages=1, extraction_time_ms=50),
|
||||
)
|
||||
assert response.is_zugferd is False
|
||||
assert response.zugferd_profil is None
|
||||
assert response.xml_raw is None
|
||||
assert response.xml_data is None
|
||||
assert response.pdf_text is not None
|
||||
|
||||
|
||||
class TestErrorDetail:
|
||||
"""Test ErrorDetail model."""
|
||||
|
||||
def test_error_detail_critical(self):
|
||||
"""Test ErrorDetail with critical severity."""
|
||||
from src.models import ErrorDetail
|
||||
|
||||
error = ErrorDetail(
|
||||
check="pflichtfelder",
|
||||
field="invoice_number",
|
||||
error_code="missing_required_field",
|
||||
message="Invoice number is required",
|
||||
severity="critical",
|
||||
)
|
||||
assert error.check == "pflichtfelder"
|
||||
assert error.field == "invoice_number"
|
||||
assert error.error_code == "missing_required_field"
|
||||
assert error.message == "Invoice number is required"
|
||||
assert error.severity == "critical"
|
||||
|
||||
def test_error_detail_warning(self):
|
||||
"""Test ErrorDetail with warning severity."""
|
||||
from src.models import ErrorDetail
|
||||
|
||||
error = ErrorDetail(
|
||||
check="pdf_abgleich",
|
||||
field=None,
|
||||
error_code="value_mismatch",
|
||||
message="Amounts differ slightly",
|
||||
severity="warning",
|
||||
)
|
||||
assert error.check == "pdf_abgleich"
|
||||
assert error.field is None
|
||||
assert error.severity == "warning"
|
||||
|
||||
|
||||
class TestValidationResult:
|
||||
"""Test ValidationResult model."""
|
||||
|
||||
def test_valid_result(self):
|
||||
"""Test ValidationResult with no errors."""
|
||||
from src.models import ValidationResult
|
||||
|
||||
result = ValidationResult(
|
||||
is_valid=True, errors=[], warnings=[], summary=None, validation_time_ms=100
|
||||
)
|
||||
assert result.is_valid is True
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
assert result.summary is None
|
||||
assert result.validation_time_ms == 100
|
||||
|
||||
def test_invalid_result_with_errors(self):
|
||||
"""Test ValidationResult with errors."""
|
||||
from src.models import ValidationResult, ErrorDetail
|
||||
|
||||
result = ValidationResult(
|
||||
is_valid=False,
|
||||
errors=[
|
||||
ErrorDetail(
|
||||
check="pflichtfelder",
|
||||
field="invoice_number",
|
||||
error_code="missing_required_field",
|
||||
message="Invoice number is required",
|
||||
severity="critical",
|
||||
),
|
||||
ErrorDetail(
|
||||
check="betraege",
|
||||
field="totals.gross",
|
||||
error_code="calculation_mismatch",
|
||||
message="Gross total mismatch: expected 118.88, got 118.90",
|
||||
severity="critical",
|
||||
),
|
||||
],
|
||||
warnings=[
|
||||
ErrorDetail(
|
||||
check="pflichtfelder",
|
||||
field="due_date",
|
||||
error_code="missing_optional_field",
|
||||
message="Due date not provided",
|
||||
severity="warning",
|
||||
)
|
||||
],
|
||||
summary={"total_errors": 2, "total_warnings": 1, "critical_errors": 2},
|
||||
validation_time_ms=150,
|
||||
)
|
||||
assert result.is_valid is False
|
||||
assert len(result.errors) == 2
|
||||
assert len(result.warnings) == 1
|
||||
assert result.summary["total_errors"] == 2
|
||||
assert result.validation_time_ms == 150
|
||||
|
||||
|
||||
class TestExtractRequest:
|
||||
"""Test ExtractRequest model."""
|
||||
|
||||
def test_extract_request(self):
|
||||
"""Test ExtractRequest model."""
|
||||
from src.models import ExtractRequest
|
||||
|
||||
request = ExtractRequest(pdf_base64="JVBERi0xLjQK...")
|
||||
assert request.pdf_base64 == "JVBERi0xLjQK..."
|
||||
|
||||
|
||||
class TestValidateRequest:
|
||||
"""Test ValidateRequest model."""
|
||||
|
||||
def test_validate_request_minimal(self):
|
||||
"""Test ValidateRequest with minimal fields."""
|
||||
from src.models import ValidateRequest
|
||||
|
||||
request = ValidateRequest(xml_data={}, checks=["pflichtfelder"])
|
||||
assert request.xml_data == {}
|
||||
assert request.checks == ["pflichtfelder"]
|
||||
assert request.pdf_text is None
|
||||
|
||||
def test_validate_request_full(self):
|
||||
"""Test ValidateRequest with all fields."""
|
||||
from src.models import ValidateRequest
|
||||
|
||||
request = ValidateRequest(
|
||||
xml_data={"invoice_number": "RE-001", "totals": {"gross": 118.88}},
|
||||
pdf_text="Invoice text...",
|
||||
checks=["pflichtfelder", "betraege", "ustid", "pdf_abgleich"],
|
||||
)
|
||||
assert request.xml_data["invoice_number"] == "RE-001"
|
||||
assert request.pdf_text is not None
|
||||
assert len(request.checks) == 4
|
||||
|
||||
|
||||
class TestErrorResponse:
|
||||
"""Test ErrorResponse model."""
|
||||
|
||||
def test_error_response(self):
|
||||
"""Test ErrorResponse model."""
|
||||
from src.models import ErrorResponse
|
||||
|
||||
response = ErrorResponse(
|
||||
error="invalid_pdf", message="The provided file is not a valid PDF"
|
||||
)
|
||||
assert response.error == "invalid_pdf"
|
||||
assert response.message == "The provided file is not a valid PDF"
|
||||
|
||||
|
||||
class TestModelsSerializeToJSON:
|
||||
"""Test JSON serialization of all models."""
|
||||
|
||||
def test_extract_response_serializes(self):
|
||||
"""Test ExtractResponse serializes to valid JSON."""
|
||||
from src.models import ExtractResponse, ExtractionMeta
|
||||
|
||||
response = ExtractResponse(
|
||||
is_zugferd=False,
|
||||
pdf_text="Invoice text...",
|
||||
extraction_meta=ExtractionMeta(pages=1, extraction_time_ms=50),
|
||||
)
|
||||
json_str = response.model_dump_json()
|
||||
assert '"is_zugferd":false' in json_str
|
||||
assert '"pdf_text"' in json_str
|
||||
|
||||
def test_validation_result_serializes(self):
|
||||
"""Test ValidationResult serializes to valid JSON."""
|
||||
from src.models import ValidationResult
|
||||
|
||||
result = ValidationResult(
|
||||
is_valid=True, errors=[], warnings=[], summary=None, validation_time_ms=100
|
||||
)
|
||||
json_str = result.model_dump_json()
|
||||
assert '"is_valid":true' in json_str
|
||||
|
||||
def test_models_roundtrip(self):
|
||||
"""Test models survive JSON roundtrip."""
|
||||
from src.models import Supplier
|
||||
|
||||
supplier = Supplier(name="ACME GmbH", vat_id="DE123456789")
|
||||
json_str = supplier.model_dump_json()
|
||||
supplier2 = Supplier.model_validate_json(json_str)
|
||||
assert supplier2.name == supplier.name
|
||||
assert supplier2.vat_id == supplier.vat_id
|
||||
Reference in New Issue
Block a user