feat: extend comprehensive monthly settlement snapshots (#18)
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user