Files
jarca-ledger-mcp/tests/test_comprehensive_settlement_adapters.py
T
gitea-impl-agent ae4383ca2c fix: address settlement display and documentation review findings
Rework Issue 18 / PR 19, source review 76: C11-F01 and D02-F02/D03-F03/D04-F04. Preserve the approved contract and add named display, schema, policy, ownership and signature regressions.
2026-10-13 13:09:16 +00:00

351 lines
24 KiB
Python

"""Issue 18 adapter/UI contracts; synthetic paths and identities only."""
import importlib
import inspect
from pathlib import Path
from jarca_ledger_mcp import service
POLICY = "set_settlement_cashflow_policy"
READER = "get_monthly_settlement_snapshot"
def _api(name):
fn = getattr(service, name, None)
assert callable(fn), f"required comprehensive settlement API missing: service.{name}"
return fn
def test_shared_adapters_expose_policy_and_saved_snapshot_contracts(monkeypatch, tmp_path):
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts")
server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server")
setter_owner = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
reader_owner = importlib.import_module("jarca_ledger_mcp.application.monthly_settlement_service")
expected = {POLICY: ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"], READER: ["month", "run_id", "db_path"]}
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(tmp_path / "synthetic.sqlite3"))
calls = []
for name, parameters in expected.items():
fn = _api(name)
assert list(inspect.signature(fn).parameters) == parameters
gateway_fn = getattr(gateway, name, None)
assert callable(gateway_fn), f"gateway missing {name}"
assert inspect.signature(gateway_fn) == inspect.signature(fn)
marker = {"success": True, "operation": name}
def stub(*args, _name=name, _marker=marker, **kwargs):
calls.append((_name, kwargs.get("db_path")))
return _marker
monkeypatch.setattr(setter_owner if name == POLICY else reader_owner, name, stub)
kwargs = {key: "synthetic" for key in parameters if key != "db_path"}
if name == POLICY:
kwargs["expected_version"] = 1
kwargs["cashflow_policy"] = {"savings_categories": [], "category_decisions": []}
path = str(tmp_path / "synthetic.sqlite3")
kwargs["db_path"] = path
assert gateway_fn(**kwargs) == marker
assert calls[-1] == (name, path)
assert name in contracts.TOOL_CONTRACTS
tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools
assert name in tools
tool = tools[name]
call = getattr(tool, "fn", getattr(tool, "function", None))
assert callable(call)
tool_kwargs = dict(kwargs)
tool_kwargs.pop("db_path")
boundary_calls = []
def boundary(*args, **forwarded):
bound = inspect.signature(fn).bind(*args, **forwarded)
boundary_calls.append(dict(bound.arguments))
return marker
# Existing MCP contracts bind facade callables at import time. Test the
# actual MCP/contract boundary, not a later owner-module monkeypatch.
monkeypatch.setattr(contracts, name, boundary)
assert call(**tool_kwargs) == marker
assert boundary_calls[-1] == tool_kwargs
schema = contracts.TOOL_CONTRACTS[name]
assert callable(schema)
tool_schema = tool.parameters if hasattr(tool, "parameters") else tool.inputSchema
assert "db_path" not in str(tool_schema)
def test_readonly_reader_on_missing_path_does_not_create_database(tmp_path):
path = tmp_path / "absent.sqlite3"
result = _api(READER)(month="2026-09", db_path=str(path))
assert result.get("success") is False
assert not path.exists()
def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mutation():
root = Path(__file__).parents[1]
drafts = root / "dashboard/page_views/settlement_drafts.py"
monthly = root / "dashboard/page_views/monthly_settlement.py"
source = drafts.read_text(encoding="utf-8")
monthly_source = monthly.read_text(encoding="utf-8")
combined = source + "\n" + monthly_source
assert "종합 월정산 정책" in combined
assert "저장된 월정산 스냅샷" in combined
assert POLICY in source or POLICY in monthly_source
assert READER in source or READER in monthly_source
assert "settlement_gateway" in combined
assert "sqlite3.connect" not in combined and "CREATE TABLE" not in combined
assert "form_submit_button" in source or "if st.button" in source
def test_authenticated_dashboard_entrypoint_keeps_authentication_before_database_resolution():
app = (Path(__file__).parents[1] / "dashboard/app.py").read_text(encoding="utf-8")
assert app.index("require_dashboard_authentication()") < app.index("get_db_path()")
def test_new_contracts_keep_asset_direct_close_signatures_and_live_report_api():
assets = importlib.import_module("jarca_ledger_mcp.application.assets_service")
report = importlib.import_module("jarca_ledger_mcp.application.transaction_report_services")
direct = inspect.signature(assets.close_asset_snapshot)
assert "cashflow_policy" not in direct.parameters
assert list(inspect.signature(report.get_monthly_cashflow_report).parameters)[0] == "month"
assert callable(_api(POLICY)) and callable(_api(READER))
def _draft_page(monkeypatch, status="active"):
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
policy = {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": []}
draft = {"id": "synthetic-draft", "month": "2026-09", "basis_date": "2026-09-30", "status": status, "version": 1, "cashflow_policy": policy, "final_run_id": "synthetic-run"}
final = {"income_total": 101, "expense_total": 0, "net_cashflow": 101, "total_assets": 0, "total_liabilities": 0, "net_worth": 0, "aggregation_version": "comprehensive-v1", "cashflow_items": [{"cashflow_kind": "income", "category": "Salary", "subcategory": "Monthly", "amount": 101, "transaction_count": 1, "currency": "KRW"}]}
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": [draft]})
monkeypatch.setattr(settlement_gateway, "get_settlement_draft", lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "synthetic-fp"}, "final_response": final if status == "completed" else None})
page = AppTest.from_string("from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')", default_timeout=30).run()
assert not page.exception
return page, policy
def test_draft_policy_editor_uses_saved_draft_policy(monkeypatch):
import json
page, policy = _draft_page(monkeypatch)
assert json.loads(page.text_area[0].value) == policy, "rendering must not replace persisted policy with an empty default"
def test_completed_draft_page_displays_saved_response_and_cashflow_kind(monkeypatch):
page, _ = _draft_page(monkeypatch, "completed")
assert len(page.dataframe) == 1, "completed get exposes saved final_response at the response root"
frame = page.dataframe[0].value
assert "cashflow_kind" in frame.columns
assert frame.iloc[0]["cashflow_kind"] == "income"
assert sum(item.value == "### 불변 이력" for item in page.markdown) == 1
# Real transport/UI acceptance coverage. All identities and ledger values are synthetic.
def test_real_comprehensive_stdio_policy_close_and_saved_snapshot(tmp_path):
import asyncio, json, os, sqlite3, sys
from contextlib import closing
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from jarca_ledger_mcp.db import init_db
db = tmp_path / "stdio-comprehensive.sqlite3"
init_db(str(db))
category = service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))
assert category["success"]
assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"]
assert service.create_ledger_category("Synthetic food", "Meal", cashflow_kind="expense", db_path=str(db))["success"]
assert service.create_ledger_transaction("2026-09-06", "expense", 10, "Synthetic food", "Meal", db_path=str(db))["success"]
assert service.create_ledger_category("Synthetic refund", "Meal", cashflow_kind="expense", db_path=str(db))["success"]
assert service.create_ledger_transaction("2026-09-07", "refund", 3, "Synthetic refund", "Meal", db_path=str(db))["success"]
assert service.create_ledger_category("Synthetic savings", "Reserve", cashflow_kind="investment", db_path=str(db))["success"]
assert service.create_ledger_transaction("2026-09-08", "expense", 5, "Synthetic savings", "Reserve", db_path=str(db))["success"]
acct = service.create_ledger_account("Synthetic checking", last_balance=100, liquidity_bucket="cash", db_path=str(db))["account"]["id"]
assert service.register_account_valuation(acct, "2026-09-30", 100, verification_status="verified", idempotency_key="stdio-valuation", db_path=str(db))["success"]
root = Path(__file__).resolve().parents[1]
# Deliberately construct a minimal environment; no caller auth/Gitea variables survive.
env = {k: os.environ[k] for k in ("PATH", "HOME", "LANG") if k in os.environ}
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT="stdio", PYTHONDONTWRITEBYTECODE="1")
params = StdioServerParameters(command=sys.executable, args=["-B", "-m", "jarca_ledger_mcp.server"], env=env, cwd=str(root))
async def exercise():
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = {t.name: t for t in (await session.list_tools()).tools}
for name in (POLICY, READER):
assert name in tools
schema = tools[name].inputSchema
assert "db_path" not in schema.get("properties", {})
assert set(schema["properties"]) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"}
async def call(name, args):
result = await session.call_tool(name, args)
assert not result.isError
return json.loads(result.content[0].text)
made = await call("create_settlement_draft", {"month":"2026-09", "created_by":"synthetic", "idempotency_key":"stdio-draft"})
draft = made["draft"]
policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]}
changed = await call(POLICY, {"draft_id":draft["id"], "expected_version":draft["version"], "updated_by":"synthetic", "cashflow_policy":policy})
draft = changed["draft"]
assert draft["version"] == 2 and draft["cashflow_policy"] == policy
review = await call("review_settlement_draft", {"draft_id":draft["id"]})
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
assert key in review
assert review["cashflow_items"] and all("cashflow_kind" in row for row in review["cashflow_items"])
assert any(t.get("applied_amount") is not None for t in review.get("source_snapshot", {}).get("transactions", []))
approved = await call("approve_settlement_draft", {"draft_id":draft["id"], "expected_version":draft["version"], "review_fingerprint":review["review_fingerprint"], "approved_by":"synthetic", "approval_reason":"acceptance", "approval_reference":"synthetic-ref"})
closed = await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"})
run = closed["run_id"]
exact = await call(READER, {"run_id":run})
monthly = await call(READER, {"month":"2026-09"})
assert exact["run_id"] == run and monthly["run_id"] == run
before = (await call("get_settlement_draft", {"draft_id":draft["id"]}))
assert await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"}) == closed
assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before
assert await call(READER, {"run_id":run}) == exact
# Later ledger writes must not rewrite either saved surface.
service.create_ledger_category("Later", "Synthetic", cashflow_kind="income", db_path=str(db))
service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Synthetic", db_path=str(db))
assert await call(READER, {"run_id":run}) == exact
assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before
return run
run = asyncio.run(asyncio.wait_for(exercise(), timeout=60))
with closing(sqlite3.connect(db)) as conn:
columns = {r[1] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns
assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (run,)).fetchone()[0] > 0
def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path, monkeypatch):
import sqlite3
from streamlit.testing.v1 import AppTest
from dashboard import authentication
from jarca_ledger_mcp.db import init_db
db = tmp_path / "dashboard-comprehensive.sqlite3"
init_db(str(db))
assert service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))["success"]
assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"]
asset = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=str(db))
assert asset["success"]
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
assert settings is not None
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
draft = service.create_settlement_draft("2026-09", "synthetic", "dashboard-draft", db_path=str(db))["draft"]
policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]}
changed = service.set_settlement_cashflow_policy(draft["id"], draft["version"], "synthetic", policy, db_path=str(db))["draft"]
reviewed = service.review_settlement_draft(draft["id"], db_path=str(db))
assert reviewed["success"]
assert service.approve_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "synthetic", "acceptance", "synthetic-ref", db_path=str(db))["success"]
saved = service.finalize_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "dashboard-close", db_path=str(db))
assert saved["success"]
with sqlite3.connect(db) as conn:
before = conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall()
before_rows = conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall()
before_detail = conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall()
app = AppTest.from_file(str(Path(__file__).resolve().parents[1] / "dashboard" / "app.py"), default_timeout=30)
app.query_params["page"] = "월정산"
app.query_params["month"] = "2026-09"
app.run()
assert not app.exception, app.exception
body = "\n".join(str(x.value) for x in list(app.text) + list(app.markdown) + list(app.caption))
assert "저장된 월정산 스냅샷" in body and f"run {saved['run_id']}" in body
assert any(metric.label == "자산 합계" for metric in app.metric)
frames = [item.value for item in app.dataframe]
assert any("cashflow_kind" in frame.columns and "income" in set(frame["cashflow_kind"]) for frame in frames)
with sqlite3.connect(db) as conn:
assert conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall() == before
assert conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall() == before_rows
assert conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == before_detail
import pytest
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
# Public review contract: cashflow_blockers is a list of safe dictionaries;
# these fields are emitted by the service, not renderer-invented `code`.
draft = {"id": "red-draft", "month": "2026-09", "basis_date": "2026-09-30",
"status": "active", "version": 1, "cashflow_policy": {"savings_categories": [], "category_decisions": []}}
blocker = {"transaction_id": "txn-safe-17", "reason": "unknown cashflow classification requires an explicit decision"}
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts",
lambda *a, **k: {"success": True, "drafts": [draft]})
monkeypatch.setattr(settlement_gateway, "get_settlement_draft",
lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "fp"}})
monkeypatch.setattr(settlement_gateway, "review_settlement_draft",
lambda *a, **k: {"success": True, "version": 1, "review_fingerprint": "fp",
"cashflow_blockers": [blocker], "cashflow_items": [], "missing_items": []})
app = AppTest.from_string(
"import streamlit as st\n"
"from dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
"render_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')",
default_timeout=30,
)
app.run()
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
next(button for button in app.button if button.label == "검토 / 새로고침 대상").click().run()
assert not app.exception
assert next(button for button in app.button if button.label == "검토 결과 승인").disabled
shown = "\n".join(str(x.value) for x in list(app.warning) + list(app.error) + list(app.caption))
assert "txn-safe-17" in shown and blocker["reason"] in shown, (
"public renderer must show safe transaction_id and reason from each blocker; "
"a failure here is the renderer dropping the declared public fields"
)
@pytest.mark.parametrize("comprehensive", [False, True])
def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypatch, comprehensive):
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
# Mirrors get_monthly_settlement_snapshot's declared response envelope/keys.
# Legacy/partial record is read-only; excluded assets carry reason in `items`.
snapshot = {"source": "settlement_snapshot", "run_id": "saved-run-9", "snapshot_run_id": "saved-run-9",
"settlement_month": "2026-09", "basis_date": "2026-09-30", "is_closed_snapshot": True,
"is_comprehensive": False, "legacy": True, "aggregation_version": None,
"snapshot_schema_version": None, "close_status": "legacy_read_only", "cashflow_items": [],
"items": [{"source_type": "manual_asset", "source_id": "asset-opaque-3", "name": "Synthetic asset",
"exclusion_reason": "valuation_missing", "verification_status": "unverified"}],
"source_snapshot": {"transactions": [], "assets": []}, "totals": {},
"income_total": None, "expense_total": None, "net_cashflow": None,
"total_assets": None, "total_liabilities": None, "net_worth": None,
"correction": {"is_correction": False, "correction_of_run_id": "", "correction_reason": ""}}
if comprehensive:
snapshot.update(is_comprehensive=True, legacy=False, aggregation_version="comprehensive-v1",
snapshot_schema_version=1, close_status="conditional")
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
snapshot[key] = 0
snapshot["totals"] = {key:snapshot[key] for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth")}
snapshot["source_snapshot"]["assets"] = [
{"source_type":"account", "source_id":"missing-source-7", "status":"missing",
"amount":None, "applied_amount":None, "valuation_id":None,
"verification_status":"unverified", "exclusion_reason":""},
{"source_type":"account", "source_id":"excluded-source-8", "status":"excluded",
"amount":10, "applied_amount":None, "exclusion_reason":"synthetic exclusion",
"unexpected_evidence":"not-public-evidence"},
]
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, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
app = AppTest.from_string(
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
app.run()
assert not app.exception, f"synthetic renderer fixture failed before assertion: {app.exception}"
import json
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 snapshot["close_status"] in body and (comprehensive or "legacy" in body.lower()), (
"saved snapshot renderer must expose close_status and legacy/partial provenance"
)
assert "asset-opaque-3" in body and "valuation_missing" in body and "제외" in body, (
"saved snapshot renderer must expose each missing/excluded asset status and reason"
)
if comprehensive:
assert "missing-source-7" in body and "missing" in body and "누락" in body
assert "excluded-source-8" in body and "synthetic exclusion" in body
assert "not-public-evidence" not in body
assert not app.exception