feat: add persistent monthly settlement drafts and approved valuation selection

This commit is contained in:
2026-10-10 02:49:34 +00:00
parent 68212e773b
commit 92f44661c3
37 changed files with 2902 additions and 29 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
with sqlite3.connect(path) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
assert version == "12"
assert version == "13"
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
+1 -1
View File
@@ -122,7 +122,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "12"
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "13"
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
+1 -1
View File
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path)
assert health["schema_version"] == "12"
assert health["schema_version"] == "13"
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
assert created["success"] is True
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
"""Acceptance contract for the approved monthly settlement draft lifecycle.
These tests intentionally resolve the API at runtime: before implementation the
suite must fail as an assertion (RED), not fail during pytest collection.
All data is synthetic and each test owns a temporary SQLite database.
"""
import json
import sqlite3
from concurrent.futures import ThreadPoolExecutor
import pytest
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
API = (
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets",
"set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft",
"cancel_settlement_draft",
)
def api(name):
# Deliberately runtime checked so absent API produces a clear RED assertion.
fn = getattr(service, name, None)
assert callable(fn), f"required settlement-draft API missing: service.{name}"
return fn
def dbfile(tmp_path):
path = str(tmp_path / "synthetic-ledger.sqlite3")
init_db(path)
return path
def create(path, month="2026-09", key="create-1", **kwargs):
return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs)
def assert_ok(result):
assert result.get("success") is True, result
return result
def draft_of(result):
assert isinstance(result.get("draft"), dict), result
return result["draft"]
def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path):
path = dbfile(tmp_path)
result = assert_ok(create(path))
draft = draft_of(result)
assert draft["month"] == "2026-09" and draft["basis_date"] == "2026-09-30"
assert draft["status"] == "active" and draft["version"] == 1
assert result["items"] == []
assert isinstance(result["review"], dict)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert fetched["draft"]["id"] == draft["id"]
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
assert any(row.get("id") == draft["id"] for row in listed.get("drafts", []))
@pytest.mark.parametrize("month", ["", "2026-9", "2026-13", "2026/09", "2026-09-30", " 2026-09"])
def test_month_and_basis_are_strictly_validated(tmp_path, month):
path = dbfile(tmp_path)
result = api("create_settlement_draft")(month, "tester", "strict", db_path=path)
assert result.get("success") is False
assert result.get("error_code") in {"validation_error", "invalid_argument"}
def test_basis_must_be_calendar_month_end_and_create_key_is_idempotent(tmp_path):
path = dbfile(tmp_path)
bad = api("create_settlement_draft")("2026-09", "tester", "bad-date", basis_date="2026-09-29", db_path=path)
assert bad.get("success") is False
first = assert_ok(create(path, key="same-key", memo="first"))
replay = assert_ok(create(path, key="same-key", memo="first"))
assert draft_of(first)["id"] == draft_of(replay)["id"]
conflict = create(path, key="same-key", memo="different payload")
assert conflict.get("success") is False
def test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month(tmp_path):
path = dbfile(tmp_path)
first = draft_of(assert_ok(create(path, key="one")))
duplicate = create(path, key="two")
assert duplicate.get("success") is False
assert api("cancel_settlement_draft")(first["id"], first["version"], "tester", "test cancellation", db_path=path).get("success") is True
reopened = draft_of(assert_ok(create(path, key="three")))
assert reopened["id"] != first["id"]
assert reopened["correction_of_run_id"] in (None, "")
def test_input_validation_versions_actors_and_include_exclude_reason(tmp_path):
path = dbfile(tmp_path)
draft = draft_of(assert_ok(create(path)))
update = api("update_settlement_draft")
for bad_version in (0, -1, True, 1.5, None):
result = update(draft["id"], bad_version, "tester", "memo", db_path=path)
assert result.get("success") is False
for actor in ("", " "):
result = update(draft["id"], 1, actor, "memo", db_path=path)
assert result.get("success") is False
assert api("refresh_settlement_draft_targets")(draft["id"], 1, "tester", db_path=path).get("success") is True
current = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"]
assert current["version"] >= 2
def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_path):
path = dbfile(tmp_path)
acct = service.create_ledger_account("Synthetic cash", last_balance=10, liquidity_bucket="cash", db_path=path)
assert acct["success"]
draft = draft_of(assert_ok(create(path)))
before = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
service.create_ledger_account("Added later", last_balance=20, liquidity_bucket="cash", db_path=path)
with sqlite3.connect(path) as conn:
snapshot = conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] if conn.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_draft_items'").fetchone() else None
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert {"version", "review_fingerprint", "items", "included_items", "excluded_items", "missing_items", "warnings", "approval_required", "totals"} <= review.keys()
assert {"total_assets", "total_liabilities", "net_worth"} <= review["totals"].keys()
assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert before["draft"]["version"] == draft["version"]
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
if snapshot is not None:
assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot
def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path):
path = dbfile(tmp_path)
a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path)
late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path)
assert late.get("success")
with sqlite3.connect(path) as conn:
conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"]))
d = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
rows = review["items"]
auto_future = [x for x in rows if x.get("source_id") == a]
assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future)
item = next(x for x in rows if x.get("source_id") == b)
assert item.get("valuation_id") == late["valuation"]["id"]
assert item.get("selection_mode", "exact") == "exact"
assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True
revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
selected = next(x for x in revised["items"] if x.get("source_id") == a)
assert selected.get("valuation_date") == "2026-10-05"
assert selected.get("date_difference_days") == 5
assert selected.get("selection_mode") == "manual_substitute"
def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path):
path = dbfile(tmp_path)
acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
row = next(x for x in review["items"] if x.get("source_id") == acct)
assert row.get("amount") is None
assert row.get("candidate_amount") == 900
child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path)
assert child.get("success")
changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert any(x.get("source_id") == acct for x in changed["missing_items"])
assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True
def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path):
path = dbfile(tmp_path)
manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"]
future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"]
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"])
service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path)
drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"])
refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path)
assert refreshed.get("success") is True
def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path):
path = dbfile(tmp_path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "reviewed synthetic evidence", "ref-1", allow_missing_exclusions=True, db_path=path)
assert approved.get("success") is True
mutated = api("update_settlement_draft")(draft["id"], draft["version"], "tester", "changed after approval", db_path=path)
assert mutated.get("success") is True
stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path)
assert stale.get("success") is False
assert stale.get("error_code") in {"conflict", "validation_error"}
def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path):
path = dbfile(tmp_path)
assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=path))
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, db_path=path)
assert approval.get("success")
final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert final.get("success")
replay = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert replay == final
conflict = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
assert conflict == final
with sqlite3.connect(path) as conn:
run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
assert run_count == 1 and row_count >= 1
assert final["draft_id"] == draft["id"]
assert final["draft_version"] == draft["version"]
assert final["review_fingerprint"] == review["review_fingerprint"]
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
series = service.get_net_worth_timeseries(start_date="2026-09-01", end_date="2026-09-30", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
assert report["success"] and series["success"] and monthly["success"]
assert report["close_metadata"].get("draft_id") == draft["id"]
def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
path = dbfile(tmp_path)
assert_ok(service.create_ledger_account("Synthetic", last_balance=5, liquidity_bucket="cash", db_path=path))
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path)
assert approved.get("success")
with sqlite3.connect(path) as conn:
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
assert result.get("success") is False
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
assert after["draft"]["status"] == "active"
def test_schema13_migration_is_additive_and_idempotent(tmp_path):
path = dbfile(tmp_path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')")
conn.commit()
init_db(path)
init_db(path)
with sqlite3.connect(path) as conn:
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '13'
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
path = dbfile(tmp_path)
fn = api("create_settlement_draft")
def attempt(i):
return fn("2026-09", "tester", f"concurrent-{i}", db_path=path)
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(attempt, range(4)))
successes = [r for r in results if r.get("success")]
assert len(successes) == 1
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results)
d = draft_of(successes[0])
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path)
assert approved.get("success")
stale = api("finalize_settlement_draft")(d["id"], d["version"] + 1, review["review_fingerprint"], "stale", db_path=path)
assert stale.get("success") is False
def test_approval_is_mandatory_even_for_complete_exact_evidence(tmp_path):
path = dbfile(tmp_path)
draft = draft_of(assert_ok(create(path)))
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
finalized = api("finalize_settlement_draft")(
draft["id"], draft["version"], review["review_fingerprint"], "unapproved",
db_path=path,
)
assert finalized.get("success") is False
+13 -9
View File
@@ -21,31 +21,35 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
"schema": "gitea-agent.requirement/v1",
"id": "REQ-003",
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
"revision": "1.0",
"revision": "1.1",
"status": "approved",
"depends_on": [],
"applies_to": ["ledger", "dashboard", "mcp"],
"language": "ko",
"issue": "#12",
"implementation_pr": "#13",
"issue": "#15",
"implementation_pr": "미정",
}
assert re.search(r"^# REQ-003 — ", body, re.M)
assert "${" not in text
requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M)
assert requirements == [f"{n:02d}" for n in range(1, 11)]
assert requirements == [f"{n:02d}" for n in range(1, 12)]
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
assert len(acs) == 10
assert body.count("**영향 범위**") == 10
assert len(acs) == 27
baseline_acs = re.findall(r"^### REQ-003\.(0[1-9]|10)-AC01 —", body, re.M)
assert baseline_acs == [f"{n:02d}" for n in range(1, 11)]
assert body.count("**영향 범위**") == 11 # Impact is per requirement, not per AC.
assert body.count("**Cases (정상/경계/실패)**") == 10
assert body.count("**State Invariants**") == 10
assert body.count("**판정 기준**") == 10
assert body.count("**검증**") == 10
assert body.count("**판정 기준**") == 27
assert body.count("**검증**") == 27
classification = re.findall(
r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$",
body,
re.M,
)
assert len(classification) == 10
assert body.count("- 수준: integration") == 17
assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 17
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
for marker in (
@@ -142,7 +146,7 @@ def test_req003_has_exact_canonical_numbered_sections() -> None:
headings = re.findall(r"^## (\d+)\. ", body, re.M)
assert headings == [str(n) for n in range(1, 12)]
assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M)
assert body.count("### REQ-003.") == 20
assert body.count("### REQ-003.") == 38
assert "### REQ-003.01-AC01" in body
assert "### REQ-003.10-AC01" in body
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "12"
assert service.health_check(db_path)["schema_version"] == "13"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+1 -1
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path))
assert result["success"] is True
assert result["schema_version"] == "12"
assert result["schema_version"] == "13"
assert db_path.exists()
+112
View File
@@ -0,0 +1,112 @@
"""Contract tests for the Issue 15 Streamlit gateway and MCP surface.
Fixtures are synthetic and isolated: each test receives an empty temporary SQLite
path; no production DB or external service is used. These assertions intentionally
fail until the approved adapter/API is implemented.
"""
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
import pytest
OPERATIONS = {
"create_settlement_draft": ["month", "created_by", "idempotency_key", "basis_date", "memo", "correction_of_run_id", "correction_reason", "db_path"],
"list_settlement_drafts": ["month", "db_path"],
"get_settlement_draft": ["draft_id", "db_path"],
"update_settlement_draft": ["draft_id", "expected_version", "updated_by", "memo", "db_path"],
"refresh_settlement_draft_targets": ["draft_id", "expected_version", "updated_by", "db_path"],
"set_settlement_draft_item": ["draft_id", "source_type", "source_id", "expected_version", "selected_by", "valuation_id", "selection_mode", "selection_reason", "included", "exclusion_reason", "db_path"],
"review_settlement_draft": ["draft_id", "db_path"],
"approve_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "approved_by", "approval_reason", "approval_reference", "allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute", "db_path"],
"finalize_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "idempotency_key", "db_path"],
"cancel_settlement_draft": ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"],
}
@pytest.fixture
def db_path(tmp_path: Path) -> Path:
return tmp_path / "synthetic-settlement.sqlite3"
def test_gateway_exports_exact_contract_signature_and_delegates(monkeypatch, db_path):
module = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
calls = []
for name, expected_parameters in OPERATIONS.items():
gateway_fn = getattr(module, name)
service_fn = getattr(service, name)
assert list(inspect.signature(gateway_fn).parameters) == expected_parameters
assert inspect.signature(gateway_fn) == inspect.signature(service_fn)
marker = {"success": True, "operation": name}
def stub(*args, _marker=marker, _name=name, **kwargs):
calls.append((_name, kwargs.get("db_path")))
return _marker
monkeypatch.setattr(service, name, stub)
kwargs = {p: "synthetic" for p in expected_parameters}
kwargs.update(db_path=str(db_path))
kwargs["correction_of_run_id"] = ""
kwargs["correction_reason"] = ""
if "expected_version" in kwargs:
kwargs["expected_version"] = 1
if "included" in kwargs:
kwargs["included"] = True
if name == "create_settlement_draft":
kwargs.update(month="2026-09", basis_date="2026-09-30")
assert gateway_fn(**kwargs) == marker
assert calls[-1] == (name, str(db_path))
def test_gateway_exposes_existing_valuation_evidence_operations():
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
for name in ("register_account_valuation", "list_account_valuations", "correct_account_valuation"):
assert callable(getattr(gateway, name)), name
def test_mcp_contract_and_fastmcp_tool_calls_preserve_service_result(monkeypatch, db_path):
contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts")
server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server")
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
calls = []
tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools
for name in OPERATIONS:
assert name in contracts.TOOL_CONTRACTS
assert name in tools
marker = {"success": True, "draft": {"id": "fixture-draft", "version": 1}}
def stub(*args, _marker=marker, _name=name, **kwargs):
calls.append(_name)
return _marker
monkeypatch.setattr(contracts, name, stub)
tool = tools[name]
call = getattr(tool, "fn", getattr(tool, "function", None))
assert call is not None
kwargs = {p: "synthetic" for p in OPERATIONS[name]}
kwargs.pop("db_path", None)
if "expected_version" in kwargs: kwargs["expected_version"] = 1
if "included" in kwargs: kwargs["included"] = True
if name == "create_settlement_draft": kwargs.update(month="2026-09", basis_date="2026-09-30")
assert call(**kwargs) == marker
assert calls[-1] == name
def test_read_only_gateway_calls_do_not_create_missing_database(monkeypatch, db_path):
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
# Real delegation is required here: read paths must not create a missing file.
for name in ("list_settlement_drafts",):
getattr(gateway, name)(db_path=str(db_path))
assert not db_path.exists()
def test_dashboard_authentication_entrypoint_precedes_database_resolution():
app_source = (Path(__file__).parents[1] / "dashboard" / "app.py").read_text(encoding="utf-8")
assert app_source.index("require_dashboard_authentication()") < app_source.index("get_db_path()")
def test_service_reexports_every_lifecycle_operation():
service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
assert all(callable(getattr(service, name, None)) for name in OPERATIONS)
@@ -0,0 +1,63 @@
"""Executable reproductions reconcile static audit hypotheses with real behavior."""
import sqlite3
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path):
path = str(tmp_path / 'empty.sqlite3')
init_db(path)
result = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
draft = result['draft']
assert draft['status'] == 'active' and draft['version'] == 1
assert draft['basis_date'] == '2026-09-30' and draft['correction_of_run_id'] == ''
assert result['items'] == [] and not result['review']['complete']
assert result['review']['totals'] == {'total_assets': 0, 'total_liabilities': 0, 'net_worth': 0}
assert ok(s.get_settlement_draft(draft['id'], db_path=path)) == result
with sqlite3.connect(path) as conn:
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1
assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert conn.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path):
path = str(tmp_path / 'selection.sqlite3')
init_db(path)
account = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id']
verified = ok(s.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='verified', db_path=path))['valuation']['id']
unverified = ok(s.register_account_valuation(account, '2026-09-30', 999, verification_status='unverified', idempotency_key='unverified', db_path=path))['valuation']['id']
with sqlite3.connect(path) as conn:
conn.execute("UPDATE account_valuations SET recorded_at='2026-10-01T00:00:00Z' WHERE id=?", (verified,))
conn.execute("UPDATE account_valuations SET recorded_at='2026-10-02T00:00:00Z' WHERE id=?", (unverified,))
selected = ok(s.select_account_valuation(account, '2026-09-30', as_of=False, db_path=path))
assert selected['valuation']['id'] == verified
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
item = next(x for x in draft['items'] if x['source_id'] == account)
assert item['valuation_id'] == verified and item['amount'] == 100 and item['verification_status'] == 'verified'
def test_refresh_manual_target_preserves_retired_history_and_allows_explicit_refreeze(tmp_path):
path = str(tmp_path / 'retired.sqlite3')
init_db(path)
asset = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
created = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
draft, original = created['draft'], created['items'][0]
ok(s.deactivate_manual_asset(asset, db_path=path))
retired = ok(s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'refresher', db_path=path))
row = next(x for x in retired['items'] if x['source_id'] == asset)
assert row['id'] == original['id'] and not row['included'] and row['exclusion_reason']
assert row['state_hash'] == original['state_hash'] and row['candidate_amount'] == 100
refrozen = ok(s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, retired['draft']['version'], 'refreezer', included=False, exclusion_reason='Synthetic retired source confirmed', db_path=path))
row = refrozen['items'][0]
assert row['id'] == original['id'] and not row['included'] and row['amount'] is None
assert row['state_hash'] != original['state_hash'] and not row['review_needed']
assert row['selected_by'] == 'refreezer'
invalid = s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, refrozen['draft']['version'], 'selector', included=True, db_path=path)
assert not invalid['success']
assert ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']['version'] == refrozen['draft']['version']
+181
View File
@@ -0,0 +1,181 @@
"""Synthetic regressions for frozen evidence and reviewed authority."""
import sqlite3
import pytest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
@pytest.fixture
def case(tmp_path):
path = str(tmp_path / 'authority.sqlite3')
init_db(path)
account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id']
val = ok(s.register_account_valuation(account, '2026-09-30', 500, valuation_kind='total_value', verification_status='verified', idempotency_key='evidence', db_path=path))['valuation']['id']
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
return path, account, val, draft
def approve(path, draft, **flags):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'synthetic reviewed', 'ref', db_path=path, **flags))
return review
def counts(path):
with sqlite3.connect(path) as conn:
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots'))
@pytest.mark.parametrize('sql', [
"UPDATE account_valuations SET amount=600 WHERE id=?",
"UPDATE account_valuations SET memo='changed' WHERE id=?",
"UPDATE account_valuations SET principal=123 WHERE id=?",
])
def test_full_valuation_drift_changes_fingerprint_and_blocks_write(case, sql):
path, account, val, draft = case
review = approve(path, draft)
with sqlite3.connect(path) as conn:
conn.execute(sql, (val,))
changed = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert changed['review_fingerprint'] != review['review_fingerprint']
assert any(x.get('review_needed') for x in changed['items'])
result = s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)
assert not result['success'], result
assert counts(path) == (0, 0)
def test_classification_drift_requires_explicit_reselection(case):
path, account, val, draft = case
review = approve(path, draft)
with sqlite3.connect(path) as conn:
conn.execute("UPDATE accounts SET liquidity_bucket='cash' WHERE id=?", (account,))
changed = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert changed['review_fingerprint'] != review['review_fingerprint']
assert any(x.get('review_needed') for x in changed['items'])
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success']
assert counts(path) == (0, 0)
def test_corrected_parent_cannot_finalize_without_explicit_child_decision(case):
path, account, val, draft = case
previous = approve(path, draft)
child = ok(s.correct_account_valuation(val, amount=550, verification_status='verified', correction_reason='synthetic correction', idempotency_key='child', db_path=path))['valuation']['id']
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert review['review_fingerprint'] != previous['review_fingerprint']
result = s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'review again', 'ref2', db_path=path)
if result['success']:
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success']
assert counts(path) == (0, 0)
updated = ok(s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'selector', valuation_id=child, db_path=path))['draft']
fresh = approve(path, updated)
final = ok(s.finalize_settlement_draft(updated['id'], updated['version'], fresh['review_fingerprint'], 'final2', db_path=path))
assert final['totals']['net_worth'] == 550
def test_create_replay_uses_original_payload_after_memo_edit(case):
path, account, val, draft = case
ok(s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path))
replay = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))
assert replay['draft']['id'] == draft['id']
assert not s.create_settlement_draft('2026-09', 'creator', 'create', memo='different original request', db_path=path)['success']
def test_empty_draft_cannot_create_empty_close(tmp_path):
path = str(tmp_path / 'empty.sqlite3')
init_db(path)
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'empty', db_path=path))['draft']
review = approve(path, draft, allow_missing_exclusions=True)
assert review['complete'] is False
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'empty-final', db_path=path)['success']
assert counts(path) == (0, 0)
def test_manual_reselection_explicitly_refreezes_semantic_evidence(tmp_path):
path = str(tmp_path / 'manual.sqlite3')
init_db(path)
asset = ok(s.create_manual_asset('Synthetic home', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'manual', db_path=path))['draft']
old = approve(path, draft)
ok(s.update_manual_asset(asset, valuation_amount=110, db_path=path))
drift = ok(s.review_settlement_draft(draft['id'], db_path=path))
assert drift['review_fingerprint'] != old['review_fingerprint']
assert not s.finalize_settlement_draft(draft['id'], draft['version'], old['review_fingerprint'], 'stale', db_path=path)['success']
refreshed = ok(s.set_settlement_draft_item(draft['id'], 'manual', asset, draft['version'], 'selector', db_path=path))['draft']
new = approve(path, refreshed)
assert not any(x.get('review_needed') for x in new['items'])
final = ok(s.finalize_settlement_draft(refreshed['id'], refreshed['version'], new['review_fingerprint'], 'manual-final', db_path=path))
assert final['totals']['net_worth'] == 110
@pytest.mark.parametrize('operation,args', [
('list_settlement_drafts', ()), ('get_settlement_draft', ('missing',)), ('review_settlement_draft', ('missing',)),
])
def test_draft_reads_preserve_missing_db_error_and_no_create(tmp_path, operation, args):
path = tmp_path / 'absent-parent' / 'db.sqlite3'
result = getattr(s, operation)(*args, db_path=str(path))
assert result['error_code'] == 'db_not_initialized', result
assert not path.parent.exists()
def test_target_refresh_refreezes_manual_retains_account_selection_and_retires_targets(tmp_path):
path = str(tmp_path / 'refresh.sqlite3')
init_db(path)
manual = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id']
val = ok(s.register_account_valuation(account, '2026-09-30', 50, valuation_kind='total_value', verification_status='verified', idempotency_key='initial', db_path=path))['valuation']['id']
d = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
ok(s.update_manual_asset(manual, valuation_amount=110, db_path=path))
child = ok(s.correct_account_valuation(val, amount=60, verification_status='verified', correction_reason='synthetic', idempotency_key='child', db_path=path))['valuation']['id']
added = ok(s.create_manual_asset('Synthetic new', 20, '2026-09-30', 'other', db_path=path))['manual_asset']['id']
result = ok(s.refresh_settlement_draft_targets(d['id'], d['version'], 'refresh actor', db_path=path))
review = result['review']
property_row = next(x for x in review['items'] if x['source_id'] == manual)
assert property_row['amount'] == 110 and not property_row['review_needed']
account_row = next(x for x in review['items'] if x['source_id'] == account)
assert account_row['valuation_id'] == val and account_row['valuation_id'] != child
assert account_row['review_needed']
assert any(x['source_id'] == added and x['amount'] == 20 for x in review['items'])
ok(s.deactivate_manual_asset(added, db_path=path))
refreshed = ok(s.refresh_settlement_draft_targets(d['id'], result['draft']['version'], 'refresh actor', db_path=path))
retired = next(x for x in refreshed['items'] if x['source_id'] == added)
assert retired['included'] is False and retired['exclusion_reason']
assert retired['selected_by'] == 'refresh actor'
@pytest.mark.parametrize('action', ['memo', 'cancel', 'refresh', 'selection'])
def test_any_draft_mutation_clears_all_approval_version_state(case, action):
path, account, val, draft = case
approve(path, draft)
before = ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']
assert before['approval_version'] == draft['version'] and before['approved_at']
if action == 'memo':
result = s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path)
elif action == 'cancel':
result = s.cancel_settlement_draft(draft['id'], draft['version'], 'editor', 'synthetic cancellation', db_path=path)
elif action == 'refresh':
result = s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'editor', db_path=path)
else:
result = s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'editor', valuation_id=val, db_path=path)
changed = ok(result)['draft']
assert changed['version'] == draft['version'] + 1
assert changed['approval_version'] == 0 and changed['approved_at'] == ''
assert all(changed[key] == '' for key in ('approved_by', 'approval_fingerprint', 'approval_reason', 'approval_reference'))
assert changed['approval_flags'] == '{}'
def test_finalize_same_key_different_payload_conflicts_without_partial_write(case):
path, account, val, draft = case
review = approve(path, draft)
final = ok(s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path))
before = counts(path)
assert s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path) == final
conflict = s.finalize_settlement_draft(draft['id'], draft['version'] + 1, review['review_fingerprint'], 'final-key', db_path=path)
assert not conflict['success'] and conflict['error_code'] == 'conflict'
assert counts(path) == before
@@ -0,0 +1,173 @@
"""Clause-level acceptance uses real synthetic SQLite and public consumers."""
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(result):
assert result['success'], result
return result
def account(path, name, **fields):
return ok(s.create_ledger_account(name, db_path=path, **fields))['account']['id']
def value(path, aid, key, when, amount, **fields):
return ok(s.register_account_valuation(aid, when, amount, idempotency_key=key, verification_status=fields.pop('verification_status', 'verified'), db_path=path, **fields))['valuation']['id']
def choose(path, draft, aid, vid, **fields):
return ok(s.set_settlement_draft_item(draft['id'], 'account', aid, draft['version'], 'synthetic selector', valuation_id=vid, db_path=path, **fields))['draft']
def approve(path, draft, **flags):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'synthetic review reason', 'synthetic reference', db_path=path, **flags))
return review
def test_date_metadata_public_contract_names_survive_all_closed_consumers(tmp_path):
path = str(tmp_path / 'dates.sqlite3')
init_db(path)
aid = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
vid = value(path, aid, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET recorded_at='2026-10-08T00:00:00Z' WHERE id=?", (vid,))
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic late loan statement')
review = approve(path, d, allow_manual_substitute=True)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
series = ok(s.get_net_worth_timeseries('2026-09-01', '2026-09-30', db_path=path))
monthly = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
expected = {'valuation_date': '2026-10-07', 'date_difference_days': 7, 'date_difference_direction': 'after', 'age_days': -7, 'recorded_at': '2026-10-08T00:00:00Z', 'representative_liability_definition': 'principal_only'}
rows = [review['included_items'][0], final['items'][0], report['close_metadata']['applied_evidence'][0], series['points'][0]['applied_evidence'][0], monthly['source_coverage']['assets']['applied_evidence'][0]]
for row in rows:
assert {key: row.get(key) for key in expected} == expected
for row in rows[1:]:
assert row['approval']['allow_manual_substitute'] is True
assert row['approval']['allow_missing_exclusions'] is False
assert row['approval']['allow_carried_before'] is False
def test_representative_integer_precision_no_double_sum_and_scope_rejection(tmp_path):
path = str(tmp_path / 'precision.sqlite3')
init_db(path)
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
other = account(path, 'Synthetic other')
amount = 9007199254740993
representative = value(path, aid, 'total', '2026-09-30', amount, valuation_kind='total_value')
value(path, aid, 'cash', '2026-09-30', 100, valuation_kind='cash_balance')
wrong = value(path, other, 'other', '2026-09-30', 10)
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
review = ok(s.review_settlement_draft(d['id'], db_path=path))
item = next(x for x in review['included_items'] if x['source_id'] == aid)
assert item['valuation_id'] == representative and type(item['amount']) is int and item['amount'] == amount
assert review['totals']['net_worth'] == amount + 10
before = ok(s.get_settlement_draft(d['id'], db_path=path))
bad = s.set_settlement_draft_item(d['id'], 'account', aid, d['version'], 'selector', valuation_id=wrong, db_path=path)
assert not bad['success'] and bad['error_code'] == 'validation_error'
assert ok(s.get_settlement_draft(d['id'], db_path=path)) == before
def test_unverified_substitute_only_finalizes_as_explicit_approved_missing(tmp_path):
path = str(tmp_path / 'unverified.sqlite3')
init_db(path)
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
vid = value(path, aid, 'candidate', '2026-10-05', 100, valuation_kind='total_value', verification_status='unverified')
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic review-only candidate')
review = ok(s.review_settlement_draft(d['id'], db_path=path))
assert not review['included_items'] and review['missing_items'][0]['amount'] is None
bad = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'reviewer', 'reason', 'ref', allow_manual_substitute=True, db_path=path)
assert not bad['success']
before = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not before['success']
review = approve(path, d, allow_missing_exclusions=True)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
assert not final['items'] and final['excluded_items'][0]['amount'] is None
assert final['excluded_items'][0]['candidate_amount'] == 100
assert final['totals']['net_worth'] == 0 and final['has_approved_missing']
assert not final['contains_substitutions'] and final['close_status'] == 'conditional'
def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path):
path = str(tmp_path / 'separation.sqlite3')
init_db(path)
aid = account(path, 'Synthetic cash')
value(path, aid, 'cash', '2026-09-30', 100)
tx = ok(s.create_ledger_transaction('2026-09-15', amount=5, account_id=aid, category='미분류', db_path=path))
assert tx['success']
ok(s.upsert_review_decision('transaction', tx['transaction']['id'], status='pending', operator='synthetic reviewer', db_path=path))
before = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
assert before['unresolved_blocker_count'] > 0
with sqlite3.connect(path) as c:
original = c.execute('SELECT * FROM transactions').fetchall()
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
review = approve(path, d)
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
after = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
assert final['totals']['net_worth'] == 100 and after['source_coverage']['assets']['is_closed_snapshot']
assert after['unresolved_blockers'] == before['unresolved_blockers'] and after['unresolved_blocker_count'] > 0
with sqlite3.connect(path) as c:
assert c.execute('SELECT * FROM transactions').fetchall() == original
def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollback_concurrent_retry(tmp_path):
path = str(tmp_path / 'september.sqlite3')
init_db(path)
cash = account(path, 'Synthetic cash')
pension = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
loan = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
rejected = account(path, 'Synthetic rejected')
foreign = account(path, 'Synthetic USD', currency='USD')
unknown = ok(s.create_manual_asset('Synthetic undated property', None, '', 'real_estate', db_path=path))['manual_asset']['id']
cv = value(path, cash, 'cash', '2026-09-30', 100)
pv = value(path, pension, 'pension', '2026-10-05', 120, valuation_kind='total_value')
lv = value(path, loan, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
value(path, rejected, 'rejected', '2026-09-30', 999, verification_status='rejected')
value(path, foreign, 'foreign', '2026-09-30', 77, currency='USD')
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET recorded_at='2026-10-10T00:00:00Z' WHERE id=?", (cv,))
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
first = ok(s.review_settlement_draft(d['id'], db_path=path))
assert first['totals']['net_worth'] == 100
d = choose(path, d, pension, pv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 05 pension')
d = choose(path, d, loan, lv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 07 loan')
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
assert review['totals']['net_worth'] == 180
assert {x['source_id'] for x in review['missing_items']} == {rejected, foreign, unknown}
assert all(x['amount'] is None for x in review['missing_items'])
assert any(w['code'] == 'non_krw' for w in review['warnings'])
with sqlite3.connect(path) as c:
c.execute("UPDATE account_valuations SET memo='Synthetic evidence drift' WHERE id=?", (cv,))
stale = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not stale['success'] and stale['error_code'] == 'conflict'
d = choose(path, d, cash, cv)
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
with sqlite3.connect(path) as c:
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
assert not failed['success'] and failed['error_code'] == 'db_error'
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
c.execute('DROP TRIGGER synthetic_failure')
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
def finish():
return s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(finish) for _ in range(2)]
results = [ok(f.result()) for f in futures]
assert results[0] == results[1]
final = results[0]
assert final['contains_substitutions'] and final['has_approved_missing'] and not final['contains_carried']
assert final['totals']['net_worth'] == 180 and len(final['items']) == 3
assert {x['valuation_date'] for x in final['items']} == {'2026-09-30', '2026-10-05', '2026-10-07'}
assert finish() == final
with sqlite3.connect(path) as c:
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count']
@@ -0,0 +1,67 @@
"""Acceptance contract for immutable, reason-linked settlement corrections."""
import sqlite3
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(r):
assert r['success'], r
return r
def counts(path):
with sqlite3.connect(path) as c:
return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
def finish(path, draft):
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
approved = ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'approved synthetic correction', 'acceptance', db_path=path))
return ok(s.finalize_settlement_draft(draft['id'], approved['draft']['version'], review['review_fingerprint'], 'close-' + draft['id'], db_path=path))
def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_correction(tmp_path):
path = str(tmp_path / 'correction.sqlite3')
init_db(path)
aid = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id']
first_value = ok(s.register_account_valuation(aid, '2026-09-30', 100, verification_status='verified', idempotency_key='v1', db_path=path))['valuation']['id']
original_draft = ok(s.create_settlement_draft('2026-09', 'synthetic', 'draft-1', db_path=path))['draft']
original = finish(path, original_draft)
original_rows = counts(path)
corrected_value = ok(s.correct_account_valuation(first_value, 125, 'synthetic valuation correction', idempotency_key='v2', db_path=path))['valuation']['id']
assert counts(path)['asset_snapshot_runs'] == original_rows['asset_snapshot_runs']
assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots']
run_id = original['snapshot_run_id']
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
before = counts(path)
for i, (parent, reason) in enumerate(bad_requests):
result = s.create_settlement_draft('2026-09', 'synthetic', f'invalid-{i}', correction_of_run_id=parent, correction_reason=reason, db_path=path)
assert not result['success']
assert counts(path) == before
ordinary = s.create_settlement_draft('2026-09', 'synthetic', 'ordinary-after-close', db_path=path)
assert not ordinary['success'] and ordinary['error_code'] == 'validation_error'
assert counts(path) == before
correction = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-1', correction_of_run_id=run_id, correction_reason='synthetic close correction', db_path=path))['draft']
active_conflict = s.create_settlement_draft('2026-09', 'synthetic', 'active-conflict', correction_of_run_id=run_id, correction_reason='another linked correction', db_path=path)
assert not active_conflict['success'] and active_conflict['error_code'] == 'conflict'
assert counts(path)['settlement_drafts'] == before['settlement_drafts'] + [counts(path)['settlement_drafts'][-1]]
assert correction['correction_of_run_id'] == run_id
selected = ok(s.set_settlement_draft_item(correction['id'], 'account', aid, correction['version'], 'synthetic selector', valuation_id=corrected_value, db_path=path))['draft']
corrected = finish(path, selected)
assert corrected['correction']['correction_of_run_id'] == run_id
assert corrected['correction']['correction_reason'] == 'synthetic close correction'
for table, rows in original_rows.items():
assert counts(path)[table][:len(rows)] == rows
third_value = ok(s.correct_account_valuation(corrected_value, 150, 'second synthetic correction', idempotency_key='v3', db_path=path))['valuation']['id']
later = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-2', correction_of_run_id=corrected['snapshot_run_id'], correction_reason='second synthetic close correction', db_path=path))['draft']
later = ok(s.set_settlement_draft_item(later['id'], 'account', aid, later['version'], 'synthetic selector', valuation_id=third_value, db_path=path))['draft']
finish(path, later)
before_replay = counts(path)
replay = s.finalize_settlement_draft(correction['id'], selected['version'], corrected['review_fingerprint'], 'close-' + correction['id'], db_path=path)
assert replay == corrected
assert counts(path) == before_replay
+106
View File
@@ -0,0 +1,106 @@
"""Dashboard lifecycle contract tests using Streamlit AppTest when available.
All data is synthetic; renders must not mutate/create state or databases. This
suite deliberately asserts the Issue 15 lifecycle UI is present before it exists.
"""
from __future__ import annotations
from pathlib import Path
import pytest
VIEW = Path(__file__).parents[1] / "dashboard" / "page_views" / "settlement_drafts.py"
def _app_wrapper(tmp_path: Path) -> Path:
wrapper = tmp_path / "settlement_panel_app.py"
wrapper.write_text(
"import os\nfrom dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
"render_settlement_drafts(db_path=os.environ.get(\"JARCA_LEDGER_DB_PATH\"))\n",
encoding="utf-8",
)
return wrapper
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
from streamlit.testing.v1 import AppTest
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
assert not app.exception
def test_monthly_and_assets_pages_invoke_shared_panel():
root = VIEW.parents[1]
pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")]
assert len(pages) == 2
for page in pages:
assert "render_settlement_drafts" in page.read_text(encoding="utf-8")
def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path):
pytest.importorskip("streamlit.testing.v1")
from streamlit.testing.v1 import AppTest
db_path = tmp_path / "dashboard-missing.sqlite3"
# Explicit synthetic DB path and no authenticated actor: render only.
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
assert not app.exception
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
assert expected in page
assert not db_path.exists(), "render-only Dashboard created a database"
def test_panel_actions_are_explicit_and_selection_state_bound_to_draft_version():
source = VIEW.read_text(encoding="utf-8")
for action in ("create_settlement_draft", "update_settlement_draft", "cancel_settlement_draft",
"refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft",
"approve_settlement_draft", "finalize_settlement_draft", "register_account_valuation",
"list_account_valuations", "correct_account_valuation"):
assert action in source, f"missing explicit action {action}"
assert "session_state" in source
assert "review_fingerprint" in source
assert "expected_version" in source or "version, actor" in source
assert "transaction" in source.lower() and "거래" in source
def test_closed_draft_view_exposes_immutable_evidence_and_correction_creation():
source = VIEW.read_text(encoding="utf-8")
for immutable in ("draft_version", "review_fingerprint", "final_run_id", "correction_of_run_id",
"basis_date", "created_by", "created_at", "updated_at", "memo"):
assert immutable in source, f"closed view omits {immutable}"
assert "correction_reason" in source
assert "completion" in source or "completed" in source
def test_render_path_contains_no_implicit_lifecycle_mutations():
source = VIEW.read_text(encoding="utf-8")
# All lifecycle mutations must be behind explicit submit/button branches;
# import/render must never invoke them unconditionally.
assert "if st.button" in source or "form_submit_button" in source
assert "render_settlement_drafts" in source
def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch):
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp.db import init_db
from jarca_ledger_mcp.application.assets_service import create_manual_asset
db_path = tmp_path / "lifecycle.sqlite3"
init_db(str(db_path))
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
assert seeded.get("success"), seeded
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
assert not app.exception
app.button[0].click().run()
assert not app.exception
assert app.button[0].label == "초안 생성 / 정정 초안"
assert len(app.selectbox[0].options) == 1
assert "active" in " ".join(item.value for item in app.caption)
# Exercise read-only review through the rendered explicit button.
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
review.click().run(timeout=10)
assert not app.exception
assert any("review_fingerprint" in item.value for item in app.caption)
assert not app.exception
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import ast
import json
import re
from pathlib import Path
ROOT = Path(__file__).parents[1]
DOCS = ROOT / "docs"
REQ = DOCS / "requirements/req-003_account-valuation-history.md"
def text(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_settlement_requirement_revision_is_additive_and_traceable() -> None:
source = REQ.read_text(encoding="utf-8")
_, raw, body = source.split("---", 2)
metadata = json.loads(raw)
assert metadata["id"] == "REQ-003"
assert metadata["revision"] == "1.1"
assert metadata["issue"] == "#15"
assert metadata["implementation_pr"] == "미정"
assert "#12" in body and "#13" in body
assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)]
assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)]
assert "schema 11→12" in body and "schema 12→13" in body
def test_draft_docs_cover_authority_dates_atomicity_and_read_only_contracts() -> None:
docs = "\n".join(text(path) for path in (
"docs/requirements/req-003_account-valuation-history.md", "docs/data-model.md",
"docs/interface-contract.md", "docs/project-structure.md", "docs/runtime-flow.md",
))
for marker in ("date_difference_days = valuation_date - basis_date", "age_days", "manual_substitute",
"candidate_amount", "authority amount NULL", "refresh_settlement_draft_targets",
"BEGIN IMMEDIATE", "persisted response", "one active draft", "correction_reason",
"unverified", "no-create", "transaction readiness"):
assert marker in docs, marker
def test_new_public_operations_are_fully_listed_from_contract() -> None:
interface = text("docs/interface-contract.md")
for operation in ("create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
"update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item",
"review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft",
"cancel_settlement_draft"):
assert f"{operation}(" in interface, operation
assert "review_fingerprint" in interface and "selection_mode" in interface
def test_settlement_acceptance_cases_are_individually_named_and_real() -> None:
import ast
req = REQ.read_text(encoding="utf-8").split("---", 2)[2]
section = req.split("## 7. 인수 기준", 1)[1].split("## 8. 오류", 1)[0]
cases = re.findall(r"^### REQ-003\.11-AC(\d+) — (.+)$", section, re.M)
assert len(cases) == 17
assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 18)]
files = {str(p.relative_to(ROOT)): p for p in (ROOT / "tests").glob("test_*.py")}
for block in re.split(r"(?=^### REQ-003\.11-AC)", section, flags=re.M)[1:]:
ref = re.search(r"`(tests/[^`]+\.py)::(test_[A-Za-z0-9_]+)`", block)
assert ref, block.splitlines()[0]
path, func = ref.groups()
assert path in files, path
names = {n.name for n in ast.walk(ast.parse(files[path].read_text(encoding="utf-8"))) if isinstance(n, ast.FunctionDef)}
assert func in names, (path, func)
for label in ("판정 기준", "검증 분류", "상태 불변조건"):
assert label in block, (block.splitlines()[0], label)
assert "## 12." not in req
requirements = req.split("## 5. 요구사항", 1)[1].split("## 6.", 1)[0]
assert requirements.count("**영향 범위**") == 11
new_requirement = requirements.split("### REQ-003.11 —", 1)[1]
for dimension in ("인터페이스", "영속성", "후속 사용 주체", "마이그레이션", "호환성", "배포 전제 조건"):
assert f"- {dimension}:" in new_requirement
def test_settlement_metadata_names_match_actual_public_contract() -> None:
interface = text('docs/interface-contract.md').split('## settlement-draft-service', 1)[1]
model = text('docs/data-model.md').split('## settlement-drafts', 1)[1]
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'representative_liability_definition'):
assert field in interface and field in model, field
for field in ('allow_missing_exclusions', 'allow_carried_before', 'allow_manual_substitute'):
assert field in interface
assert 'test_date_metadata_public_contract_names_survive_all_closed_consumers' in interface
def test_draft_mcp_signature_documents_internal_database_boundary() -> None:
interface = text("docs/interface-contract.md").split("## settlement-draft-service", 1)[1]
assert re.search(r"db_path.*(?:internal|service|gateway).*(?:not|omitted|제외).*(?:MCP|inputSchema)", interface, re.I | re.S), \
"document must distinguish internal db_path from MCP inputSchema"
assert "db_path: str | None = None" in interface
service = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
names = {n.name for n in ast.walk(service) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
assert "create_settlement_draft" in names
def test_legacy_close_fields_are_scoped_separately_from_draft_finalize() -> None:
interface = text("docs/interface-contract.md")
legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0]
draft = interface.split("## settlement-draft-service", 1)[1]
assert re.search(r"legacy direct close", legacy, re.I), "exact field list must be scoped to legacy close"
assert re.search(r"additive settlement-draft finalize response", legacy, re.I) and re.search(r"preserving common aliases", draft, re.I), "draft finalize output extension and retained common aliases must be distinguished"
assert not re.search(r"new.*(?:identical|same).*shape", draft, re.I)
def test_requirement_authority_scope_distinguishes_approved_drafts_and_automatic_selection() -> None:
req = REQ.read_text(encoding="utf-8")
constraints = req.split("## 6. 제약과 불변조건", 1)[1].split("## 7.", 1)[0]
assert re.search(r"automatic.*`exact`.*`as_of`", constraints, re.I), "future exclusion must explicitly scope automatic exact/as_of selection"
assert re.search(r"legacy direct close", constraints, re.I), \
"future exclusion must explicitly scope legacy direct close"
assert re.search(r"verified.*future.*manual_substitute", constraints, re.I | re.S), \
"verified future valuation may only enter an explicitly approved draft substitute"
assert all(term in constraints for term in ("reason", "review", "approval", "original")), \
"draft substitute must preserve reason, review, approval, and original dates"
assert re.search(r"unverified.*rejected.*unknown.*(?:any|all).*path", constraints, re.I | re.S), \
"unverified, rejected, or unknown amounts are never authority on any path"
assert "future_manual_asset" not in constraints
@@ -0,0 +1,24 @@
"""Additive draft lineage must preserve legacy persisted evidence shapes."""
import pytest
from jarca_ledger_mcp.application.assets_service import _closed_run_metadata
@pytest.mark.parametrize('field,public_field', [
('excluded_evidence', 'excluded_items'), ('carried_evidence', 'carried_items'),
])
def test_legacy_run_evidence_does_not_gain_draft_identity_keys(field, public_field):
evidence = {'source_type': 'manual_asset', 'source_id': 'synthetic', 'reason': 'missing_valuation_amount', 'currency': 'KRW'}
metadata = {'snapshot_run_id': 'legacy-run', 'draft_id': '', 'draft_version': 0, 'review_fingerprint': '', field: [evidence]}
result = _closed_run_metadata(metadata)
assert result[public_field] == [evidence]
assert 'blocking' not in result[public_field][0]
assert not {'draft_id', 'draft_version', 'review_fingerprint'} & result[public_field][0].keys()
def test_draft_evidence_retains_its_persisted_lineage_and_selection_reason():
evidence = {'source_type': 'account', 'source_id': 'synthetic', 'draft_id': 'draft', 'draft_version': 2, 'review_fingerprint': 'fingerprint', 'selection_mode': 'manual_substitute', 'selection_reason': 'synthetic statement', 'item_version': 2}
metadata = {'snapshot_run_id': 'draft-run', 'applied_evidence': [evidence]}
result = _closed_run_metadata(metadata)
assert result['applied_evidence'] == [evidence]
assert result['contains_substitutions']
+104
View File
@@ -0,0 +1,104 @@
"""Exercise settlement MCP lifecycle through the repository's real stdio server."""
import asyncio
from contextlib import closing
import inspect
import json
import os
import sqlite3
import sys
from pathlib import Path
import pytest
mcp = pytest.importorskip('mcp')
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
'update_settlement_draft', 'refresh_settlement_draft_targets',
'set_settlement_draft_item', 'review_settlement_draft',
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft')
ROOT = Path(__file__).resolve().parents[1]
def payload(result):
assert not result.isError, result
assert len(result.content) == 1 and result.content[0].type == 'text'
data = json.loads(result.content[0].text)
assert isinstance(data, dict) and isinstance(data.get('success'), bool)
return data
def rows(db, table):
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
with closing(sqlite3.connect(db)) as connection:
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
def test_real_stdio_startup_and_all_ten_lifecycle_tools(tmp_path):
asyncio.run(_exercise_stdio_lifecycle(tmp_path))
async def _exercise_stdio_lifecycle(tmp_path):
from jarca_ledger_mcp import service as service
db = tmp_path / 'mcp-runtime.sqlite3'
env = os.environ.copy()
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT='stdio', PYTHONDONTWRITEBYTECODE='1')
# The test owns this bounded stdio child; stdio_client reaps it on context exit.
params = StdioServerParameters(command=sys.executable, args=['-B', '-m', 'jarca_ledger_mcp.server'], env=env, cwd=str(ROOT))
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
initialized = await session.initialize()
assert initialized.serverInfo.name == 'jarca-ledger-mcp'
listed = await session.list_tools()
tool_map = {tool.name: tool for tool in listed.tools}
assert set(OPS) <= set(tool_map)
for name in OPS:
schema = tool_map[name].inputSchema
props = schema.get('properties', {})
assert 'db_path' not in props
parameters = inspect.signature(getattr(service, name)).parameters
assert set(props) == set(parameters) - {'db_path'}
assert set(schema.get('required', [])) == {key for key, value in parameters.items()
if key != 'db_path' and value.default is inspect.Parameter.empty}
# Seed through a real service database setup, never through MCP stubs.
from jarca_ledger_mcp.db import init_db
init_db(str(db))
account = service.create_ledger_account('Synthetic MCP cash', db_path=str(db))['account']['id']
service.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='mcp-value', db_path=str(db))
async def call(name, args):
return payload(await session.call_tool(name, args))
made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'})
assert made['success']
draft_id = made['draft']['id']
assert (await call('list_settlement_drafts', {'month':'2026-09'}))['drafts'][0]['id'] == draft_id
fetched = await call('get_settlement_draft', {'draft_id':draft_id})
draft = fetched['draft']
updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'})
draft = updated['draft']
refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'})
draft = refreshed['draft']
changed = await call('set_settlement_draft_item', {'draft_id':draft_id,'source_type':'account','source_id':account,'expected_version':draft['version'],'selected_by':'stdio-test','valuation_id':service.list_account_valuations(account, db_path=str(db))['valuations'][0]['id']})
draft = changed['draft']
reviewed = await call('review_settlement_draft', {'draft_id':draft_id})
assert reviewed['success'], reviewed
fingerprint = reviewed['review_fingerprint']
approved = await call('approve_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'approved_by':'stdio-test','approval_reason':'transport acceptance','approval_reference':'test'})
assert approved['success']
closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'})
assert closed['success'] and closed['totals']['net_worth'] == 100
before = (await call('get_settlement_draft', {'draft_id':draft_id}))
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
before_replay = {table: rows(db, table) for table in tables}
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
assert await call('get_settlement_draft', {'draft_id':draft_id}) == before
assert {table: rows(db, table) for table in tables} == before_replay
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
assert second['success']
cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled'
@@ -0,0 +1,58 @@
"""Operator feedback distinguishes read failures and partially committed saves."""
import pytest
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
from jarca_ledger_mcp.db import init_db
def panel(monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20)
assert not app.exception
return app
def control(elements, label):
return next(element for element in elements if element.label == label)
def test_panel_shows_safe_error_when_draft_listing_fails(tmp_path, monkeypatch):
path = tmp_path / 'missing.sqlite3'
monkeypatch.setattr(settlement_gateway, 'list_settlement_drafts', lambda *args, **kwargs: {
'success': False, 'error_code': 'storage_error', 'message': 'SYNTHETIC_PRIVATE_DIAGNOSTIC',
})
app = panel(monkeypatch, path)
assert app.error and 'storage_error' in ' '.join(element.value for element in app.error)
assert not any('초안이 없습니다' in element.value for element in app.info)
visible = ' '.join(element.value for elements in (app.error, app.info, app.caption) for element in elements)
assert 'SYNTHETIC_PRIVATE_DIAGNOSTIC' not in visible
assert not path.exists()
def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch):
path = str(tmp_path / 'partial.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
idempotency_key='synthetic-value', db_path=path)['success']
app = panel(monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
review_key = f"review_result_{draft['id']}"
assert app.session_state[review_key]['success']
control(app.text_input, '초안 메모 저장').set_value('Synthetic persisted memo')
control(app.text_input, '대상 ID').set_value('synthetic-nonexistent-target')
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
assert not app.exception
current = s.get_settlement_draft(draft['id'], db_path=path)
assert current['draft']['memo'] == 'Synthetic persisted memo'
assert current['draft']['version'] == draft['version'] + 1
assert len(current['items']) == 1 and current['items'][0]['source_id'] == account
assert app.error
assert any('메모는 저장되었지만 항목 변경은 실패했습니다' in element.value for element in app.warning)
with pytest.raises(KeyError):
app.session_state[review_key]
assert not any(element.label in {'검토 결과 승인', '최종 마감'} for element in app.button)
@@ -0,0 +1,154 @@
"""Real AppTest controls use synthetic SQLite, not mocked gateway results.
These are lower-level panel tests; existing dashboard authentication suites own
entrypoint authentication acceptance.
"""
import pytest
from streamlit.testing.v1 import AppTest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def control(elements, label):
return next(e for e in elements if e.label == label)
def app_for(tmp_path, monkeypatch, path):
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
appfile = tmp_path / 'panel.py'
appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n')
app = AppTest.from_file(str(appfile)).run(timeout=20)
assert not app.exception
return app
def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_database(tmp_path, monkeypatch):
path = str(tmp_path / 'panel.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
control(app.selectbox, '선택 방식').select('manual_substitute')
control(app.text_input, '선택 사유').set_value('Synthetic late statement')
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
assert not app.exception and not app.error
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
assert any('대체 평가자료를 포함한 월정산' in w.value for w in app.warning)
control(app.text_input, '승인 사유').set_value('Synthetic reviewer accepted date')
control(app.text_input, '승인 근거').set_value('Synthetic reference')
control(app.checkbox, '대체자료 승인').check()
control(app.button, '검토 결과 승인').click().run(timeout=20)
assert not app.exception and not app.error
control(app.button, '최종 마감').click().run(timeout=20)
assert not app.exception and not app.error
# AppTest 1.58 retains pre-rerun controls in the click-run delta tree,
# including in a flat synthetic app. Check immediate history here;
# control absence below uses a fresh instance, not a claim about live DOM.
assert any('불변 이력' in e.value for e in app.markdown)
drafts = s.list_settlement_drafts('2026-09', db_path=path)['drafts']
assert len(drafts) == 1 and drafts[0]['status'] == 'completed'
final = s.get_settlement_draft(drafts[0]['id'], db_path=path)['final_response']
assert final['totals']['net_worth'] == 100 and final['contains_substitutions']
assert final['items'][0]['selection_reason'] == 'Synthetic late statement'
# Reusing the stale tree also raises KeyError for removed selectbox state.
# Reopen the real panel against the same completed SQLite draft instead.
app = app_for(tmp_path, monkeypatch, path)
assert not app.exception and '불변 이력' in ' '.join(e.value for e in app.markdown)
assert not any(e.label == '최종 마감' for e in app.button)
assert not any(e.label == '검토 결과 승인' for e in app.button)
assert not any('현재 승인 대상 fingerprint:' in e.value for e in app.caption)
def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monkeypatch):
path = str(tmp_path / 'list.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.text_input, '대상 ID').set_value(account)
control(app.button, '평가자료 조회').click().run(timeout=20)
assert not app.exception
assert vid in ' '.join(e.value for e in app.json)
def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path, monkeypatch):
path = str(tmp_path / 'register.sqlite3')
init_db(path)
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
control(app.number_input, '금액').set_value(40)
control(app.selectbox, '평가 종류').select('loan_balance')
control(app.selectbox, '대표 부채 정의').select('principal_only')
control(app.selectbox, '확인 상태').select('verified')
control(app.text_input, '등록 idempotency key').set_value('synthetic-loan-value')
control(app.button, '평가자료 등록').click().run(timeout=20)
assert not app.exception and not app.error
values = s.list_account_valuations(loan, db_path=path)['valuations']
assert len(values) == 1 and values[0]['representative_liability_definition'] == 'principal_only'
assert values[0]['amount'] == 40
def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypatch):
path = str(tmp_path / 'edit.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
key = f"review_result_{draft['id']}"
old = app.session_state[key]['review_fingerprint']
assert any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
control(app.text_input, '초안 메모 저장').set_value('Synthetic edited memo')
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
assert not app.exception and not app.error
assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
assert not any(e.label == '검토 결과 승인' for e in app.button)
assert not any(e.label == '최종 마감' for e in app.button)
with pytest.raises(KeyError):
app.session_state[key]
current = s.get_settlement_draft(draft['id'], db_path=path)['draft']
assert current['version'] == draft['version'] + 1 and current['memo'] == 'Synthetic edited memo'
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
control(app.text_input, '승인 사유').set_value('Synthetic fresh review')
control(app.text_input, '승인 근거').set_value('Synthetic reference')
control(app.button, '검토 결과 승인').click().run(timeout=20)
assert not app.exception and not app.error
control(app.button, '최종 마감').click().run(timeout=20)
assert not app.exception and not app.error
assert s.get_settlement_draft(draft['id'], db_path=path)['draft']['status'] == 'completed'
def test_panel_correction_invalidates_review_without_registration_account_input(tmp_path, monkeypatch):
path = str(tmp_path / 'correction.sqlite3')
init_db(path)
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
app = app_for(tmp_path, monkeypatch, path)
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
assert control(app.text_input, '평가 등록 계좌 ID').value == ''
control(app.text_input, '정정할 valuation ID').set_value(vid)
control(app.number_input, '정정 금액').set_value(20)
control(app.text_input, '평가 정정 사유').set_value('Synthetic correction')
control(app.text_input, '평가 정정 키').set_value('synthetic-child')
control(app.selectbox, '정정 확인 상태').select('verified')
control(app.button, '평가자료 정정').click().run(timeout=20)
assert not app.exception and not app.error
assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
assert not any(e.label == '검토 결과 승인' for e in app.button)
detail = s.get_settlement_draft(draft['id'], db_path=path)
assert detail['items'][0]['review_needed'] and detail['items'][0]['valuation_id'] == vid
assert len(s.list_account_valuations(account, db_path=path)['valuations']) == 2
@@ -0,0 +1,42 @@
"""Consumer contracts for immutable draft-close evidence."""
import json
import sqlite3
from jarca_ledger_mcp import service
from jarca_ledger_mcp.db import init_db
def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
path = str(tmp_path / "synthetic.sqlite3")
init_db(path)
with sqlite3.connect(path) as conn:
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,draft_id,draft_version,review_fingerprint,applied_evidence,excluded_evidence,carried_evidence,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (
"run-draft", "2026-09-30", "conditional", "exact", "draft-1", 7, "fingerprint-1",
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","name":"Synthetic","amount":100,"currency":"KRW","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1","source_attributes":{"bucket":"real_estate","secret":"hidden"},"approval":{"reference":"approval-1","memo":"hidden"},"unknown":{"credential":"hidden"}}]),
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]),
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]),
"2026-09-30T02:00:00Z"))
conn.execute("INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,snapshot_run_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate','run-draft','','2026-09-30T02:00:00Z')")
conn.commit()
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
assert report["success"]
metadata = report["close_metadata"]
assert (metadata["draft_id"], metadata["draft_version"], metadata["review_fingerprint"]) == ("draft-1", 7, "fingerprint-1")
assert metadata["contains_carried"] is True and metadata["contains_substitutions"] is False and metadata["has_approved_missing"] is True
evidence = metadata["applied_evidence"][0]
for key in ("valuation_date", "age_days", "selection_mode", "reason", "selected_by", "selected_at", "item_version", "verification_status", "source_ref", "source_attributes", "approval"):
assert key in evidence
serialized = json.dumps(report)
assert "hidden" not in serialized and "credential" not in serialized and "unknown" not in serialized
series = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path)
assert series["points"][0]["draft_id"] == "draft-1"
assert series["points"][0]["contains_carried"] is True
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
assert monthly["source_coverage"]["assets"]["draft_id"] == "draft-1"
def test_active_cancelled_drafts_do_not_create_timeseries_points(tmp_path):
path = str(tmp_path / "synthetic.sqlite3")
init_db(path)
result = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path)
assert result["success"] and result["points"] == []
+129
View File
@@ -0,0 +1,129 @@
"""Strict selection, conditional authority and public close regressions."""
import pytest
from jarca_ledger_mcp import service as s
from jarca_ledger_mcp.db import init_db
def ok(r):
assert r['success'], r
return r
def setup(tmp_path, currency='KRW'):
path = str(tmp_path / 'scope.sqlite3')
init_db(path)
account = ok(s.create_ledger_account('Synthetic investment', type='investment', currency=currency, liquidity_bucket='investment', db_path=path))['account']['id']
return path, account
def valuation(path, account, key='v', **kwargs):
values = dict(basis_date='2026-09-30', amount=100, valuation_kind='total_value', verification_status='verified', idempotency_key=key, db_path=path)
values.update(kwargs)
return ok(s.register_account_valuation(account, **values))['valuation']['id']
def draft(path):
return ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
def select(path, d, account, value, **kwargs):
return s.set_settlement_draft_item(d['id'], 'account', account, d['version'], 'selector', valuation_id=value, db_path=path, **kwargs)
def test_automatic_selection_preserves_representative_priority_and_verified_status(tmp_path):
path, account = setup(tmp_path)
representative = valuation(path, account)
valuation(path, account, 'cash', amount=30, valuation_kind='cash_balance')
valuation(path, account, 'candidate', amount=900, verification_status='unverified')
d = draft(path)
review = ok(s.review_settlement_draft(d['id'], db_path=path))
row = next(x for x in review['items'] if x['source_id'] == account)
assert row['valuation_id'] == representative
assert review['totals']['net_worth'] == 100
def test_automatic_ambiguous_representative_remains_missing_until_explicit_choice(tmp_path):
path, account = setup(tmp_path)
chosen = valuation(path, account)
valuation(path, account, 'other', amount=200)
d = draft(path)
r = ok(s.review_settlement_draft(d['id'], db_path=path))
assert r['missing_items'] and r['totals']['net_worth'] == 0
selected = ok(select(path, d, account, chosen))['draft']
assert ok(s.review_settlement_draft(selected['id'], db_path=path))['totals']['net_worth'] == 100
@pytest.mark.parametrize('status,mode', [('unverified','exact'), ('unverified','carried_before'), ('rejected','manual_substitute')])
def test_invalid_verification_never_selected_as_confirmed_authority(tmp_path, status, mode):
path, account = setup(tmp_path)
value = valuation(path, account, verification_status=status, basis_date='2026-09-29' if mode == 'carried_before' else '2026-09-30')
d = draft(path)
result = select(path, d, account, value, selection_mode=mode, selection_reason='synthetic reason')
assert not result['success'], result
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['version'] == d['version']
def test_non_krw_candidate_is_missing_null_authority_not_converted(tmp_path):
path, account = setup(tmp_path, currency='USD')
value = valuation(path, account, currency='USD')
d = draft(path)
ok(select(path, d, account, value))
r = ok(s.review_settlement_draft(d['id'], db_path=path))
assert r['missing_items'] and not r['included_items']
assert r['items'][0]['amount'] is None
assert r['items'][0]['candidate_amount'] == 100
assert r['totals']['net_worth'] == 0 and r['warnings']
def test_manual_loan_is_representative_liability_and_future_manual_is_not_substitute(tmp_path):
path = str(tmp_path / 'manual-loan.sqlite3')
init_db(path)
ok(s.create_manual_asset('Synthetic liability', 40, '2026-09-30', 'loan', db_path=path))
future = ok(s.create_manual_asset('Future property', 100, '2026-10-05', 'real_estate', db_path=path))['manual_asset']['id']
d = draft(path)
r = ok(s.review_settlement_draft(d['id'], db_path=path))
assert r['totals']['total_assets'] == 0 and r['totals']['total_liabilities'] == 40
result = s.set_settlement_draft_item(d['id'], 'manual', future, d['version'], 'selector', selection_mode='manual_substitute', selection_reason='not a valuation id', db_path=path)
assert not result['success'], result
@pytest.mark.parametrize('bad', [None, True, 1, [], {}])
def test_invalid_creator_is_rejected_before_database_creation(tmp_path, bad):
path = tmp_path / 'absent' / 'db.sqlite3'
result = s.create_settlement_draft('2026-09', bad, 'key', db_path=str(path))
assert not result['success'] and result['error_code'] == 'validation_error', result
assert not path.parent.exists()
@pytest.mark.parametrize('bad', ['yes', 1, None])
def test_approval_flags_require_actual_boolean_and_preserve_state(tmp_path, bad):
path, account = setup(tmp_path)
valuation(path, account)
d = draft(path)
r = ok(s.review_settlement_draft(d['id'], db_path=path))
result = s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'synthetic reason', 'ref', allow_missing_exclusions=bad, db_path=path)
assert not result['success'] and result['error_code'] == 'validation_error', result
assert not ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['approved_by']
def test_finalized_substitute_retains_dates_flags_and_public_consumer_evidence(tmp_path):
path, account = setup(tmp_path)
v = valuation(path, account, basis_date='2026-10-05')
d = draft(path)
d = ok(select(path, d, account, v, selection_mode='manual_substitute', selection_reason='synthetic after-date'))['draft']
r = ok(s.review_settlement_draft(d['id'], db_path=path))
ok(s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'substitution accepted', 'ref', allow_manual_substitute=True, db_path=path))
final = ok(s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path))
assert final['contains_substitutions'] is True
assert final['has_approved_missing'] is False
assert final['contains_carried'] is False
assert final['valuation_date'] and 'age_days' in final and 'verification_status' in final and 'evidence_kind' in final
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
assert report['net_worth'] == 100
assert report['close_metadata']['contains_substitutions'] is True
evidence = report['close_metadata']['applied_evidence'][0]
assert evidence['valuation_date'] == '2026-10-05'
assert evidence['date_difference_days'] == 5 and evidence['selection_mode'] == 'manual_substitute'
assert evidence['selection_reason'] == 'synthetic after-date'
assert s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path) == final