feat: extend comprehensive monthly settlement snapshots (#18)

This commit is contained in:
gitea-impl-agent
2026-10-13 04:36:38 +00:00
parent f67c07adf0
commit dd35e0785f
30 changed files with 1817 additions and 48 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
with sqlite3.connect(path) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
assert version == "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
+478
View File
@@ -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
+1 -1
View File
@@ -122,7 +122,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "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):
+1 -1
View File
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
def test_ledger_category_create_list_delete_reactivate(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
health = service.health_check(db_path)
assert health["schema_version"] == "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
+1 -1
View File
@@ -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):
+14 -10
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
assert status["integrity_check"] == "ok"
assert status["table_counts"]["review_decisions"] == 0
assert status["table_counts"]["reconciliation_sessions"] == 0
assert service.health_check(db_path)["schema_version"] == "13"
assert service.health_check(db_path)["schema_version"] == "14"
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
+1 -1
View File
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
result = service.health_check(str(db_path))
assert result["success"] is True
assert result["schema_version"] == "13"
assert result["schema_version"] == "14"
assert db_path.exists()
+246
View File
@@ -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"
+89
View File
@@ -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']
+11 -5
View File
@@ -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