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
225 lines
7.9 KiB
Python
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",
|
|
)
|