"""Private real-statement suite (marker: private_fixtures). Runs only when invoked explicitly: PRIVATE_FIXTURES_DIR=/secure/path uv run pytest -m private_fixtures When PRIVATE_FIXTURES_DIR is unset, the ignored repository-local ``examples/`` directory is used if it contains a ``manifest.json``. Normal CI never collects this module (the default pytest addopts deselect the marker). Privacy contract (docs/parsers/private-fixture-gate.md): - fixture contents, parsed rows, descriptions, identifiers, and manifest financial values never appear in assertion output, messages, or logs; - failures report only the filename, adapter id, and the name of the mismatched aggregate; - every file is parsed twice and the results must be identical. """ import json import os from pathlib import Path import pytest from app.imports.adapters import detect_adapter, magic_kind from app.imports.service import _reconciliation_summary pytestmark = pytest.mark.private_fixtures REPO_EXAMPLES = Path(__file__).resolve().parents[2] / "examples" def _load_entries() -> tuple[Path | None, list[dict], str | None]: """Resolve fixture entries. Returns (directory, entries, explicit_error). An explicit error is only produced when PRIVATE_FIXTURES_DIR was set on purpose: then a missing directory, missing/malformed manifest, or empty manifest must fail loudly (redacted message, no values). Without the variable, an absent or broken repo-local examples directory is a silent skip. """ env = os.environ.get("PRIVATE_FIXTURES_DIR") if env: directory = Path(env) if not directory.is_dir(): return None, [], "PRIVATE_FIXTURES_DIR is set but the directory does not exist" try: manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8")) except Exception: return directory, [], "private manifest.json is missing or unreadable" entries = manifest.get("fixtures") if isinstance(manifest, dict) else None if not isinstance(entries, list) or not entries: return directory, [], "private manifest declares no fixtures" return directory, [entry for entry in entries if isinstance(entry, dict)], None try: if not REPO_EXAMPLES.is_dir(): return None, [], None manifest = json.loads((REPO_EXAMPLES / "manifest.json").read_text(encoding="utf-8")) entries = manifest.get("fixtures") if isinstance(manifest, dict) else None if not isinstance(entries, list): return None, [], None return REPO_EXAMPLES, [entry for entry in entries if isinstance(entry, dict)], None except Exception: return None, [], None FIXTURE_DIR, ENTRIES, EXPLICIT_ERROR = _load_entries() if EXPLICIT_ERROR: raise pytest.fail.Exception(EXPLICIT_ERROR) PARAMS = ENTRIES if ENTRIES else [None] def _check(ok: bool, name: str, what: str) -> None: assert ok, f"{name}: {what} mismatch" def _fail(name: str, message: str) -> None: pytest.fail(f"{name}: {message}", pytrace=False) @pytest.fixture(params=PARAMS, ids=[entry.get("filename", "unavailable") for entry in PARAMS]) def parsed(request: pytest.FixtureRequest): entry = request.param if entry is None: pytest.skip("private fixtures are not available") name = str(entry.get("filename", "unnamed")) path = (FIXTURE_DIR / name) if FIXTURE_DIR is not None else None if path is None or not path.is_file(): _fail(name, "fixture file is missing") raw = path.read_bytes() kind = magic_kind(raw) if kind not in {"xlsx", "pdf"}: _fail(name, "unexpected file type") try: adapter = detect_adapter(raw, kind) except Exception: _fail(name, "adapter detection failed") if adapter.adapter_id != entry.get("adapter_id"): _fail(name, "adapter detection mismatch") try: first = adapter.parse(raw) second = adapter.parse(raw) except Exception: _fail(name, "parse failed") if first != second: _fail(name, "parse result is not deterministic") return name, entry, first def test_manifest_contract(parsed) -> None: name, entry, draft = parsed _check(draft.parser_version == entry.get("parser_version"), name, "parser version") for key, actual in (("period_start", draft.period_start), ("period_end", draft.period_end)): expected = entry.get(key) _check( expected is None or (actual is not None and actual.isoformat() == expected), name, key ) def test_row_counts(parsed) -> None: name, entry, draft = parsed rows = draft.rows _check(len(rows) == entry.get("row_count"), name, "row count") posted = sum(1 for row in rows if row.bank_status == "posted") holds = sum(1 for row in rows if row.bank_status == "hold") _check(posted == entry.get("posted_count"), name, "posted count") _check(holds == entry.get("hold_count"), name, "hold count") _check( sum(1 for row in rows if row.direction == "in") == entry.get("in_count"), name, "inflow row count", ) _check( sum(1 for row in rows if row.direction == "out") == entry.get("out_count"), name, "outflow row count", ) def test_row_invariants(parsed) -> None: name, _, draft = parsed rows = draft.rows _check(all(row.amount_kopecks >= 0 for row in rows), name, "positive amount invariant") _check( all(row.bank_status in {"posted", "hold"} for row in rows), name, "bank status invariant", ) def test_hold_totals(parsed) -> None: name, entry, draft = parsed hold_total = sum(row.amount_kopecks for row in draft.rows if row.bank_status == "hold") _check(hold_total == entry.get("hold_total_kopecks"), name, "hold total") def test_control_totals(parsed) -> None: name, entry, draft = parsed _check( draft.declared_inflow_kopecks == entry.get("declared_inflow_kopecks"), name, "declared inflow total", ) _check( draft.declared_outflow_kopecks == entry.get("declared_outflow_kopecks"), name, "declared outflow total", ) _check( draft.declared_hold_kopecks == entry.get("declared_hold_kopecks"), name, "declared hold total", ) def test_balances(parsed) -> None: name, entry, draft = parsed _check( draft.opening_balance_kopecks == entry.get("opening_balance_kopecks"), name, "opening balance", ) _check( draft.closing_balance_kopecks == entry.get("closing_balance_kopecks"), name, "closing balance", ) def test_declared_totals_match_parsed_rows(parsed) -> None: name, _, draft = parsed posted_in = sum( row.amount_kopecks for row in draft.rows if row.bank_status == "posted" and row.direction == "in" ) posted_out = sum( row.amount_kopecks for row in draft.rows if row.bank_status == "posted" and row.direction == "out" ) hold_total = sum(row.amount_kopecks for row in draft.rows if row.bank_status == "hold") # Declared controls are canonicalized to posted-only by the adapters; the # hold control equals the parsed hold sum. _check(posted_in == draft.declared_inflow_kopecks, name, "declared vs parsed inflow") _check(posted_out == draft.declared_outflow_kopecks, name, "declared vs parsed outflow") _check(hold_total == (draft.declared_hold_kopecks or 0), name, "declared vs parsed hold") def test_service_reconciliation_matches(parsed) -> None: name, _, draft = parsed summary = _reconciliation_summary(draft) _check(summary.get("matched") is True, name, "service reconciliation matched") _check( summary.get("posted_totals_matched") is True, name, "service reconciliation posted totals", ) _check( summary.get("hold_totals_matched") is not False, name, "service reconciliation hold totals", )