feat: extend comprehensive monthly settlement snapshots (#18)
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 == "13"
|
||||
assert version == "14"
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Issue 18 comprehensive settlement acceptance contracts (synthetic SQLite only).
|
||||
|
||||
Public APIs are resolved at call time so the pre-implementation suite collects and
|
||||
fails with an actionable assertion rather than an import/attribute error.
|
||||
"""
|
||||
import hashlib
|
||||
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
|
||||
|
||||
|
||||
def api(name):
|
||||
fn = getattr(service, name, None)
|
||||
assert callable(fn), f"required comprehensive settlement API missing: service.{name}"
|
||||
return fn
|
||||
|
||||
|
||||
def dbfile(tmp_path):
|
||||
path = str(tmp_path / "comprehensive-synthetic.sqlite3")
|
||||
init_db(path)
|
||||
return path
|
||||
|
||||
|
||||
def ok(value):
|
||||
assert value.get("success") is True, value
|
||||
return value
|
||||
|
||||
|
||||
def new_draft(path, month="2026-09", key="draft"):
|
||||
created = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path)
|
||||
assert created.get("success") is True, created
|
||||
return ok(api("create_settlement_draft")(month, "synthetic-operator", key, db_path=path))["draft"]
|
||||
|
||||
|
||||
def close_draft(path, draft, idem="close"):
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "synthetic approval", "ref-synthetic", db_path=path)
|
||||
ok(approved)
|
||||
fresh = ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"]
|
||||
return api("finalize_settlement_draft")(draft["id"], fresh["version"], review["review_fingerprint"], idem, db_path=path)
|
||||
|
||||
|
||||
def test_draft_previews_six_totals_without_creating_run(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = result
|
||||
required = {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"}
|
||||
assert required <= review.keys() and required <= review["totals"].keys()
|
||||
assert review["aggregation_version"] == "comprehensive-v1"
|
||||
assert review["snapshot_schema_version"] == 1
|
||||
assert isinstance(review["cashflow_items"], list)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_closed_header_persists_six_totals_policy_and_versions(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
result = ok(close_draft(path, draft))
|
||||
assert result.get("run_id")
|
||||
saved = result
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
assert key in saved
|
||||
assert saved["aggregation_version"] == "comprehensive-v1"
|
||||
assert saved["snapshot_schema_version"] == 1
|
||||
with sqlite3.connect(path) as db:
|
||||
columns = {row[1] for row in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns
|
||||
row = db.execute("SELECT income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id=?", (saved["run_id"],)).fetchone()
|
||||
assert len(row) == 6
|
||||
|
||||
|
||||
def test_asset_detail_preserves_raw_evidence_and_signed_liability_contributions(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
a = service.create_ledger_account("Synthetic debt A", type="loan", liquidity_bucket="loan", db_path=path)["account"]["id"]
|
||||
b = service.create_ledger_account("Synthetic debt B", type="loan", liquidity_bucket="loan", db_path=path)["account"]["id"]
|
||||
for account, amount, key in ((a, -900, "loan-a"), (b, 200, "loan-b")):
|
||||
v = service.register_account_valuation(account, "2026-09-30", amount, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", idempotency_key=key, db_path=path)
|
||||
assert v["success"], v
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
entries = review.get("items", [])
|
||||
assert entries
|
||||
assert any(x.get("amount") in (-900, 200) for x in entries)
|
||||
result = ok(close_draft(path, draft))
|
||||
evidence = json.dumps(result, sort_keys=True)
|
||||
assert "applied_amount" in evidence
|
||||
assert "-900" in evidence and "200" in evidence
|
||||
|
||||
|
||||
def test_source_snapshot_preserves_reproducible_values_without_raw_secrets(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
account = service.create_ledger_account("Synthetic evidence", last_balance=42, liquidity_bucket="cash", db_path=path)["account"]["id"]
|
||||
# Evidence is synthetic; no credentials or user-provided freeform payloads.
|
||||
v = service.register_account_valuation(account, "2026-09-30", 42, valuation_kind="balance", verification_status="verified", idempotency_key="value", source_ref="synthetic-ref", db_path=path)
|
||||
assert v["success"], v
|
||||
result = ok(close_draft(path, new_draft(path)))
|
||||
saved = result
|
||||
assert saved.get("source_snapshot") or saved.get("source_snapshot_json")
|
||||
encoded = json.dumps(saved, sort_keys=True)
|
||||
assert "synthetic-ref" in encoded
|
||||
for secret in ("raw_payload", "password", "cookie", "token"):
|
||||
assert secret not in encoded.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["ledger_amount", "category_classification"])
|
||||
def test_source_policy_and_category_drift_invalidate_approval(tmp_path, mutation):
|
||||
path = dbfile(tmp_path)
|
||||
category = service.create_ledger_category("Food", "Meal", cashflow_kind="expense", db_path=path)
|
||||
assert category.get("success") is True, category
|
||||
transaction = service.create_ledger_transaction(transaction_date="2026-09-12", type="expense", amount=17, category="Food", subcategory="Meal", description="synthetic drift", dedupe=False, db_path=path)
|
||||
assert transaction.get("success") is True, transaction
|
||||
txid = transaction.get("transaction", transaction).get("id")
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
if mutation == "ledger_amount":
|
||||
db.execute("UPDATE transactions SET amount=amount+1 WHERE id=?", (txid,))
|
||||
else:
|
||||
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
assert "ledger_categories" in tables
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(ledger_categories)")}
|
||||
assert {"category", "subcategory", "cashflow_kind"} <= cols
|
||||
db.execute("UPDATE ledger_categories SET cashflow_kind='income' WHERE category=? AND subcategory=?", ("Food", "Meal"))
|
||||
db.commit()
|
||||
closed = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "close", db_path=path)
|
||||
assert closed.get("success") is False, closed
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_point", ["header", "assets", "cashflow", "completion"])
|
||||
def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_point):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Work", "Salary", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-10", "income", 50, "Work", "Salary", db_path=path))
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
target = {"header":"asset_snapshot_runs", "assets":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
|
||||
# Missing future table is an explicit RED assertion, not a SQL error masquerading as rollback.
|
||||
assert db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (target,)).fetchone(), f"required comprehensive table missing: {target}"
|
||||
event = "UPDATE" if failure_point == "completion" else "INSERT"
|
||||
condition = " WHEN NEW.status='completed'" if failure_point == "completion" else ""
|
||||
db.execute(f"CREATE TRIGGER fail_{failure_point} BEFORE {event} ON {target}{condition} BEGIN SELECT RAISE(ABORT,'synthetic injected failure'); END")
|
||||
failed = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "close", db_path=path)
|
||||
assert failed.get("success") is False
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
|
||||
|
||||
|
||||
def test_close_rejects_stored_detail_sum_tampering(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Work", "Salary", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-10", "income", 50, "Work", "Salary", db_path=path))
|
||||
draft = new_draft(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_cashflow_snapshots'").fetchone(), "cashflow detail storage is required"
|
||||
db.execute("CREATE TRIGGER corrupt_saved_detail AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE settlement_cashflow_snapshots SET amount=amount+1 WHERE id=NEW.id; END")
|
||||
result = close_draft(path, draft)
|
||||
assert result.get("success") is False, result
|
||||
with sqlite3.connect(path) as db:
|
||||
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
assert db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
|
||||
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
|
||||
|
||||
|
||||
def test_replay_and_correction_keep_each_run_immutable(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
first = new_draft(path)
|
||||
initial = ok(close_draft(path, first, "first-close"))
|
||||
replay = ok(api("finalize_settlement_draft")(first["id"], first["version"], initial["review_fingerprint"], "first-close", db_path=path))
|
||||
assert replay == initial
|
||||
correction = ok(api("create_settlement_draft")("2026-09", "synthetic-operator", "correction", correction_of_run_id=initial["run_id"], correction_reason="synthetic correction", db_path=path))["draft"]
|
||||
corrected = ok(close_draft(path, correction, "correction-close"))
|
||||
assert corrected["run_id"] != initial["run_id"]
|
||||
old = ok(api("get_monthly_settlement_snapshot")(run_id=initial["run_id"], db_path=path))
|
||||
new = ok(api("get_monthly_settlement_snapshot")(run_id=corrected["run_id"], db_path=path))
|
||||
assert old["run_id"] == initial["run_id"] and new["run_id"] == corrected["run_id"]
|
||||
|
||||
|
||||
def test_concurrent_close_retries_return_one_saved_result(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
def close():
|
||||
return api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "same-key", db_path=path)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(lambda _: close(), range(4)))
|
||||
successes = [r for r in results if r.get("success")]
|
||||
assert len(successes) == 4
|
||||
assert len({r["run_id"] for r in successes}) == 1
|
||||
|
||||
|
||||
def test_readonly_snapshot_missing_database_does_not_create_file(tmp_path):
|
||||
path = tmp_path / "missing.sqlite3"
|
||||
result = api("get_monthly_settlement_snapshot")(month="2026-09", db_path=str(path))
|
||||
assert result.get("success") is False
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_saved_reader_has_six_totals_and_legacy_null_is_not_live_zero(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
# Empty database has no run; a saved reader must identify no snapshot, never fabricate current totals.
|
||||
result = api("get_monthly_settlement_snapshot")(month="2026-09", db_path=path)
|
||||
assert result.get("success") is False or result.get("source") == "settlement_snapshot"
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
assert key not in result or result[key] is None
|
||||
|
||||
|
||||
def test_cashflow_and_legacy_api_shapes_remain_distinct(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
before = service.get_monthly_cashflow_report("2026-09", db_path=path)
|
||||
assert before.get("success") is True
|
||||
live_keys = set(before)
|
||||
draft = new_draft(path)
|
||||
review_result = ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
review = review_result
|
||||
assert {"income_total", "expense_total", "net_cashflow"} <= review.keys()
|
||||
after = service.get_monthly_cashflow_report("2026-09", db_path=path)
|
||||
assert set(after) == live_keys
|
||||
|
||||
|
||||
def test_policy_setter_persists_explicit_lists_and_changes_review_fingerprint(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
before = ok(api("review_settlement_draft")(draft["id"], db_path=path))["review_fingerprint"]
|
||||
policy = {"savings_categories":[{"category":"Savings","subcategory":"Reserve"}], "category_decisions":[]}
|
||||
changed = ok(api("set_settlement_cashflow_policy")(draft["id"], draft["version"], "synthetic-operator", policy, db_path=path))
|
||||
updated = changed["draft"]
|
||||
after = ok(api("review_settlement_draft")(draft["id"], db_path=path))["review_fingerprint"]
|
||||
assert updated["version"] == draft["version"] + 1
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_completed_get_and_saved_snapshot_ignore_later_live_ledger_changes(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
saved = ok(close_draft(path, draft))
|
||||
ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path))
|
||||
history = ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
fetched = ok(api("get_monthly_settlement_snapshot")(run_id=saved["run_id"], db_path=path))
|
||||
assert history["final_response"] == saved
|
||||
assert fetched["income_total"] == saved["income_total"]
|
||||
|
||||
|
||||
def _historical_asset_review_fingerprint(path, draft, review):
|
||||
"""Exact f67 asset-only serial, with no new cashflow/policy/version inputs."""
|
||||
from jarca_ledger_mcp.application import settlement_draft_service as old
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
stored = old._items(conn, draft["id"])
|
||||
states = []
|
||||
for item in stored:
|
||||
candidates = [dict(v) for v in conn.execute("SELECT * FROM account_valuations WHERE account_id=? ORDER BY id", (item["source_id"],))] if item["source_type"] == "account" else []
|
||||
states.append({"stored": item, "current": old._current_semantics(conn, item), "candidates": candidates})
|
||||
serial = {"draft_id": draft["id"], "version": draft["version"], "basis_date": draft["basis_date"], "states": states,
|
||||
"warnings": [], "latest_run": old._latest_close(conn, draft["basis_date"]), "correction_parent": draft["correction_of_run_id"],
|
||||
"totals": {k: review[k] for k in ("total_assets", "total_liabilities", "net_worth")}}
|
||||
return hashlib.sha256(json.dumps(serial, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def test_legacy_asset_approval_requires_comprehensive_review_before_close(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not review["warnings"], "this historical serial fixture is deliberately warning-free"
|
||||
historic = _historical_asset_review_fingerprint(path, draft, review)
|
||||
if "aggregation_version" not in review:
|
||||
assert historic == review["review_fingerprint"], "historical fixture must exactly match the original baseline approval"
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by='synthetic-reviewer',approved_at='2026-09-30',approval_reason='historic asset review',approval_reference='historic-ref',approval_flags=? WHERE id=?",
|
||||
(historic, draft["version"], json.dumps({"allow_missing_exclusions": False, "allow_carried_before": False, "allow_manual_substitute": False}), draft["id"]))
|
||||
refused = service.finalize_settlement_draft(draft["id"], draft["version"], historic, "historic-close", db_path=path)
|
||||
assert refused["success"] is False, "an asset-only historical approval cannot authorize comprehensive cashflow interpretation"
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT status,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "")
|
||||
fresh = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert fresh["review_fingerprint"] != historic
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "synthetic-reviewer", "comprehensive reapproval", "new-ref", db_path=path))
|
||||
saved = ok(service.finalize_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "fresh-close", db_path=path))
|
||||
ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path))
|
||||
assert ok(service.get_settlement_draft(draft["id"], db_path=path))["final_response"] == saved
|
||||
assert ok(service.finalize_settlement_draft(draft["id"], draft["version"], fresh["review_fingerprint"], "fresh-close", db_path=path)) == saved
|
||||
|
||||
|
||||
def _seed_reader_run(path, comprehensive=False, source=None):
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES ('synthetic-run','2026-09-30','complete','2026-09-30')")
|
||||
if comprehensive:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET settlement_month='2026-09',currency='KRW',income_total=20,expense_total=0,net_cashflow=20,total_assets=0,total_liabilities=0,net_worth=0,cashflow_policy_json='{}',aggregation_version='comprehensive-v1',snapshot_schema_version=1,source_snapshot_json=? WHERE id='synthetic-run'", (json.dumps(source or {}),))
|
||||
conn.execute("INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES ('synthetic-detail','synthetic-run','income','Salary','Monthly',20,1,'KRW')")
|
||||
|
||||
|
||||
def test_legacy_reader_preserves_unrecorded_currency_and_marks_legacy(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-legacy-null.sqlite3")
|
||||
_seed_reader_run(path)
|
||||
result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path))
|
||||
assert result["legacy"] is True and result["is_comprehensive"] is False
|
||||
assert result["currency"] is None, "legacy currency must not be invented as KRW"
|
||||
assert all(value is None for value in result["totals"].values())
|
||||
|
||||
|
||||
def test_legacy_reader_accepts_original_header_without_migration(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-old-columns.sqlite3")
|
||||
_seed_reader_run(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
for column in ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"):
|
||||
conn.execute(f"ALTER TABLE asset_snapshot_runs DROP COLUMN {column}")
|
||||
for kwargs in ({"run_id": "synthetic-run"}, {"month": "2026-09"}):
|
||||
result = get_monthly_settlement_snapshot(db_path=path, **kwargs)
|
||||
assert result["success"], "pre-comprehensive headers must be read without new-column SQL"
|
||||
assert all(value is None for value in result["totals"].values())
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert "settlement_month" not in {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
|
||||
|
||||
def test_stored_reader_keeps_required_transaction_date(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-source-date.sqlite3")
|
||||
tx = {"id": "synthetic-tx", "transaction_date": "2026-09-01", "type": "income", "amount": 20, "currency": "KRW", "category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "applied_amount": 20}
|
||||
_seed_reader_run(path, True, {"transactions": [tx], "assets": []})
|
||||
result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path))
|
||||
assert result["source_snapshot"]["transactions"] == [tx]
|
||||
|
||||
|
||||
def test_stored_reader_allowlists_source_root_and_nested_decision(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-source-private.sqlite3")
|
||||
marker = "SYNTHETIC_PRIVATE_SNAPSHOT"
|
||||
tx = {"id": "synthetic-tx", "transaction_date": "2026-09-01", "type": "income", "amount": 20, "currency": "KRW", "category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "applied_amount": 20, "decision": {"category": "Salary", "subcategory": "Monthly", "cashflow_kind": "income", "reason": "synthetic decision", "private_note": marker}}
|
||||
_seed_reader_run(path, True, {"transactions": [tx], "assets": [], "private_note": marker})
|
||||
result = ok(get_monthly_settlement_snapshot(run_id="synthetic-run", db_path=path))
|
||||
assert marker not in json.dumps(result), "unknown root and nested source fields are not public financial evidence"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["header_total", "asset_amount", "asset_currency", "header_source", "header_policy", "header_version"])
|
||||
def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Salary", "Monthly", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-01", "income", 20, "Salary", "Monthly", db_path=path))
|
||||
draft = new_draft(path)
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
if target.startswith("asset_"):
|
||||
column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'")
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
|
||||
else:
|
||||
column, expression = {"header_total": ("income_total", "income_total+1"), "header_source": ("source_snapshot_json", "'{}'"), "header_policy": ("cashflow_policy_json", "'{\"savings_categories\":[{\"category\":\"Other\",\"subcategory\":\"Pair\"}],\"category_decisions\":[]}'"), "header_version": ("aggregation_version", "'unknown-version'")}[target]
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE asset_snapshot_runs SET {column}={expression} WHERE id=NEW.settlement_run_id; END"
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(trigger)
|
||||
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "tamper-close", db_path=path)
|
||||
assert result["success"] is False, "persisted header/detail must match the approved result before commit"
|
||||
with sqlite3.connect(path) as conn:
|
||||
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT status,final_run_id,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "", "")
|
||||
|
||||
|
||||
def test_stored_aggregation_version_drift_invalidates_approval(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE settlement_drafts SET aggregation_version='unknown-version' WHERE id=?", (draft["id"],))
|
||||
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "version-drift", db_path=path)
|
||||
assert result["success"] is False, "unknown stored logic version cannot silently use current aggregation"
|
||||
|
||||
|
||||
def test_completed_review_returns_saved_totals_and_fingerprint(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = new_draft(path)
|
||||
saved = ok(close_draft(path, draft))
|
||||
ok(service.create_ledger_category("Later", "Income", cashflow_kind="income", db_path=path))
|
||||
ok(service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Income", db_path=path))
|
||||
review = ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert review["totals"] == saved["totals"]
|
||||
assert review["review_fingerprint"] == saved["review_fingerprint"]
|
||||
|
||||
|
||||
def test_excluded_loan_source_has_no_applied_contribution(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
values = []
|
||||
for name, amount in (("Synthetic A", -900), ("Synthetic B", 200)):
|
||||
account = ok(service.create_ledger_account(name, type="loan", liquidity_bucket="loan", db_path=path))["account"]["id"]
|
||||
value = ok(service.register_account_valuation(account, "2026-09-30", amount, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", idempotency_key=name, db_path=path))["valuation"]
|
||||
values.append((account, value["id"]))
|
||||
draft = new_draft(path)
|
||||
edited = ok(service.set_settlement_draft_item(draft["id"], "account", values[1][0], draft["version"], "operator", valuation_id=values[1][1], included=False, exclusion_reason="synthetic exclusion", db_path=path))
|
||||
review = edited["review"]
|
||||
assert review["total_liabilities"] == 900
|
||||
excluded = next(x for x in review["source_snapshot"]["assets"] if x["source_id"] == values[1][0])
|
||||
assert excluded["amount"] == 200 and excluded["applied_amount"] is None
|
||||
|
||||
|
||||
def test_cashflow_detail_currency_matches_header_without_losing_original(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
ok(service.create_ledger_category("Salary", "Monthly", cashflow_kind="income", db_path=path))
|
||||
tx = ok(service.create_ledger_transaction("2026-09-01", "income", 20, "Salary", "Monthly", db_path=path))["transaction"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE transactions SET currency='krw' WHERE id=?", (tx["id"],))
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
assert saved["cashflow_items"][0]["currency"] == "KRW"
|
||||
assert saved["source_snapshot"]["transactions"][0]["currency"] == "krw"
|
||||
|
||||
|
||||
def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
snapshot = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
asset = snapshot["source_snapshot"]["assets"][0]
|
||||
assert asset.get("authority_source") == "manual_assets"
|
||||
assert asset.get("is_liability") is False
|
||||
assert asset.get("status") == "ready"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version,aggregation_version", [(1, None), (1, ""), (1, "unknown-version"), (2, "comprehensive-v1")])
|
||||
def test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes(tmp_path, schema_version, aggregation_version):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET snapshot_schema_version=?,aggregation_version=? WHERE id=?", (schema_version, aggregation_version, saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
for query in ({"run_id": saved["run_id"]}, {"month": "2026-09"}):
|
||||
result = service.get_monthly_settlement_snapshot(db_path=path, **query)
|
||||
assert result["success"] is False, "unsupported comprehensive logic/schema is not a recognized authoritative result"
|
||||
assert result["error_code"] == "db_error"
|
||||
assert "totals" not in result and "cashflow_items" not in result
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("aggregation_version", ["unknown-version", "comprehensive-v1", ""])
|
||||
def test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy(tmp_path, aggregation_version):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET snapshot_schema_version=NULL,aggregation_version=? WHERE id=?", (aggregation_version, saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
for query in ({"run_id": saved["run_id"]}, {"month": "2026-09"}):
|
||||
result = service.get_monthly_settlement_snapshot(db_path=path, **query)
|
||||
assert result["success"] is False, "partial version metadata is not a genuine NULL-version legacy run"
|
||||
assert result["error_code"] == "db_error"
|
||||
assert "totals" not in result
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
conn.execute("UPDATE asset_snapshot_runs SET aggregation_version=NULL WHERE id=?", (saved["run_id"],))
|
||||
legacy = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
assert legacy["legacy"] and not legacy["is_comprehensive"]
|
||||
assert all(value is None for value in legacy["totals"].values())
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Issue 18 adapter/UI contracts; synthetic paths and identities only."""
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
POLICY = "set_settlement_cashflow_policy"
|
||||
READER = "get_monthly_settlement_snapshot"
|
||||
|
||||
|
||||
def _api(name):
|
||||
fn = getattr(service, name, None)
|
||||
assert callable(fn), f"required comprehensive settlement API missing: service.{name}"
|
||||
return fn
|
||||
|
||||
|
||||
def test_shared_adapters_expose_policy_and_saved_snapshot_contracts(monkeypatch, tmp_path):
|
||||
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
|
||||
contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts")
|
||||
server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server")
|
||||
setter_owner = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
|
||||
reader_owner = importlib.import_module("jarca_ledger_mcp.application.monthly_settlement_service")
|
||||
expected = {POLICY: ["draft_id", "expected_version", "updated_by", "cashflow_policy", "db_path"], READER: ["month", "run_id", "db_path"]}
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(tmp_path / "synthetic.sqlite3"))
|
||||
calls = []
|
||||
for name, parameters in expected.items():
|
||||
fn = _api(name)
|
||||
assert list(inspect.signature(fn).parameters) == parameters
|
||||
gateway_fn = getattr(gateway, name, None)
|
||||
assert callable(gateway_fn), f"gateway missing {name}"
|
||||
assert inspect.signature(gateway_fn) == inspect.signature(fn)
|
||||
marker = {"success": True, "operation": name}
|
||||
def stub(*args, _name=name, _marker=marker, **kwargs):
|
||||
calls.append((_name, kwargs.get("db_path")))
|
||||
return _marker
|
||||
monkeypatch.setattr(setter_owner if name == POLICY else reader_owner, name, stub)
|
||||
kwargs = {key: "synthetic" for key in parameters if key != "db_path"}
|
||||
if name == POLICY:
|
||||
kwargs["expected_version"] = 1
|
||||
kwargs["cashflow_policy"] = {"savings_categories": [], "category_decisions": []}
|
||||
path = str(tmp_path / "synthetic.sqlite3")
|
||||
kwargs["db_path"] = path
|
||||
assert gateway_fn(**kwargs) == marker
|
||||
assert calls[-1] == (name, path)
|
||||
assert name in contracts.TOOL_CONTRACTS
|
||||
tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools
|
||||
assert name in tools
|
||||
tool = tools[name]
|
||||
call = getattr(tool, "fn", getattr(tool, "function", None))
|
||||
assert callable(call)
|
||||
tool_kwargs = dict(kwargs)
|
||||
tool_kwargs.pop("db_path")
|
||||
boundary_calls = []
|
||||
def boundary(*args, **forwarded):
|
||||
bound = inspect.signature(fn).bind(*args, **forwarded)
|
||||
boundary_calls.append(dict(bound.arguments))
|
||||
return marker
|
||||
# Existing MCP contracts bind facade callables at import time. Test the
|
||||
# actual MCP/contract boundary, not a later owner-module monkeypatch.
|
||||
monkeypatch.setattr(contracts, name, boundary)
|
||||
assert call(**tool_kwargs) == marker
|
||||
assert boundary_calls[-1] == tool_kwargs
|
||||
schema = contracts.TOOL_CONTRACTS[name]
|
||||
assert callable(schema)
|
||||
tool_schema = tool.parameters if hasattr(tool, "parameters") else tool.inputSchema
|
||||
assert "db_path" not in str(tool_schema)
|
||||
|
||||
|
||||
def test_readonly_reader_on_missing_path_does_not_create_database(tmp_path):
|
||||
path = tmp_path / "absent.sqlite3"
|
||||
result = _api(READER)(month="2026-09", db_path=str(path))
|
||||
assert result.get("success") is False
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mutation():
|
||||
root = Path(__file__).parents[1]
|
||||
drafts = root / "dashboard/page_views/settlement_drafts.py"
|
||||
monthly = root / "dashboard/page_views/monthly_settlement.py"
|
||||
source = drafts.read_text(encoding="utf-8")
|
||||
monthly_source = monthly.read_text(encoding="utf-8")
|
||||
combined = source + "\n" + monthly_source
|
||||
assert "종합 월정산 정책" in combined
|
||||
assert "저장된 월정산 스냅샷" in combined
|
||||
assert POLICY in source or POLICY in monthly_source
|
||||
assert READER in source or READER in monthly_source
|
||||
assert "settlement_gateway" in combined
|
||||
assert "sqlite3.connect" not in combined and "CREATE TABLE" not in combined
|
||||
assert "form_submit_button" in source or "if st.button" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_entrypoint_keeps_authentication_before_database_resolution():
|
||||
app = (Path(__file__).parents[1] / "dashboard/app.py").read_text(encoding="utf-8")
|
||||
assert app.index("require_dashboard_authentication()") < app.index("get_db_path()")
|
||||
|
||||
|
||||
def test_new_contracts_keep_asset_direct_close_signatures_and_live_report_api():
|
||||
assets = importlib.import_module("jarca_ledger_mcp.application.assets_service")
|
||||
report = importlib.import_module("jarca_ledger_mcp.application.transaction_report_services")
|
||||
direct = inspect.signature(assets.close_asset_snapshot)
|
||||
assert "cashflow_policy" not in direct.parameters
|
||||
assert list(inspect.signature(report.get_monthly_cashflow_report).parameters)[0] == "month"
|
||||
assert callable(_api(POLICY)) and callable(_api(READER))
|
||||
|
||||
|
||||
def _draft_page(monkeypatch, status="active"):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
policy = {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": []}
|
||||
draft = {"id": "synthetic-draft", "month": "2026-09", "basis_date": "2026-09-30", "status": status, "version": 1, "cashflow_policy": policy, "final_run_id": "synthetic-run"}
|
||||
final = {"income_total": 101, "expense_total": 0, "net_cashflow": 101, "total_assets": 0, "total_liabilities": 0, "net_worth": 0, "aggregation_version": "comprehensive-v1", "cashflow_items": [{"cashflow_kind": "income", "category": "Salary", "subcategory": "Monthly", "amount": 101, "transaction_count": 1, "currency": "KRW"}]}
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": [draft]})
|
||||
monkeypatch.setattr(settlement_gateway, "get_settlement_draft", lambda *a, **k: {"success": True, "draft": draft, "items": [], "review": {"review_fingerprint": "synthetic-fp"}, "final_response": final if status == "completed" else None})
|
||||
page = AppTest.from_string("from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path='synthetic-unused.sqlite3', month='2026-09')", default_timeout=30).run()
|
||||
assert not page.exception
|
||||
return page, policy
|
||||
|
||||
|
||||
def test_draft_policy_editor_uses_saved_draft_policy(monkeypatch):
|
||||
import json
|
||||
page, policy = _draft_page(monkeypatch)
|
||||
assert json.loads(page.text_area[0].value) == policy, "rendering must not replace persisted policy with an empty default"
|
||||
|
||||
|
||||
def test_completed_draft_page_displays_saved_response_and_cashflow_kind(monkeypatch):
|
||||
page, _ = _draft_page(monkeypatch, "completed")
|
||||
assert len(page.dataframe) == 1, "completed get exposes saved final_response at the response root"
|
||||
frame = page.dataframe[0].value
|
||||
assert "cashflow_kind" in frame.columns
|
||||
assert frame.iloc[0]["cashflow_kind"] == "income"
|
||||
assert sum(item.value == "### 불변 이력" for item in page.markdown) == 1
|
||||
|
||||
|
||||
# Real transport/UI acceptance coverage. All identities and ledger values are synthetic.
|
||||
def test_real_comprehensive_stdio_policy_close_and_saved_snapshot(tmp_path):
|
||||
import asyncio, json, os, sqlite3, sys
|
||||
from contextlib import closing
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
db = tmp_path / "stdio-comprehensive.sqlite3"
|
||||
init_db(str(db))
|
||||
category = service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))
|
||||
assert category["success"]
|
||||
assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"]
|
||||
assert service.create_ledger_category("Synthetic food", "Meal", cashflow_kind="expense", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-06", "expense", 10, "Synthetic food", "Meal", db_path=str(db))["success"]
|
||||
assert service.create_ledger_category("Synthetic refund", "Meal", cashflow_kind="expense", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-07", "refund", 3, "Synthetic refund", "Meal", db_path=str(db))["success"]
|
||||
assert service.create_ledger_category("Synthetic savings", "Reserve", cashflow_kind="investment", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-08", "expense", 5, "Synthetic savings", "Reserve", db_path=str(db))["success"]
|
||||
acct = service.create_ledger_account("Synthetic checking", last_balance=100, liquidity_bucket="cash", db_path=str(db))["account"]["id"]
|
||||
assert service.register_account_valuation(acct, "2026-09-30", 100, verification_status="verified", idempotency_key="stdio-valuation", db_path=str(db))["success"]
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
# Deliberately construct a minimal environment; no caller auth/Gitea variables survive.
|
||||
env = {k: os.environ[k] for k in ("PATH", "HOME", "LANG") if k in os.environ}
|
||||
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT="stdio", PYTHONDONTWRITEBYTECODE="1")
|
||||
params = StdioServerParameters(command=sys.executable, args=["-B", "-m", "jarca_ledger_mcp.server"], env=env, cwd=str(root))
|
||||
|
||||
async def exercise():
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
tools = {t.name: t for t in (await session.list_tools()).tools}
|
||||
for name in (POLICY, READER):
|
||||
assert name in tools
|
||||
schema = tools[name].inputSchema
|
||||
assert "db_path" not in schema.get("properties", {})
|
||||
assert set(schema["properties"]) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"}
|
||||
async def call(name, args):
|
||||
result = await session.call_tool(name, args)
|
||||
assert not result.isError
|
||||
return json.loads(result.content[0].text)
|
||||
made = await call("create_settlement_draft", {"month":"2026-09", "created_by":"synthetic", "idempotency_key":"stdio-draft"})
|
||||
draft = made["draft"]
|
||||
policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]}
|
||||
changed = await call(POLICY, {"draft_id":draft["id"], "expected_version":draft["version"], "updated_by":"synthetic", "cashflow_policy":policy})
|
||||
draft = changed["draft"]
|
||||
assert draft["version"] == 2 and draft["cashflow_policy"] == policy
|
||||
review = await call("review_settlement_draft", {"draft_id":draft["id"]})
|
||||
for key in ("income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth"):
|
||||
assert key in review
|
||||
assert review["cashflow_items"] and all("cashflow_kind" in row for row in review["cashflow_items"])
|
||||
assert any(t.get("applied_amount") is not None for t in review.get("source_snapshot", {}).get("transactions", []))
|
||||
approved = await call("approve_settlement_draft", {"draft_id":draft["id"], "expected_version":draft["version"], "review_fingerprint":review["review_fingerprint"], "approved_by":"synthetic", "approval_reason":"acceptance", "approval_reference":"synthetic-ref"})
|
||||
closed = await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"})
|
||||
run = closed["run_id"]
|
||||
exact = await call(READER, {"run_id":run})
|
||||
monthly = await call(READER, {"month":"2026-09"})
|
||||
assert exact["run_id"] == run and monthly["run_id"] == run
|
||||
before = (await call("get_settlement_draft", {"draft_id":draft["id"]}))
|
||||
assert await call("finalize_settlement_draft", {"draft_id":draft["id"], "expected_version":approved["draft"]["version"], "review_fingerprint":review["review_fingerprint"], "idempotency_key":"stdio-close"}) == closed
|
||||
assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before
|
||||
assert await call(READER, {"run_id":run}) == exact
|
||||
# Later ledger writes must not rewrite either saved surface.
|
||||
service.create_ledger_category("Later", "Synthetic", cashflow_kind="income", db_path=str(db))
|
||||
service.create_ledger_transaction("2026-09-20", "income", 99, "Later", "Synthetic", db_path=str(db))
|
||||
assert await call(READER, {"run_id":run}) == exact
|
||||
assert (await call("get_settlement_draft", {"draft_id":draft["id"]})) == before
|
||||
return run
|
||||
run = asyncio.run(asyncio.wait_for(exercise(), timeout=60))
|
||||
with closing(sqlite3.connect(db)) as conn:
|
||||
columns = {r[1] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert {"income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= columns
|
||||
assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id=?", (run,)).fetchone()[0] > 0
|
||||
|
||||
|
||||
def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path, monkeypatch):
|
||||
import sqlite3
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from dashboard import authentication
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
db = tmp_path / "dashboard-comprehensive.sqlite3"
|
||||
init_db(str(db))
|
||||
assert service.create_ledger_category("Synthetic salary", "Monthly", cashflow_kind="income", db_path=str(db))["success"]
|
||||
assert service.create_ledger_transaction("2026-09-05", "income", 20, "Synthetic salary", "Monthly", db_path=str(db))["success"]
|
||||
asset = service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=str(db))
|
||||
assert asset["success"]
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db))
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
token = authentication.issue_persistent_token(settings, remember_me=False).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
draft = service.create_settlement_draft("2026-09", "synthetic", "dashboard-draft", db_path=str(db))["draft"]
|
||||
policy = {"savings_categories":[{"category":"Synthetic savings", "subcategory":"Reserve"}], "category_decisions":[]}
|
||||
changed = service.set_settlement_cashflow_policy(draft["id"], draft["version"], "synthetic", policy, db_path=str(db))["draft"]
|
||||
reviewed = service.review_settlement_draft(draft["id"], db_path=str(db))
|
||||
assert reviewed["success"]
|
||||
assert service.approve_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "synthetic", "acceptance", "synthetic-ref", db_path=str(db))["success"]
|
||||
saved = service.finalize_settlement_draft(draft["id"], changed["version"], reviewed["review_fingerprint"], "dashboard-close", db_path=str(db))
|
||||
assert saved["success"]
|
||||
with sqlite3.connect(db) as conn:
|
||||
before = conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall()
|
||||
before_rows = conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall()
|
||||
before_detail = conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall()
|
||||
app = AppTest.from_file(str(Path(__file__).resolve().parents[1] / "dashboard" / "app.py"), default_timeout=30)
|
||||
app.query_params["page"] = "월정산"
|
||||
app.query_params["month"] = "2026-09"
|
||||
app.run()
|
||||
assert not app.exception, app.exception
|
||||
body = "\n".join(str(x.value) for x in list(app.text) + list(app.markdown) + list(app.caption))
|
||||
assert "저장된 월정산 스냅샷" in body and f"run {saved['run_id']}" in body
|
||||
assert any(metric.label == "자산 합계" for metric in app.metric)
|
||||
frames = [item.value for item in app.dataframe]
|
||||
assert any("cashflow_kind" in frame.columns and "income" in set(frame["cashflow_kind"]) for frame in frames)
|
||||
with sqlite3.connect(db) as conn:
|
||||
assert conn.execute("SELECT name, sql FROM sqlite_master ORDER BY type,name").fetchall() == before
|
||||
assert conn.execute("SELECT * FROM asset_snapshot_runs ORDER BY id").fetchall() == before_rows
|
||||
assert conn.execute("SELECT * FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == before_detail
|
||||
@@ -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] == "13"
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14"
|
||||
|
||||
|
||||
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"] == "13"
|
||||
assert health["schema_version"] == "14"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
|
||||
@@ -256,7 +256,7 @@ def test_schema13_migration_is_additive_and_idempotent(tmp_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'
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '14'
|
||||
|
||||
|
||||
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
|
||||
|
||||
@@ -17,39 +17,43 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
|
||||
text = REQ.read_text(encoding="utf-8")
|
||||
_, raw, body = text.split("---", 2)
|
||||
metadata = json.loads(raw)
|
||||
assert metadata["implementation_pr"] == "미정" or re.fullmatch(r"https://gitea\.jsfamily\.quest/jsfamily/jarca-ledger-mcp/pulls/\d+|#\d+", metadata["implementation_pr"])
|
||||
assert metadata == {
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-003",
|
||||
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
|
||||
"revision": "1.1",
|
||||
"revision": "1.2",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#15",
|
||||
"implementation_pr": "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16",
|
||||
"issue": "#18",
|
||||
"implementation_pr": metadata["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, 12)]
|
||||
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
|
||||
assert len(acs) == 27
|
||||
acs = re.findall(r"^### (REQ-003\.\d+-AC\d+) —", body, re.M)
|
||||
expected_acs = {f"REQ-003.{n:02d}-AC01" for n in range(1, 11)} | {f"REQ-003.11-AC{n:02d}" for n in range(1, 28)}
|
||||
assert len(acs) == len(expected_acs)
|
||||
assert len(set(acs)) == len(acs)
|
||||
assert set(acs) == expected_acs
|
||||
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("**판정 기준**") == 27
|
||||
assert body.count("**검증**") == 27
|
||||
assert body.count("**판정 기준**") == 37
|
||||
assert body.count("**검증**") == 37
|
||||
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 body.count("- 수준: integration") == 27
|
||||
assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 27
|
||||
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
|
||||
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
|
||||
for marker in (
|
||||
@@ -146,7 +150,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.") == 38
|
||||
assert body.count("### REQ-003.") == 48
|
||||
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"] == "13"
|
||||
assert service.health_check(db_path)["schema_version"] == "14"
|
||||
|
||||
|
||||
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"] == "13"
|
||||
assert result["schema_version"] == "14"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Cashflow policy lifecycle and explicit-classification contract tests."""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def _capability(name):
|
||||
candidate = getattr(service, name, None)
|
||||
assert callable(candidate), f"required public service capability {name} is absent"
|
||||
return candidate
|
||||
|
||||
|
||||
def _ok(result):
|
||||
assert result["success"], result
|
||||
return result
|
||||
|
||||
|
||||
def _draft(path, month="2026-09"):
|
||||
return _ok(service.create_settlement_draft(month, "synthetic creator", "synthetic-key", db_path=path))["draft"]
|
||||
|
||||
|
||||
def _policy(decisions=None):
|
||||
return {"savings_categories": [{"category": "Savings", "subcategory": "Monthly"}], "category_decisions": decisions or []}
|
||||
|
||||
|
||||
def _add_category(name, subcategory, kind, path):
|
||||
return _ok(service.create_ledger_category(name, subcategory, cashflow_kind=kind, db_path=path))
|
||||
|
||||
|
||||
def _add_tx(day, typ, amount, category, subcategory, path):
|
||||
result = service.create_ledger_transaction(day, typ, amount, category, subcategory, db_path=path)
|
||||
assert result["success"], result
|
||||
assert isinstance(result["transaction"], dict)
|
||||
return result["transaction"]["id"]
|
||||
|
||||
|
||||
def test_explicit_savings_refund_and_movement_precedence(tmp_path):
|
||||
path = str(tmp_path / "policy-precedence.sqlite3")
|
||||
init_db(path)
|
||||
draft = _draft(path)
|
||||
setter = _capability("set_settlement_cashflow_policy")
|
||||
_capability("get_monthly_settlement_snapshot")
|
||||
_add_category("Savings", "Monthly", "investment", path)
|
||||
_add_category("Savings", "Fund", "investment", path)
|
||||
_add_category("Movement", "Transfer", "transfer", path)
|
||||
_add_category("Voucher", "Load", "expense", path)
|
||||
_add_category("Voucher", "Consumption", "expense", path)
|
||||
_add_category("Loan", "Debt payment", "debt_payment", path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
ids = {
|
||||
"save": _add_tx("2026-09-01", "expense", 100, "Savings", "Monthly", path),
|
||||
"unlisted_investment": _add_tx("2026-09-02", "expense", 50, "Savings", "Fund", path),
|
||||
"transfer": _add_tx("2026-09-03", "transfer", 200, "Movement", "Transfer", path),
|
||||
"voucher_load": _add_tx("2026-09-04", "expense", 300, "Voucher", "Load", path),
|
||||
"voucher_consumption": _add_tx("2026-09-05", "expense", 30, "Voucher", "Consumption", path),
|
||||
"debt": _add_tx("2026-09-06", "expense", 40, "Loan", "Debt payment", path),
|
||||
"food": _add_tx("2026-09-07", "expense", 80, "Food", "Meals", path),
|
||||
"refund": _add_tx("2026-09-30", "refund", 20, "Food", "Meals", path),
|
||||
}
|
||||
policy = _policy([{"category": "Voucher", "subcategory": "Load", "cashflow_kind": "excluded", "reason": "synthetic asset movement"}])
|
||||
changed = _ok(setter(draft["id"], draft["version"], "synthetic operator", policy, db_path=path))
|
||||
saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"]
|
||||
assert saved["cashflow_policy"] == policy and changed["draft"]["version"] == draft["version"] + 1
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert "cashflow_items" in reviewed
|
||||
grouped = {(x["cashflow_kind"], x["category"], x["subcategory"]): x for x in reviewed["cashflow_items"]}
|
||||
assert grouped[("expense", "Savings", "Monthly")]["amount"] == 100
|
||||
assert grouped[("expense", "Savings", "Monthly")]["transaction_count"] == 1
|
||||
assert grouped[("excluded", "Voucher", "Load")]["transaction_count"] == 1
|
||||
assert grouped[("expense", "Voucher", "Consumption")]["amount"] == 30
|
||||
assert grouped[("expense", "Loan", "Debt payment")]["amount"] == 40
|
||||
assert grouped[("expense", "Food", "Meals")]["amount"] == 60
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
source = reviewed["source_snapshot"]["transactions"]
|
||||
by_id = {x["id"]: x for x in source}
|
||||
for txid in ids.values():
|
||||
assert {"id", "transaction_date", "type", "amount", "currency", "category", "subcategory", "cashflow_kind", "applied_amount"} <= by_id[txid].keys()
|
||||
assert by_id[ids["refund"]]["amount"] == 20
|
||||
assert by_id[ids["refund"]]["applied_amount"] == -20
|
||||
assert by_id[ids["transfer"]]["cashflow_kind"] == "excluded"
|
||||
assert by_id[ids["unlisted_investment"]]["cashflow_kind"] == "excluded"
|
||||
assert reviewed["expense_total"] == 230
|
||||
|
||||
|
||||
def test_unknown_classification_blocks_and_explicit_exclusion_unblocks(tmp_path):
|
||||
path = str(tmp_path / "unknown-blocker.sqlite3")
|
||||
init_db(path)
|
||||
draft = _draft(path)
|
||||
_add_category("Mystery", "Other", "unknown", path)
|
||||
_add_tx("2026-09-14", "expense", 60, "Mystery", "Other", path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert reviewed["cashflow_blockers"]
|
||||
assert not service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path, allow_missing_exclusions=True)["success"]
|
||||
explicit = _policy([{"category": "Mystery", "subcategory": "Other", "cashflow_kind": "excluded", "reason": "synthetic operator exclusion"}])
|
||||
edited = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", explicit, db_path=path))["draft"]
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
assert reviewed["version"] == edited["version"]
|
||||
assert service.approve_settlement_draft(draft["id"], edited["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path)["success"]
|
||||
|
||||
|
||||
def test_policy_edit_persists_versions_and_invalidates_approval(tmp_path):
|
||||
path = str(tmp_path / "policy-approval.sqlite3")
|
||||
init_db(path)
|
||||
_ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
_ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
result = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "editor", _policy(), db_path=path))
|
||||
saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"]
|
||||
assert saved["cashflow_policy"] == _policy()
|
||||
assert result["draft"]["version"] == draft["version"] + 1
|
||||
assert saved["approved_by"] == ""
|
||||
|
||||
|
||||
def test_invalid_policy_payload_is_rejected_without_write(tmp_path):
|
||||
path = str(tmp_path / "policy-invalid.sqlite3")
|
||||
init_db(path)
|
||||
draft = _draft(path)
|
||||
before = _ok(service.get_settlement_draft(draft["id"], db_path=path))
|
||||
invalid = {"savings_categories": [{"category": "Savings"}], "category_decisions": []}
|
||||
result = _capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", invalid, db_path=path)
|
||||
assert not result["success"]
|
||||
assert _ok(service.get_settlement_draft(draft["id"], db_path=path)) == before
|
||||
|
||||
|
||||
def test_review_contract_and_stored_reader_never_recomputes_live_preview(tmp_path):
|
||||
path = str(tmp_path / "stored-reader.sqlite3")
|
||||
init_db(path)
|
||||
reader = _capability("get_monthly_settlement_snapshot")
|
||||
empty = reader(month="2026-09", db_path=path)
|
||||
assert empty["success"] is False and empty["error_code"] == "not_found"
|
||||
_ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert "review_fingerprint" in reviewed and "review" not in reviewed
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
final = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path))
|
||||
assert "final_response" not in final and "snapshot_run_id" in final
|
||||
snapshot = _ok(reader(run_id=final["snapshot_run_id"], db_path=path))
|
||||
assert snapshot["is_closed_snapshot"] is True
|
||||
assert snapshot["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
assert snapshot["total_assets"] == 100
|
||||
assert _ok(reader(month="2026-09", db_path=path))["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
_ok(service.create_manual_asset("Later live cash", 900, "2026-09-30", "cash", db_path=path))
|
||||
persisted = _ok(reader(run_id=final["snapshot_run_id"], db_path=path))
|
||||
assert persisted["total_assets"] == 100 and persisted["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
assert "live_authority" not in persisted.values()
|
||||
|
||||
|
||||
def test_month_boundaries_refund_month_negative_total_and_fx_blocker(tmp_path):
|
||||
path = str(tmp_path / "month-boundaries.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
_add_tx("2026-08-31", "expense", 10, "Food", "Meals", path)
|
||||
_add_tx("2026-09-01", "expense", 5, "Food", "Meals", path)
|
||||
_add_tx("2026-10-01", "expense", 7, "Food", "Meals", path)
|
||||
_add_tx("2026-09-15", "refund", 20, "Food", "Meals", path)
|
||||
_add_tx("2026-09-16", "expense", 3, "Food", "Meals", path)
|
||||
d = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(d["id"], db_path=path))
|
||||
food = next(x for x in reviewed["cashflow_items"] if x["cashflow_kind"] == "expense" and x["category"] == "Food")
|
||||
assert food["amount"] == -12
|
||||
assert reviewed["expense_total"] == -12
|
||||
assert reviewed["cashflow_blockers"] == []
|
||||
_add_category("Foreign", "Purchase", "expense", path)
|
||||
_add_tx("2026-09-20", "expense", 12, "Foreign", "Purchase", path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("UPDATE transactions SET currency='USD' WHERE category='Foreign'")
|
||||
reviewed = _ok(service.review_settlement_draft(d["id"], db_path=path))
|
||||
assert reviewed["cashflow_blockers"]
|
||||
assert any("fx" in json.dumps(x).lower() or "currency" in json.dumps(x).lower() for x in reviewed["cashflow_blockers"])
|
||||
assert reviewed["expense_total"] == -12
|
||||
|
||||
|
||||
def test_known_refund_only_category_is_retained_at_zero(tmp_path):
|
||||
path = str(tmp_path / "zero-refund.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
_add_tx("2026-09-01", "expense", 20, "Food", "Meals", path)
|
||||
_add_tx("2026-09-02", "refund", 20, "Food", "Meals", path)
|
||||
reviewed = _ok(service.review_settlement_draft(_draft(path)["id"], db_path=path))
|
||||
food = next(x for x in reviewed["cashflow_items"] if x["cashflow_kind"] == "expense" and x["category"] == "Food")
|
||||
assert food["amount"] == 0 and food["transaction_count"] == 2
|
||||
|
||||
|
||||
def test_policy_capabilities_are_checked_at_call_time(tmp_path):
|
||||
path = str(tmp_path / "future-capabilities.sqlite3")
|
||||
init_db(path)
|
||||
assert callable(_capability("set_settlement_cashflow_policy"))
|
||||
assert callable(_capability("get_monthly_settlement_snapshot"))
|
||||
assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "not_found"
|
||||
|
||||
|
||||
def _private_cashflow_review(path, policy=None):
|
||||
from jarca_ledger_mcp.application.settlement_cashflow_service import _review_cashflow
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
return _review_cashflow(conn, "2026-09", policy or {})
|
||||
|
||||
|
||||
def test_cashflow_group_field_matches_persisted_detail_contract(tmp_path):
|
||||
path = str(tmp_path / "group-field.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Food", "Meals", "expense", path)
|
||||
_add_tx("2026-09-01", "expense", 20, "Food", "Meals", path)
|
||||
result = _private_cashflow_review(path)
|
||||
assert len(result["cashflow_items"]) == 1
|
||||
assert "cashflow_kind" in result["cashflow_items"][0], "group shape must use the persisted/public cashflow_kind field"
|
||||
|
||||
|
||||
def test_refund_reduces_debt_payment_and_selected_savings(tmp_path):
|
||||
for kind, category, subcategory in [("debt_payment", "Loan", "Payment"), ("investment", "Savings", "Monthly")]:
|
||||
path = str(tmp_path / (kind + "-refund.sqlite3"))
|
||||
init_db(path)
|
||||
_add_category(category, subcategory, kind, path)
|
||||
txid = _add_tx("2026-09-01", "refund", 20, category, subcategory, path)
|
||||
result = _private_cashflow_review(path, _policy())
|
||||
assert not result["cashflow_blockers"]
|
||||
assert result["expense_total"] == -20, "refund must reduce expense even for debt payment or selected savings"
|
||||
assert result["source_snapshot"]["transactions"][0]["id"] == txid
|
||||
assert result["source_snapshot"]["transactions"][0]["applied_amount"] == -20
|
||||
|
||||
|
||||
def test_unknown_cashflow_source_is_not_recorded_as_excluded(tmp_path):
|
||||
path = str(tmp_path / "unknown-source.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("Mystery", "Other", "unknown", path)
|
||||
_add_tx("2026-09-01", "expense", 20, "Mystery", "Other", path)
|
||||
result = _private_cashflow_review(path)
|
||||
assert result["cashflow_blockers"] and not result["cashflow_items"]
|
||||
assert result["source_snapshot"]["transactions"][0]["cashflow_kind"] != "excluded", "unknown is not an authorized exclusion"
|
||||
|
||||
|
||||
def test_nonstring_cashflow_decision_kind_has_validation_error():
|
||||
from jarca_ledger_mcp.application.settlement_cashflow_service import _normalize_cashflow_policy
|
||||
malformed = {"category_decisions": [{"category": "Food", "subcategory": "Meals", "cashflow_kind": [], "reason": "synthetic"}]}
|
||||
error = None
|
||||
try:
|
||||
_normalize_cashflow_policy(malformed)
|
||||
except Exception as caught:
|
||||
error = caught
|
||||
assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Cashflow schema and legacy migration contract regressions."""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def _schema(path):
|
||||
with sqlite3.connect(path) as db:
|
||||
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables
|
||||
header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")}
|
||||
assert {"settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"} <= header
|
||||
assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
|
||||
|
||||
|
||||
def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
|
||||
path = str(tmp_path / "detail-constraints.sqlite3")
|
||||
init_db(path)
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')")
|
||||
insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
|
||||
good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW")
|
||||
db.execute(insert, good)
|
||||
for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]:
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute(insert, values)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW"))
|
||||
db.execute(insert, ("d10", "run-a", "income", "", "", 2, 1, "KRW"))
|
||||
indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")}
|
||||
unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique]
|
||||
assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
|
||||
assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'")
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path):
|
||||
"""Schema-13 fixture uses the frozen production header and asset DDL."""
|
||||
path = str(tmp_path / "schema13.sqlite3")
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE schema_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
||||
INSERT INTO schema_meta VALUES('schema_version','13');
|
||||
CREATE TABLE asset_snapshots (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '', snapshot_run_id TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '',
|
||||
valuation_id TEXT NOT NULL DEFAULT '', selection_policy TEXT NOT NULL DEFAULT 'exact',
|
||||
verification_status TEXT NOT NULL DEFAULT 'unverified', evidence_kind TEXT NOT NULL DEFAULT 'applied',
|
||||
exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE asset_snapshot_runs (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
|
||||
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
||||
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL, draft_id TEXT NOT NULL DEFAULT '', draft_version INTEGER NOT NULL DEFAULT 0,
|
||||
review_fingerprint TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-09-30','complete_exact','then');
|
||||
INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,created_at,snapshot_run_id) VALUES('legacy-row','2026-09-30','manual_asset','synthetic','Synthetic',42,'then','legacy-run');
|
||||
""")
|
||||
init_db(path)
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
columns = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert "draft_id" in columns and "income_total" in columns
|
||||
first = db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()
|
||||
assert first[:4] == ("legacy-run", "2026-09-30", "complete_exact", "then")
|
||||
assert first[4:] == (None, None, None, None, None, None)
|
||||
old = db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall()
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone() == first
|
||||
assert db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall() == old
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
|
||||
@@ -18,7 +18,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
|
||||
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 result['review']['totals'] == {'total_assets': 0, 'total_liabilities': 0, 'net_worth': 0, 'income_total': 0, 'expense_total': 0, 'net_cashflow': 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
|
||||
|
||||
@@ -107,7 +107,21 @@ def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path):
|
||||
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)
|
||||
review = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert review['totals']['total_assets'] == 100
|
||||
assert not review['complete'] and review['cashflow_blockers']
|
||||
blocked = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path)
|
||||
assert not blocked['success']
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
|
||||
policy = {'savings_categories': [], 'category_decisions': [{'category': '미분류', 'subcategory': '', 'cashflow_kind': 'excluded', 'reason': 'synthetic explicit exclusion'}]}
|
||||
updated = s.set_settlement_cashflow_policy(d['id'], d['version'], 'synthetic reviewer', policy, db_path=path)
|
||||
assert updated['success']
|
||||
d = updated['draft']
|
||||
assert d['version'] == 2
|
||||
review = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert review['complete']
|
||||
ok(s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'synthetic reviewer', 'reason', 'ref', db_path=path))
|
||||
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']
|
||||
|
||||
@@ -19,9 +19,9 @@ def test_settlement_requirement_revision_is_additive_and_traceable() -> None:
|
||||
_, 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"] == "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp/pulls/16"
|
||||
assert metadata["revision"] == "1.2"
|
||||
assert metadata["issue"] == "#18"
|
||||
assert metadata["implementation_pr"] == "미정" or re.fullmatch(r"https://gitea\.jsfamily\.quest/jsfamily/jarca-ledger-mcp/pulls/\d+|#\d+", 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)]
|
||||
@@ -50,13 +50,19 @@ def test_new_public_operations_are_fully_listed_from_contract() -> None:
|
||||
assert "review_fingerprint" in interface and "selection_mode" in interface
|
||||
|
||||
|
||||
def test_comprehensive_migration_acceptance_has_explicit_legacy_test_mapping() -> None:
|
||||
text = REQ.read_text(encoding="utf-8")
|
||||
case = text.split("### REQ-003.11-AC26 —", 1)[1].split("### REQ-003.11-AC27 —", 1)[0]
|
||||
assert "`tests/test_settlement_cashflow_schema.py::test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null`" in case
|
||||
|
||||
|
||||
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)]
|
||||
assert len(cases) == 27
|
||||
assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 28)]
|
||||
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)
|
||||
|
||||
@@ -266,7 +266,7 @@ def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tm
|
||||
reviewed = approve(path, d)
|
||||
assert reviewed["review_fingerprint"] != legacy_fingerprint
|
||||
final = ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path))
|
||||
assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60}
|
||||
assert final["totals"] == {"total_assets": 100, "total_liabilities": 40, "net_worth": 60, "income_total": 0, "expense_total": 0, "net_cashflow": 0}
|
||||
assert ok(s.get_settlement_draft(d["id"], db_path=path))["final_response"] == final
|
||||
assert ok(s.finalize_settlement_draft(d["id"], d["version"], reviewed["review_fingerprint"], "legacy-close", db_path=path)) == final
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
|
||||
Reference in New Issue
Block a user