import json import re from decimal import Decimal from pathlib import Path import pytest from app.parsers.dkb import _split_counterparty from app.parsers.registry import parse_pdf from app.parsers.validate import balance_difference FIXTURES = Path(__file__).parent / "fixtures" CASES = [("vr_beispiel.pdf", "vr"), ("hvb_beispiel.pdf", "hvb"), ("dkb_beispiel.pdf", "dkb")] NOISE = re.compile( r"(Kontostand|Übertrag|alter Saldo|neuer Saldo|Seite \d|Blatt \d)", re.I) @pytest.mark.parametrize("filename,bank", CASES) def test_parse_fixture(filename, bank): path = FIXTURES / filename if not path.exists(): pytest.skip(f"Fixture {filename} fehlt") stmt = parse_pdf(path) assert stmt.bank == bank assert stmt.opening_balance is not None assert stmt.closing_balance is not None assert len(stmt.transactions) > 0 assert balance_difference(stmt) == Decimal("0") @pytest.mark.parametrize("filename,bank", CASES) def test_no_noise_in_parsed_fields(filename, bank): path = FIXTURES / filename if not path.exists(): pytest.skip(f"Fixture {filename} fehlt") stmt = parse_pdf(path) for t in stmt.transactions: assert not NOISE.search(t.purpose), f"Noise im Verwendungszweck: #{stmt.transactions.index(t)}" assert not NOISE.search(t.counterparty), f"Noise im Empfänger: #{stmt.transactions.index(t)}" assert stmt.period_start <= t.booking_date <= stmt.period_end @pytest.mark.parametrize("filename,bank", CASES) def test_expected_values(filename, bank): path = FIXTURES / filename exp_path = FIXTURES / f"expected_{bank}.json" if not path.exists() or not exp_path.exists(): pytest.skip("Fixture oder expected-Datei fehlt") stmt = parse_pdf(path) exp = json.loads(exp_path.read_text()) assert len(stmt.transactions) == exp["transaction_count"] for c in exp["spot_checks"]: t = stmt.transactions[c["index"]] assert str(t.booking_date) == c["booking_date"] assert str(t.amount) == c["amount"] assert c["counterparty_contains"].lower() in t.counterparty.lower() assert c["purpose_contains"].lower() in t.purpose.lower() # --- DKB Empfänger/Zweck-Split-Heuristik (D-T5-1/D-T5-2) ------------------- # Ausschließlich synthetische Zeilen (fiktive Namen/Nummern), keine echten # Kontodaten. def test_split_counterparty_legal_form_single(): cp, rest = _split_counterparty( "Musterfirma Beispiel GmbH Rechnung Nr. F000123 vom 01.01.20") assert cp == "Musterfirma Beispiel GmbH" assert rest == "Rechnung Nr. F000123 vom 01.01.20" def test_split_counterparty_legal_form_et_cie_chain(): cp, rest = _split_counterparty( "Fiktiv Global S.a.r.l. et Cie S.C.A Onlinedienst Referenz 999") assert cp == "Fiktiv Global S.a.r.l. et Cie S.C.A" assert rest == "Onlinedienst Referenz 999" def test_split_counterparty_reference_boundary_digit_token(): cp, rest = _split_counterparty( "Beispiel Handelsgesellschaft Auftrag 84210 Sonderzahlung") assert cp == "Beispiel Handelsgesellschaft Auftrag" assert rest == "84210 Sonderzahlung" def test_split_counterparty_reference_boundary_keyword_token(): cp, rest = _split_counterparty( "Servicepartner Nord Vertrag ABC123 Laufzeit") assert cp == "Servicepartner Nord" assert rest == "Vertrag ABC123 Laufzeit" def test_split_counterparty_no_split_pure_words(): cp, rest = _split_counterparty("MUSTER PERSON Nebenkosten") assert cp == "MUSTER PERSON Nebenkosten" assert rest == "" def test_split_counterparty_siehe_anlage(): cp, rest = _split_counterparty("siehe Anlage Nr. 7") assert cp == "" assert rest == "siehe Anlage Nr. 7" def test_split_counterparty_never_empty_guard(): # Erstes Token enthält eine Ziffer, darf aber nie selbst die # Referenz-Grenze bilden (Regel greift frühestens ab Token 1) - da kein # weiteres Token eine Ziffer/Schlüsselwort liefert, bleibt die ganze # Zeile als counterparty erhalten (nie leer). cp, rest = _split_counterparty("42 Testfirma Ausgleich") assert cp == "42 Testfirma Ausgleich" assert rest == "" assert cp != "" # Einzelnes Token (auch mit Ziffer): kein Schnitt möglich, bleibt # counterparty (nie leer, nie None). cp2, rest2 = _split_counterparty("12345") assert cp2 == "12345" assert rest2 == ""