Preserve inactive-category fallback and saved settlement header evidence
This commit is contained in:
@@ -440,6 +440,37 @@ def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path):
|
||||
assert asset.get("status") == "ready"
|
||||
|
||||
|
||||
def test_saved_reader_projects_safe_header_exclusion_carry_and_approval_evidence(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
excluded = [{"source_type":"account", "source_id":"missing-account-41", "reason":"missing_verified_exact_valuation",
|
||||
"valuation_date":"2026-09-27", "arbitrary_metadata":{"password":"DO-NOT-LEAK"}}]
|
||||
carried = [{"source_type":"account", "source_id":"carried-account-42", "reason":"as_of_carry_forward",
|
||||
"valuation_date":"2026-09-27", "arbitrary_metadata":{"token":"DO-NOT-LEAK"}}]
|
||||
approval = {"approved_by":"synthetic-reviewer", "approval_reason":"synthetic conditional approval",
|
||||
"approval_reference":"synthetic-ref", "allow_missing_exclusions":True,
|
||||
"allow_as_of_carry_forward":True, "private_note":"DO-NOT-LEAK"}
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET close_status='conditional',excluded_evidence=?,carried_evidence=?,approved_by=?,approval_reason=?,approval_reference=? WHERE id=?",
|
||||
(json.dumps(excluded), json.dumps(carried), approval["approved_by"], approval["approval_reason"], approval["approval_reference"], saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
assert exact["run_id"] == saved["run_id"] and exact["close_status"] == "conditional"
|
||||
assert exact["excluded_items"][0]["source_id"] == "missing-account-41"
|
||||
assert exact["excluded_items"][0]["reason"] == "missing_verified_exact_valuation"
|
||||
assert exact["carried_items"][0]["source_id"] == "carried-account-42"
|
||||
assert exact["carried_items"][0]["valuation_date"] == "2026-09-27"
|
||||
assert exact["applied_evidence"]
|
||||
assert exact["approval"]["approval_reason"] == "synthetic conditional approval"
|
||||
encoded = json.dumps(exact, sort_keys=True)
|
||||
assert "DO-NOT-LEAK" not in encoded and "private_note" not in encoded and "arbitrary_metadata" not in encoded
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version,aggregation_version", [(1, None), (1, ""), (1, "unknown-version"), (2, "comprehensive-v1")])
|
||||
def test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes(tmp_path, schema_version, aggregation_version):
|
||||
path = dbfile(tmp_path)
|
||||
@@ -476,3 +507,106 @@ def test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy(tmp_p
|
||||
legacy = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
assert legacy["legacy"] and not legacy["is_comprehensive"]
|
||||
assert all(value is None for value in legacy["totals"].values())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema_thirteen_conditional_close(tmp_path):
|
||||
"""Execute immutable pre-comprehensive public APIs, without another checkout."""
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
revision = "f67c07adf01d1f17415bf7cf836f46997ca6f21c"
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
namespace = "_legacy_schema_thirteen"
|
||||
names = subprocess.check_output(
|
||||
["git", "ls-tree", "-r", "--name-only", revision, "jarca_ledger_mcp"],
|
||||
cwd=root, text=True,
|
||||
).splitlines()
|
||||
blobs = {
|
||||
name: subprocess.check_output(["git", "show", f"{revision}:{name}"], cwd=root)
|
||||
for name in names if name.endswith(".py")
|
||||
}
|
||||
|
||||
class BaselineModules(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != namespace and not fullname.startswith(namespace + "."):
|
||||
return None
|
||||
relative = fullname[len(namespace):].replace(".", "/")
|
||||
candidate = "jarca_ledger_mcp" + relative
|
||||
package = candidate + "/__init__.py"
|
||||
module = candidate + ".py"
|
||||
if package in blobs:
|
||||
return importlib.util.spec_from_loader(fullname, self, is_package=True)
|
||||
if module in blobs:
|
||||
return importlib.util.spec_from_loader(fullname, self)
|
||||
return None
|
||||
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
relative = module.__name__[len(namespace):].replace(".", "/")
|
||||
candidate = "jarca_ledger_mcp" + relative
|
||||
filename = candidate + ("/__init__.py" if hasattr(module, "__path__") else ".py")
|
||||
module.__file__ = f"git:{revision}:{filename}"
|
||||
exec(compile(blobs[filename], module.__file__, "exec"), module.__dict__)
|
||||
|
||||
finder = BaselineModules()
|
||||
sys.meta_path.insert(0, finder)
|
||||
try:
|
||||
old = importlib.import_module(namespace + ".service")
|
||||
old_db = importlib.import_module(namespace + ".db")
|
||||
path = str(tmp_path / "baseline-schema-thirteen.sqlite3")
|
||||
old_db.init_db(path)
|
||||
missing = ok(old.create_ledger_account("Synthetic missing", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
carried = ok(old.create_ledger_account("Synthetic carry", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
ok(old.register_account_valuation(carried, "2026-09-27", 100, verification_status="verified", idempotency_key="baseline-carry", db_path=path))
|
||||
closed = ok(old.close_asset_snapshot(
|
||||
"2026-09-30", selection_policy="as_of", idempotency_key="baseline-close",
|
||||
allow_missing_exclusions=True, allow_as_of_carry_forward=True,
|
||||
approved_by="synthetic-reviewer", approval_reason="synthetic conditional approval",
|
||||
approval_reference="synthetic-ref", db_path=path,
|
||||
))
|
||||
original = ok(old.get_asset_snapshot_report("2026-09-30", db_path=path))
|
||||
yield path, missing, carried, closed, original
|
||||
finally:
|
||||
sys.meta_path.remove(finder)
|
||||
for name in list(sys.modules):
|
||||
if name == namespace or name.startswith(namespace + "."):
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def test_schema_thirteen_saved_reader_retains_real_conditional_header_without_writes(schema_thirteen_conditional_close):
|
||||
path, missing, carried, closed, original = schema_thirteen_conditional_close
|
||||
init_db(path)
|
||||
assert ok(service.get_asset_snapshot_report("2026-09-30", db_path=path)) == original
|
||||
with sqlite3.connect(path) as conn:
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
assert exact["legacy"] and not exact["is_comprehensive"]
|
||||
assert all(value is None for value in exact["totals"].values())
|
||||
assert exact["close_status"] == "conditional"
|
||||
excluded = next(item for item in exact["excluded_items"] if item["source_id"] == missing)
|
||||
assert excluded["reason"]
|
||||
carry = next(item for item in exact["carried_items"] if item["source_id"] == carried)
|
||||
assert carry["valuation_date"] == "2026-09-27"
|
||||
assert exact["applied_evidence"]
|
||||
assert exact["approval"]["approved_by"] == "synthetic-reviewer"
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
def test_saved_evidence_privacy_contract_filters_keys_not_allowlisted_scalar_values():
|
||||
from pathlib import Path
|
||||
from jarca_ledger_mcp.application.assets_service import _public_evidence
|
||||
value = {"candidate": {"name": "synthetic public scalar", "memo": "private", "source_attributes": {"reason": "synthetic reason", "private_key": "private"}}, "unrecognized": "private"}
|
||||
assert _public_evidence(value, strict=True) == {"candidate": {"name": "synthetic public scalar", "source_attributes": {"reason": "synthetic reason"}}}
|
||||
contract = (Path(__file__).resolve().parents[1] / "docs/interface-contract.md").read_text()
|
||||
assert "allowlisted scalar values are preserved" in contract
|
||||
assert "not a content-based secret redactor" in contract
|
||||
|
||||
@@ -4,6 +4,7 @@ import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from test_comprehensive_settlement import schema_thirteen_conditional_close # noqa: F401
|
||||
|
||||
POLICY = "set_settlement_cashflow_policy"
|
||||
READER = "get_monthly_settlement_snapshot"
|
||||
@@ -256,6 +257,43 @@ def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path,
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approval(schema_thirteen_conditional_close, monkeypatch): # noqa: F811 - imported pytest fixture
|
||||
import json
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
|
||||
|
||||
path, missing, carried, closed, _ = schema_thirteen_conditional_close
|
||||
# Migrate the authentic schema-13 baseline in place, then exercise the real
|
||||
# service through its public Streamlit gateway; no saved response is mocked.
|
||||
init_db(path)
|
||||
snapshot = settlement_gateway.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path)
|
||||
assert snapshot["success"]
|
||||
excluded = next(row for row in snapshot["excluded_items"] if row["source_id"] == missing)
|
||||
carry = next(row for row in snapshot["carried_items"] if row["source_id"] == carried)
|
||||
approval = snapshot["approval"]
|
||||
assert approval
|
||||
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-09", "reports": {}})
|
||||
monkeypatch.setattr(overview_gateway, "list_cross_transfer_review_queue", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(overview_gateway, "list_ledger_import_batches", lambda *a, **k: {"success": True, "import_batches": []})
|
||||
monkeypatch.setattr(overview_gateway, "get_asset_snapshot_report", lambda *a, **k: {"success": False})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_review_operating_indicators", lambda *a, **k: {"success": True, "indicators": {}})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
f"page_monthly_settlement({path!r})", default_timeout=30).run()
|
||||
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
|
||||
body = "\\n".join(str(x.value) for x in list(app.caption) + list(app.markdown) + list(app.text) + list(app.warning) + list(app.info))
|
||||
body += "\\n" + json.dumps([frame.value.to_dict("records") for frame in app.dataframe], ensure_ascii=False, default=str)
|
||||
assert missing in body and excluded["reason"] in body, "saved panel must render excluded_items source_id and reason"
|
||||
assert carried in body and carry["valuation_date"] in body, "saved panel must render carried_items source_id and historical valuation_date"
|
||||
for key in ("approved_by", "approval_reason", "approval_reference"):
|
||||
assert approval[key] in body, f"saved panel must render approval.{key} from the saved response"
|
||||
|
||||
|
||||
def test_draft_cashflow_blocker_renders_safe_transaction_id_and_reason(monkeypatch):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
|
||||
@@ -244,3 +244,67 @@ def test_nonstring_cashflow_decision_kind_has_validation_error():
|
||||
except Exception as caught:
|
||||
error = caught
|
||||
assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError"
|
||||
|
||||
|
||||
def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
|
||||
path = str(tmp_path / "inactive-category-fallback.sqlite3")
|
||||
init_db(path)
|
||||
# A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize.
|
||||
_ok(service.create_manual_asset("Synthetic close cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
cases = [
|
||||
# SQL fallback is category-based: ordinary legacy categories become expense,
|
||||
# while canonical legacy categories retain their established classifications.
|
||||
("Old exclude", "A", "exclude", "expense", 11, "expense"),
|
||||
("Old income", "B", "income", "expense", 13, "expense"),
|
||||
("Old transfer", "C", "transfer", "expense", 17, "expense"),
|
||||
("Old unknown", "D", "unknown", "income", 19, "expense"),
|
||||
("Old refund", "E", "exclude", "refund", 5, "expense"),
|
||||
("Old movement", "F", "exclude", "transfer", 7, "excluded"),
|
||||
("수입", "Legacy", "exclude", "income", 29, "income"),
|
||||
("Old expense", "H", "expense", "expense", 37, "expense"),
|
||||
("Old investment", "G", "investment", "expense", 31, "expense"),
|
||||
]
|
||||
ids = {}
|
||||
for index, (category, subcategory, kind, raw_type, amount, expected) in enumerate(cases):
|
||||
_add_category(category, subcategory, kind, path)
|
||||
txid = _add_tx(f"2026-09-{index + 1:02d}", raw_type, amount, category, subcategory, path)
|
||||
assert service.delete_ledger_category(category, subcategory, force=True, db_path=path)["success"]
|
||||
ids[txid] = (expected, kind, raw_type, amount)
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
by_id = {row["id"]: row for row in reviewed["source_snapshot"]["transactions"]}
|
||||
for txid, (expected, _kind, raw_type, amount) in ids.items():
|
||||
row = by_id[txid]
|
||||
applied = -amount if raw_type == "refund" else amount
|
||||
assert row["cashflow_kind"] == expected
|
||||
assert row["originalcashflow_kind"] == ("income" if row["category"] == "수입" else ("unknown" if row["category"] == "미분류" else "expense"))
|
||||
assert row["applied_amount"] == applied # excluded movement retains its amount as evidence
|
||||
assert row["sourceprovenance"] == "fallback"
|
||||
assert row["category_cashflow_kind"] is None and row["category_active"] is None
|
||||
assert reviewed["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37
|
||||
assert reviewed["income_total"] == 29
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path))
|
||||
snapshot = _ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
persisted = {row["id"]: row for row in snapshot["source_snapshot"]["transactions"]}
|
||||
for txid in ids:
|
||||
assert persisted[txid]["sourceprovenance"] == "fallback"
|
||||
assert persisted[txid]["category_active"] is None
|
||||
assert persisted[txid]["category_cashflow_kind"] is None
|
||||
assert snapshot["expense_total"] == 123 and snapshot["income_total"] == 29
|
||||
|
||||
|
||||
def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path):
|
||||
path = str(tmp_path / "inactive-explicit-decision.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("미분류", "Retired", "unknown", path)
|
||||
unresolved = _add_tx("2026-09-10", "expense", 23, "미분류", "Retired", path)
|
||||
assert service.delete_ledger_category("미분류", "Retired", force=True, db_path=path)["success"]
|
||||
draft = _draft(path)
|
||||
policy = _policy([{"category": "미분류", "subcategory": "Retired", "cashflow_kind": "income", "reason": "synthetic explicit decision"}])
|
||||
_ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", policy, db_path=path))
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
row = next(x for x in reviewed["source_snapshot"]["transactions"] if x["id"] == unresolved)
|
||||
assert row["cashflow_kind"] == "income" and row["decision"]["cashflow_kind"] == "income"
|
||||
assert row["originalcashflow_kind"] == "unknown" and row["sourceprovenance"] == "fallback"
|
||||
assert reviewed["income_total"] == 23
|
||||
|
||||
Reference in New Issue
Block a user