Files
finance/backend/tests/test_private_fixtures.py
T
k1nq e43428c1a2
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 20s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Failing after 24s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 1m34s
Implement Gitea CI and real statement imports
2026-09-18 17:59:02 +05:00

225 lines
7.9 KiB
Python

"""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",
)