feat: add persistent monthly settlement drafts and approved valuation selection
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user