fix: address account valuation review findings

This commit is contained in:
2026-10-06 14:43:29 +00:00
parent 399d0923be
commit 6a8a27a74e
34 changed files with 1940 additions and 131 deletions
+87 -2
View File
@@ -61,7 +61,7 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
"월정산": "월간 핵심 KPI",
}
for page, expected_text in page_expectations.items():
at = AppTest.from_file(str(app_path), default_timeout=10)
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
if page == "월정산":
at.query_params["month"] = "2026-06"
@@ -88,7 +88,7 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "최근 동기화: 2026-06-30T23:59:00+09:00" in body_text
assert "잔액: 1,250,000원" in body_text
assert "상태: 정상" in body_text
assert "자산 상세: 경고 없음" in body_text
assert "자산 상세: 경고 3건 / 제외 1건" in body_text
assert "월정산 결과 / Evidence" in body_text
assert "읽기 전용 근거 요약" in body_text
assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text
@@ -130,3 +130,88 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert "/home/" not in body_text
assert "raw_payload" not in body_text
assert "🔒 읽기 전용" in body_text
def _dashboard_body(at):
return "\\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
def test_authenticated_asset_and_monthly_pages_render_evidence_details(tmp_path, monkeypatch):
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
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()
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
for page in ("자산/마감", "월정산"):
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
if page == "월정산":
at.query_params["month"] = "2026-06"
at.run()
assert not at.exception
body = _dashboard_body(at)
if page == "월정산":
assert "missing_verified_exact_valuation" in body
assert "acct_" in body
assert "제외 상세" in body
assert "source_note" not in body
assert "memo" not in body
def test_authenticated_dashboard_all_pages_are_no_write(tmp_path, monkeypatch):
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
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()
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
import sqlite3
def data_version():
with sqlite3.connect(fixture) as conn:
return conn.execute("PRAGMA data_version").fetchone()[0]
before_data_version = data_version()
for page in pages:
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
if page == "월정산":
at.query_params["month"] = "2026-06"
at.run()
assert not at.exception, (page, at.exception)
assert data_version() == before_data_version
def test_authenticated_dashboard_missing_db_is_stable_and_does_not_initialize(tmp_path, monkeypatch):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
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()
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
for page in ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]:
at = AppTest.from_file(str(app_path), default_timeout=30)
at.query_params["page"] = page
at.run()
assert not at.exception, (page, at.exception)
body = _dashboard_body(at) + "\n".join(str(item.value) for item in at.error)
assert "Ledger DB를 사용할 수 없습니다" in body
assert not db_path.exists()
assert not db_path.parent.exists()
+2
View File
@@ -53,6 +53,7 @@ def test_direct_page_query_access_renders_requested_page(monkeypatch):
selected = {}
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app.overview_gateway, "get_ledger_db_status", lambda **kwargs: {"success": True})
monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "계좌"})
monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "요약"})
@@ -157,6 +158,7 @@ def test_refresh_control_preserves_cache_clear_contract(monkeypatch):
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
monkeypatch.setattr(app.overview_gateway, "get_ledger_db_status", lambda **kwargs: {"success": True})
monkeypatch.setattr(app, "inject_app_css", lambda: None)
monkeypatch.setattr(app.st, "query_params", {"page": "요약"})
monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page)
+5
View File
@@ -6,6 +6,7 @@ import sqlite3
from pathlib import Path
from jarca_ledger_mcp import service
from jarca_ledger_mcp.infrastructure.sqlite import init_db
from jarca_ledger_mcp.provenance_policy import (
EXPOSURE_LEVEL_DEBUG_GATED,
EXPOSURE_LEVEL_MINIMAL,
@@ -63,6 +64,7 @@ def test_preview_ledger_csv_import_redacts_full_path_and_raw_columns(tmp_path):
def test_preview_and_import_kb_bank_pdf_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = {
"file_path": SECRET_SOURCE_PATH,
"source_hash": SOURCE_HASH,
@@ -101,6 +103,7 @@ def test_preview_and_import_kb_bank_pdf_sanitize_path_and_payload(monkeypatch, t
def test_preview_and_import_seoulpay_xlsx_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = {
"file_path": "/very/secret/home/alice/Documents/private/seoulpay.xlsx",
"source_hash": SOURCE_HASH,
@@ -144,6 +147,7 @@ def test_preview_and_import_seoulpay_xlsx_sanitize_path_and_payload(monkeypatch,
def test_preview_and_import_toss_bank_pdf_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = {
"file_path": SECRET_SOURCE_PATH,
"source_hash": SOURCE_HASH,
@@ -184,6 +188,7 @@ def test_preview_and_import_toss_bank_pdf_sanitize_path_and_payload(monkeypatch,
def test_preview_and_import_toss_securities_pdf_sanitize_path_and_payload(monkeypatch, tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
parsed = {
"file_path": SECRET_SOURCE_PATH,
"source_hash": SOURCE_HASH,
+120 -3
View File
@@ -31,12 +31,12 @@ def test_empty_conditional_close_is_closed_and_second_close_requires_correction(
assert second["success"] is False and second["error_code"] == "validation_error"
def test_preview_marks_approval_eligible_missing_valuation_as_conditional(tmp_path):
def test_preview_marks_approval_eligible_missing_valuation_as_incomplete_draft(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_ledger_account("미확인 계좌", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["success"] is True
assert preview["close_status"] == "conditional"
assert preview["close_status"] == "incomplete_draft"
assert any(warning["code"] == "missing_verified_exact_valuation" for warning in preview["warnings"])
@@ -140,7 +140,7 @@ def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_p
"2026-06-30", source_note="source-a", memo="memo-b",
idempotency_key="close-1", db_path=path,
)
assert first["success"] and same["success"] and same["reused"] is True
assert first["success"] and same["success"] and same == first
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
other_asset = first["included_items"][0]["source_id"]
@@ -390,6 +390,122 @@ def test_snapshot_idempotency_binds_redacted_candidate_state(tmp_path):
assert retry["error_code"] == "idempotency_conflict"
def test_reconciliation_requires_balance_coverage_through_requested_basis(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-01", "income", 1_000, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_basis"] == "latest_transaction_balance_after"
assert item["calculation_complete"] is False
assert item["status"] == "INCOMPLETE_CALCULATION"
assert item["difference"] is None
def test_reconciliation_accepts_balance_coverage_at_requested_basis(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-30", "income", 1_000, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is True
assert item["status"] == "OK"
assert item["difference"] == 0
def test_reconciliation_requires_start_balance_for_transaction_coverage(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-01", "income", 1_000, account_id=account, db_path=path,
)
service.create_ledger_transaction(
"2026-06-30", "expense", 100, account_id=account,
balance_after=900, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=900,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is False
assert item["status"] == "INCOMPLETE_CALCULATION"
assert item["difference"] is None
def test_reconciliation_requires_authoritative_opening_evidence_for_multi_transaction_coverage(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.create_ledger_transaction(
"2026-06-15", "income", 100, account_id=account,
balance_after=900, db_path=path,
)
service.create_ledger_transaction(
"2026-06-30", "income", 100, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is False
assert item["status"] == "INCOMPLETE_CALCULATION"
assert item["difference"] is None
def test_reconciliation_accepts_continuous_transactions_with_verified_opening_evidence(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
"계좌", last_balance=1_000, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.register_account_valuation(
account, "2026-06-14", 800, valuation_kind="balance",
verification_status="verified", db_path=path,
)
service.create_ledger_transaction(
"2026-06-15", "income", 100, account_id=account,
balance_after=900, db_path=path,
)
service.create_ledger_transaction(
"2026-06-30", "income", 100, account_id=account,
balance_after=1_000, db_path=path,
)
item = service.get_balance_reconciliation_report(
account_id=account, baseline_balance=1_000,
baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
)["items"][0]
assert item["calculation_complete"] is True
assert item["status"] == "OK"
assert item["difference"] == 0
def test_reconciliation_fails_closed_on_ambiguous_verified_leaf(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account(
@@ -483,6 +599,7 @@ def test_legacy_snapshot_rows_do_not_override_live_authority_or_claim_closed(tmp
def test_snapshot_public_api_preserves_legacy_db_path_position(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
init_db(path)
preview = service.preview_asset_snapshot_close("2026-06-30", path)
closed = service.close_asset_snapshot("2026-06-30", "", "", "", path)
report = service.get_asset_snapshot_report("2026-06-30", path)
+190
View File
@@ -0,0 +1,190 @@
from types import SimpleNamespace
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import imports_service
def _account(path, *, account_type="bank", currency="KRW"):
return service.create_ledger_account("계좌", type=account_type, currency=currency, db_path=path)["account"]["id"]
def test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
investment = _account(path, account_type="investment")
loan = _account(path, account_type="loan")
inv = service.register_account_valuation(
investment, "2026-06-30", 1_000, valuation_kind="total_value",
principal=0, reported_principal_kind="contributed", reported_principal_label="납입원금",
interest=None, fee=None, idempotency_key="inv", db_path=path,
)
debt = service.register_account_valuation(
loan, "2026-06-30", 9_000, valuation_kind="loan",
representative_liability_definition="principal_interest_fees", principal=9_000,
interest=300, fee=20, idempotency_key="loan", db_path=path,
)
assert inv["success"] and debt["success"]
inv_value = service.list_account_valuations(investment, db_path=path)["valuations"][0]
debt_value = service.list_account_valuations(loan, db_path=path)["valuations"][0]
assert inv_value["principal"] == 0
assert inv_value["interest"] is None and inv_value["fee"] is None
assert inv_value["reported_principal_kind"] == "contributed"
assert inv_value["reported_principal_label"] == "납입원금"
assert debt_value["amount"] == 9_000
assert debt_value["principal"] == 9_000 and debt_value["interest"] == 300 and debt_value["fee"] == 20
def test_direct_correction_requires_matching_existing_parent_and_reason(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(account, "2026-06-30", 100, db_path=path)["valuation"]
for kwargs in (
{"supersedes_id": "missing", "correction_reason": "정정"},
{"supersedes_id": parent["id"], "correction_reason": ""},
):
result = service.register_account_valuation(account, "2026-06-30", 200, db_path=path, **kwargs)
assert result["success"] is False
other = _account(path, currency="USD")
result = service.register_account_valuation(
other, "2026-06-30", 200, currency="USD", supersedes_id=parent["id"],
correction_reason="cross account", db_path=path,
)
assert result["success"] is False
assert service.list_account_valuations(account, db_path=path)["count"] == 1
def test_correction_rejects_currency_and_kind_override_but_preserves_valid_overrides(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path, account_type="investment")
parent = service.register_account_valuation(
account, "2026-06-30", 100, valuation_kind="total_value", memo="old", db_path=path,
)["valuation"]
bad_currency = service.correct_account_valuation(parent["id"], 110, "정정", currency="USD", db_path=path)
bad_kind = service.correct_account_valuation(parent["id"], 110, "정정", valuation_kind="balance", db_path=path)
good = service.correct_account_valuation(parent["id"], 110, "정정", memo="new", db_path=path)
assert bad_currency["success"] is False and bad_kind["success"] is False
assert good["success"] and good["valuation"]["memo"] == "new"
def _fake_tx(date, balance, *, row=1, kind="bank"):
if kind == "kb":
return SimpleNamespace(
transaction_date=date, transaction_datetime=f"{date} 10:00:00", amount=100,
counterparty="상대", raw_summary="이체", branch="", remittance_memo="",
balance_after=balance, type="income", page=1,
external_id=lambda source_hash: f"kb:{source_hash}:{row}",
raw_payload=lambda: "{}",
)
if kind == "tosssec":
return SimpleNamespace(
transaction_date=date, amount=100, action="매수", name="종목", code="A", section="거래",
quantity=1, unit_price=100, fee=0, tax=0, balance_after=balance, type="income",
external_id=lambda source_hash: f"sec:{source_hash}:{row}", raw_payload=lambda: "{}",
)
return SimpleNamespace(
transaction_date=date, transaction_datetime=f"{date} 10:00:00", amount=100,
action="입금", counterparty="상대", balance_after=balance, type="income", raw_summary="입금",
external_id=lambda source_hash: f"bank:{source_hash}:{row}", raw_payload=lambda: "{}",
)
def _parsed(tx, source, metadata):
return {
"transactions": [tx], "validation": {"ok": True}, "source_hash": f"hash-{source}",
"metadata": metadata, "file_path": "/tmp/synthetic.pdf", "page_count": 1,
}
def test_import_historical_kb_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("KB", institution="KB국민은행", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
monkeypatch.setattr(imports_service, "parse_kb_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="kb"), "kb", {"final_balance": 700, "account_number_masked": ""}))
result = imports_service.import_kb_bank_pdf("ignored", "pw", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_import_historical_toss_bank_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Toss", institution="토스뱅크", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_bank_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700), "bank", {"account_number_masked": ""}))
result = imports_service.import_toss_bank_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_import_historical_toss_securities_does_not_replace_newer_current_balance(monkeypatch, tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("Securities", type="investment", institution="토스증권", last_balance=900, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
monkeypatch.setattr(imports_service, "parse_toss_securities_pdf", lambda *_: _parsed(_fake_tx("2026-06-01", 700, kind="tosssec"), "sec", {"account_number_masked": ""}))
result = imports_service.import_toss_securities_pdf("ignored", account_id=account, db_path=path)
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
assert result["success"] and current["last_balance"] == 900 and current["last_synced_at"] == "2026-06-30"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_source_ref_is_account_scoped_evidence_identity_without_explicit_key(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
first = _account(path)
second = _account(path)
one = service.register_account_valuation(first, "2026-06-30", 100, source_ref="statement", db_path=path)
same = service.register_account_valuation(first, "2026-06-30", 100, source_ref="statement", db_path=path)
changed = service.register_account_valuation(first, "2026-06-30", 101, source_ref="statement", db_path=path)
independent = service.register_account_valuation(second, "2026-06-30", 101, source_ref="statement", db_path=path)
empty_a = service.register_account_valuation(first, "2026-07-01", 1, source_ref="", db_path=path)
empty_b = service.register_account_valuation(first, "2026-07-01", 1, source_ref="", db_path=path)
assert one["success"] and same["reused"] is True
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
assert independent["success"] and independent["reused"] is False
assert empty_a["success"] and empty_b["success"] and empty_b["reused"] is False
def test_correction_rejects_self_parent_and_cycles_without_write(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(account, "2026-06-30", 100, db_path=path)["valuation"]
child = service.register_account_valuation(account, "2026-06-30", 101, supersedes_id=parent["id"], correction_reason="r", db_path=path)["valuation"]
from jarca_ledger_mcp.db import db
with db(path) as conn:
conn.execute("UPDATE account_valuations SET supersedes_id=? WHERE id=?", (child["id"], child["id"]))
cyclic = service.register_account_valuation(account, "2026-06-30", 102, supersedes_id=child["id"], correction_reason="r", db_path=path)
assert cyclic["success"] is False and cyclic["error_code"] == "validation_error"
assert service.list_account_valuations(account, db_path=path)["count"] == 2
def test_correction_preserves_source_ref_without_evidence_identity_conflict(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
parent = service.register_account_valuation(
account, "2026-06-30", 100, source_ref="statement-1", db_path=path,
)["valuation"]
corrected = service.correct_account_valuation(
parent["id"], 110, "정정", idempotency_key="correction-1", db_path=path,
)
replay = service.correct_account_valuation(
parent["id"], 110, "정정", idempotency_key="correction-1", db_path=path,
)
unrelated = service.register_account_valuation(
account, "2026-06-30", 120, source_ref="statement-1", idempotency_key="unrelated", db_path=path,
)
listed = service.list_account_valuations(account, db_path=path)
assert corrected["success"]
assert corrected["valuation"]["source_ref"] == "statement-1"
assert replay["success"] and replay["reused"] is True
assert replay["valuation"]["id"] == corrected["valuation"]["id"]
assert unrelated["success"] is False and unrelated["error_code"] == "idempotency_conflict"
assert listed["count"] == 2
assert sum(item["supersedes_id"] is not None for item in listed["valuations"]) == 1
def test_source_ref_conflicts_even_when_idempotency_keys_differ(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = _account(path)
first = service.register_account_valuation(account, "2026-06-30", 100, source_ref="statement", idempotency_key="a", db_path=path)
same = service.register_account_valuation(account, "2026-06-30", 100, source_ref="statement", idempotency_key="b", db_path=path)
changed = service.register_account_valuation(account, "2026-06-30", 101, source_ref="statement", idempotency_key="c", db_path=path)
assert first["success"] and same["reused"] is True
assert changed["success"] is False and changed["error_code"] == "idempotency_conflict"
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import hashlib
import inspect
import sqlite3
from pathlib import Path
import pytest
from jarca_ledger_mcp.application import account_valuation_service as valuation
from jarca_ledger_mcp.application import (
assets_service,
categorization_services,
cross_transfer_service,
reconciliation_service,
system_services,
)
from jarca_ledger_mcp.infrastructure.sqlite import connection
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_read_queries_missing_nested_db_return_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
calls = [
lambda: system_services.list_ledger_accounts(db_path=str(db_path)),
lambda: valuation.list_account_valuations("account", db_path=str(db_path)),
lambda: assets_service.list_manual_assets(db_path=str(db_path)),
lambda: assets_service.preview_asset_snapshot_close(db_path=str(db_path)),
lambda: reconciliation_service.get_balance_reconciliation_report(db_path=str(db_path)),
]
for read in calls:
result = read()
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_read_only_connection_rejects_writes_and_does_not_commit(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path)) # first-use writer initializes
before = _sha256(db_path)
with connection.read_db(str(db_path)) as conn:
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO accounts (id, name, type) VALUES ('x', 'x', 'bank')")
assert _sha256(db_path) == before
def test_read_suite_does_not_initialize_or_change_initialized_database(tmp_path, monkeypatch):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
before = _sha256(db_path)
def fail_init(*args, **kwargs):
raise AssertionError("read path called init_db")
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.migrations.init_db", fail_init)
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.connection.init_db", fail_init, raising=False)
reads = [
lambda: system_services.list_ledger_accounts(db_path=str(db_path)),
lambda: valuation.list_account_valuations("account", db_path=str(db_path)),
lambda: assets_service.list_manual_assets(db_path=str(db_path)),
lambda: assets_service.preview_asset_snapshot_close(db_path=str(db_path)),
lambda: reconciliation_service.get_balance_reconciliation_report(db_path=str(db_path)),
]
for read in reads:
assert read()["success"] is True
assert _sha256(db_path) == before
def test_first_use_writer_still_initializes_database(tmp_path):
db_path = tmp_path / "new" / "ledger.sqlite3"
result = system_services.create_ledger_account("checking", db_path=str(db_path))
assert result["success"] is True
assert db_path.is_file()
def test_read_schema_error_is_not_misreported_as_uninitialized(tmp_path):
db_path = tmp_path / "empty.sqlite3"
sqlite3.connect(db_path).close()
result = system_services.list_ledger_accounts(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_error"
def test_nested_categorization_preview_propagates_missing_db_envelope(tmp_path):
db_path = tmp_path / "missing" / "ledger.sqlite3"
result = categorization_services.preview_categorization(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert not db_path.parent.exists()
def test_public_read_signatures_remain_positional_compatible():
assert list(inspect.signature(valuation.list_account_valuations).parameters)[:2] == ["account_id", "include_rejected"]
assert list(inspect.signature(assets_service.preview_asset_snapshot_close).parameters)[:2] == ["basis_date", "db_path"]
assert list(inspect.signature(reconciliation_service.get_balance_reconciliation_report).parameters)[:2] == ["account_id", "baseline_balance"]
def test_preview_categorization_initialized_db_preserves_success_envelope(tmp_path):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
result = categorization_services.preview_categorization(db_path=str(db_path))
assert result["success"] is True
assert result["matched_count"] == 0
assert result["unmatched_count"] == 0
def test_apply_categorization_rules_missing_db_initializes_writer_or_returns_stable_error(tmp_path):
db_path = tmp_path / "nested" / "ledger.sqlite3"
result = categorization_services.apply_categorization_rules(db_path=str(db_path))
assert result["success"] is True
assert result["preview"]["matched_count"] == 0
def test_cross_transfer_review_queue_missing_nested_db_returns_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
result = cross_transfer_service.list_cross_transfer_review_queue(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_cross_transfer_preview_missing_nested_db_returns_stable_error_without_creating_path(tmp_path):
db_path = tmp_path / "missing" / "nested" / "ledger.sqlite3"
result = cross_transfer_service.preview_cross_account_transfers(db_path=str(db_path))
assert result["success"] is False
assert result["error_code"] == "db_not_initialized"
assert result["error"]
assert not db_path.exists()
assert not db_path.parent.exists()
def test_cross_transfer_read_paths_do_not_initialize_or_mutate_initialized_db(tmp_path, monkeypatch):
db_path = tmp_path / "ledger.sqlite3"
system_services.create_ledger_account("checking", db_path=str(db_path))
before = _sha256(db_path)
def fail_init(*args, **kwargs):
raise AssertionError("cross-transfer read path called init_db")
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.migrations.init_db", fail_init)
monkeypatch.setattr("jarca_ledger_mcp.infrastructure.sqlite.connection.init_db", fail_init, raising=False)
queue = cross_transfer_service.list_cross_transfer_review_queue(db_path=str(db_path))
preview = cross_transfer_service.preview_cross_account_transfers(db_path=str(db_path))
assert queue["success"] is True
assert preview["success"] is True
assert _sha256(db_path) == before
+306
View File
@@ -0,0 +1,306 @@
import json
import sqlite3
from jarca_ledger_mcp import service
def test_dated_manual_asset_is_verified_and_preview_needs_approval(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-01", "cash", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path, selection_policy="as_of")
assert preview["close_status"] == "incomplete_draft"
item = preview["included_items"][0]
assert item["verification_status"] == "verified"
assert item["evidence_kind"] == "manual_confirmed"
assert item["valuation_date"] == "2026-06-01"
assert item["carried_forward"] is True
assert item["age_days"] == 29
def test_approved_carried_close_is_conditional_and_persists_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
assert closed["close_status"] == "conditional"
item = closed["included_items"][0]
assert item["verification_status"] == "verified"
assert item["evidence_kind"] == "manual_confirmed"
with sqlite3.connect(path) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT applied_evidence FROM asset_snapshot_runs WHERE id=?", (closed["snapshot_run_id"],)).fetchone()
evidence = json.loads(row["applied_evidence"])
assert evidence[0]["valuation_date"] == "2026-06-01"
assert evidence[0]["age_days"] == 29
assert evidence[0]["carried_forward"] is True
assert closed["approval"] == {
"approved_by": "reviewer",
"approved_at": closed["closed_at"],
"approval_reason": "approved carry",
"approval_reference": "APR-59",
}
assert closed["correction"]["is_correction"] is False
assert closed["excluded_items"] == []
assert closed["provenance"]["carried_forward"] is True
assert closed["provenance"]["age_days"] == 29
assert closed["provenance"]["verification_status"] == "verified"
assert closed["provenance"]["evidence_kind"] == "manual_confirmed"
assert closed["provenance"]["approval"] == closed["approval"]
assert closed["provenance"]["correction"] == closed["correction"]
def test_reconciliation_uses_currency_and_loan_priority_and_canonical_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
investment = service.create_ledger_account("투자", type="investment", currency="USD", last_balance=100, db_path=path)["account"]["id"]
loan = service.create_ledger_account("대출", type="loan", last_balance=100, db_path=path)["account"]["id"]
service.register_account_valuation(investment, "2026-06-30", 100, currency="KRW", valuation_kind="total_value", verification_status="verified", db_path=path)
loan_value = service.register_account_valuation(loan, "2026-06-30", 80, valuation_kind="loan_balance", representative_liability_definition="principal_only", verification_status="verified", db_path=path)
report = service.get_balance_reconciliation_report(account_id=loan, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
item = report["items"][0]
assert item["external_confirmed_value"] == 80
assert item["valuation_id"] == loan_value["valuation"]["id"]
assert item["valuation_kind"] == "loan_balance"
assert item["provenance"]["valuation_date"] == "2026-06-30"
assert item["provenance"]["verification_status"] == "verified"
def test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("투자", type="investment", db_path=path)["account"]["id"]
report = service.get_balance_reconciliation_report(account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
item = report["items"][0]
assert item["status"] == "VALUATION_UNAVAILABLE"
assert item["calculation_complete"] is False
assert item["difference"] is None
assert item["external_confirmed_value"] is None
def test_timeseries_and_monthly_expose_canonical_asset_provenance(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=path)
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
timeseries = service.get_net_worth_timeseries(start_date="2026-06-01", end_date="2026-06-30", db_path=path)
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
for payload in (report, timeseries["points"][0], monthly["reports"]["assets"], monthly["source_coverage"]["assets"]):
assert "provenance" in payload
assert "valuation_date" in payload["provenance"]
assert "warnings" in payload["provenance"]
assert "exclusions" in payload["provenance"]
assert report["provenance"]["snapshot_run_id"] == closed["snapshot_run_id"]
def test_reconciliation_does_not_mark_last_balance_without_opening_evidence_complete(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("manual", last_balance=1000, last_synced_at="2026-06-30", db_path=path)["account"]["id"]
item = service.get_balance_reconciliation_report(account_id=account, db_path=path)["items"][0]
assert item["calculation_complete"] is False
assert item["difference"] is None
assert item["status"] == "INCOMPLETE_CALCULATION"
def test_monthly_settlement_propagates_snapshot_provenance_id_and_actual_valuation_date(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot("2026-06-30", db_path=path, selection_policy="as_of", allow_as_of_carry_forward=True, approved_by="r", approval_reason="r", approval_reference="r")
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
assets = monthly["source_coverage"]["assets"]
assert assets["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
assert assets["provenance"]["valuation_date"] == "2026-06-01"
assert "warnings" in assets and "excluded_items" in assets and "carried_items" in assets
def test_approved_carry_provenance_is_identical_across_closed_downstream_reports(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
point = service.get_net_worth_timeseries(start_date="2026-06-30", end_date="2026-06-30", db_path=path)["points"][0]
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
payloads = [report, point, monthly["reports"]["assets"], monthly["source_coverage"]["assets"]]
expected = {key: closed[key] for key in ("warnings", "excluded_items", "carried_items", "approval", "correction")}
expected.update({key: closed["provenance"][key] for key in ("valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")})
for payload in payloads:
assert payload.get("approval") == expected["approval"]
assert payload.get("correction") == expected["correction"]
assert payload.get("warnings") == expected["warnings"]
assert payload.get("excluded_items") == expected["excluded_items"]
assert payload.get("carried_items") == expected["carried_items"]
provenance = payload["provenance"]
for key in ("valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind"):
assert provenance[key] == expected[key]
assert report["approval"] == expected["approval"]
assert report["correction"] == expected["correction"]
assert point["approval"] == expected["approval"]
assert point["correction"] == expected["correction"]
assert monthly["source_coverage"]["assets"]["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
def test_preview_and_live_report_redact_excluded_source_note_and_memo(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
sentinel = "SHOULD_NOT_LEAK_C10"
account = service.create_ledger_account(
"unverified account", memo=sentinel, last_balance=100, last_synced_at="2026-06-30", db_path=path,
)["account"]["id"]
service.register_account_valuation(account, "2026-06-30", 100, verification_status="unverified", db_path=path)
service.create_manual_asset("missing manual", None, "2026-06-30", "cash", source_note=sentinel, memo=sentinel, db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
live = service.get_asset_snapshot_report("2026-06-30", db_path=path)
for payload in (preview, live):
assert sentinel not in json.dumps(payload, ensure_ascii=False)
assert all(sentinel not in json.dumps(item, ensure_ascii=False) for item in payload.get("warnings", []))
assert all(sentinel not in json.dumps(item, ensure_ascii=False) for item in payload.get("excluded_items", []))
assert sentinel not in json.dumps(payload.get("provenance", {}), ensure_ascii=False)
def test_live_report_and_monthly_propagate_canonical_carry_forward_metadata(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
live = service.get_asset_snapshot_report("2026-06-30", db_path=path, selection_policy="as_of")
monthly = service.get_monthly_settlement_summary("2026-06", db_path=path)
exact = service.get_asset_snapshot_report("2026-06-30", db_path=path)
expected_keys = {"warnings", "carried_items", "approval", "correction"}
assert expected_keys.issubset(live)
assert live["provenance"]["carried_forward"] is True
assert any(warning["code"] == "stale_valuation" for warning in live["warnings"])
assets = monthly["source_coverage"]["assets"]
assert expected_keys.issubset(assets)
assert assets["warnings"] == exact["warnings"]
assert assets["carried_items"] == exact["carried_items"]
assert assets["approval"] == exact["approval"]
assert assets["correction"] == exact["correction"]
def test_public_included_items_redact_source_note_and_memo_recursively(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
sentinel = "SHOULD_NOT_LEAK_C10_INCLUDED"
service.create_manual_asset("cash", 100, "2026-06-01", "cash", source_note=sentinel, memo=sentinel, db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path, selection_policy="as_of")
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
payloads = [preview, closed, report]
for payload in payloads:
assert sentinel not in json.dumps(payload.get("included_items", []), ensure_ascii=False)
assert all("source_note" not in item and "memo" not in item for item in payload.get("included_items", []))
assert sentinel not in json.dumps(report, ensure_ascii=False)
def test_snapshot_null_amount_remains_null_and_is_excluded_from_totals(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_asset_snapshot("2026-06-30", "manual_asset", "unvalued", "unvalued", None, "cash", db_path=path)
service.create_asset_snapshot("2026-06-30", "manual_asset", "zero", "zero", 0, "cash", db_path=path)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
amounts = {item["name"]: item["amount"] for item in report["included_items"]}
assert amounts == {"unvalued": None, "zero": 0}
assert report["total_assets"] == 0
assert report["buckets"][0]["item_count"] == 1
assert report["close_status"] == "incomplete_draft"
assert any(item["name"] == "unvalued" and item["reason"] == "missing_amount" for item in report["excluded_items"])
def test_idempotent_snapshot_close_replay_returns_original_immutable_response(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
kwargs = dict(basis_date="2026-06-30", db_path=path, idempotency_key="close-replay")
original = service.close_asset_snapshot(**kwargs)
replay = service.close_asset_snapshot(**kwargs)
assert replay == original
assert "reused" not in replay
for key in ("provenance", "approval", "correction", "warnings", "excluded_items", "carried_items", "buckets", "bucket_totals", "total_assets", "total_liabilities", "net_worth"):
assert replay[key] == original[key]
def test_asset_close_and_report_expose_provenance_aliases(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-01", "cash", db_path=path)
closed = service.close_asset_snapshot(
"2026-06-30", db_path=path, selection_policy="as_of",
allow_as_of_carry_forward=True, approved_by="reviewer",
approval_reason="approved carry", approval_reference="APR-59",
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
for payload in (closed, report):
assert payload["valuation_date"] == payload["provenance"]["valuation_date"] == "2026-06-01"
assert payload["age_days"] == payload["provenance"]["age_days"] == 29
assert payload["verification_status"] == payload["provenance"]["verification_status"] == "verified"
assert payload["evidence_kind"] == payload["provenance"]["evidence_kind"] == "manual_confirmed"
assert payload["monthly_source_snapshot_id"] == payload["provenance"]["monthly_source_snapshot_id"] == closed["snapshot_run_id"]
def test_report_invalid_basis_date_returns_stable_error_envelope(tmp_path):
result = service.get_asset_snapshot_report("not-a-date", db_path=str(tmp_path / "ledger.sqlite3"))
assert result == {"success": False, "error_code": "validation_error", "error": "invalid date: not-a-date"}
def test_manual_asset_evidence_has_canonical_currency_before_close(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", currency="krw", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["included_items"][0]["currency"] == "KRW"
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
assert closed["included_items"][0]["currency"] == "KRW"
with sqlite3.connect(path) as conn:
evidence = json.loads(conn.execute("SELECT applied_evidence FROM asset_snapshot_runs").fetchone()[0])
assert evidence[0]["currency"] == "KRW"
def test_close_rejects_authority_drift_since_preview(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert preview["preview_fingerprint"]
service.update_manual_asset("asset-does-not-exist", valuation_amount=101, db_path=path)
# Mutate the real authority row without changing the public API result shape.
with sqlite3.connect(path) as conn:
conn.execute("UPDATE manual_assets SET valuation_amount=101")
result = service.close_asset_snapshot("2026-06-30", db_path=path, preview_fingerprint=preview["preview_fingerprint"])
assert result["success"] is False
assert result["error_code"] == "authority_drift"
def test_close_metadata_is_allowlisted_recursively(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("cash", 100, "2026-06-30", "cash", db_path=path)
service.close_asset_snapshot("2026-06-30", db_path=path)
with sqlite3.connect(path) as conn:
conn.execute("UPDATE asset_snapshot_runs SET applied_evidence=?", ('[{"valuation_date":"2026-06-30","future_secret":{"source_note":"LEAK"}}]',))
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert "future_secret" not in json.dumps(report["close_metadata"])
assert "LEAK" not in json.dumps(report["close_metadata"])
def test_public_evidence_redacts_nested_notes_without_mutating_input_or_hash():
from jarca_ledger_mcp.application.assets_service import _evidence_state_hash, _public_evidence
item = {"amount": 0, "source_note": "top", "nested": {"memo": "deep", "keep": [1, {"source_note": "list"}]}}
before_hash = _evidence_state_hash(item)
redacted = _public_evidence(item)
assert redacted == {"amount": 0, "nested": {"keep": [1, {}]}}
assert item["nested"]["memo"] == "deep"
assert _evidence_state_hash(item) == before_hash
+218 -1
View File
@@ -40,11 +40,26 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
assert body.count("**State Invariants**") == 10
assert body.count("**판정 기준**") == 10
assert body.count("**검증**") == 10
classification = re.findall(
r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$",
body,
re.M,
)
assert len(classification) == 10
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
for marker in (
"v12", "idempotent", "append-only", "future", "unverified", "unknown",
"idempotency_conflict", "correction_conflict", "NULL", "VALUATION_MANAGED",
"conditional_approval_required", "MCP", "Dashboard", "auth-before-DB",
"read-only", "monthly", "synthetic", "실제 credential", "호환성",
"reported principal amount", "outstanding principal", "interest", "fee",
"same account/date/currency/purpose", "correction_reason", "no-write",
"source_ref", "evidence idempotency", "cannot roll back newer current cache",
"incomplete_draft", "explicit dated manual asset", "future unknown",
"reconciliation completeness", "unavailable investment", "loan purpose",
"no fake writes", "provenance", "writer", "bootstrap", "init_db",
"read_db", "mode=ro", "db_not_initialized", "no mkdir", "no migrate", "no commit",
):
assert marker in text, marker
@@ -60,7 +75,12 @@ def test_canonical_docs_have_required_sections_and_no_placeholders() -> None:
text = _text(rel)
assert "${" not in text, rel
data = _text("docs/data-model.md")
for marker in ("## 목적과 범위", "## 정본 엔터티", "## 관계와 불변조건", "## 저장과 마이그레이션", "schema 12", "account-valuations", "asset-snapshot-runs"):
for marker in (
"## 목적과 범위", "## 정본 엔터티", "## 관계와 불변조건", "## 저장과 마이그레이션", "schema 12", "account-valuations", "asset-snapshot-runs",
"reported principal amount", "outstanding principal", "NULL", "0", "source_ref", "evidence idempotency",
"correction parent", "same account/date/currency/purpose", "provenance", "reconciliation completeness",
"unavailable investment", "loan purpose", "no fake writes",
):
assert marker in data
interface = _text("docs/interface-contract.md")
for marker in ("## 목적과 범위", "## 계약", "## 호환성", "## 예시", "## 검증", "REQ-003", "register_account_valuation", "preview_asset_snapshot_close", "conditional_approval_required"):
@@ -115,3 +135,200 @@ def test_existing_auth_menu_evidence_inventory_is_preserved() -> None:
"streamlit_browser_evidence.py", "auth-before-DB", "cookie/cleanup/logout",
):
assert marker in interface + runtime + structure, marker
def test_req003_has_exact_canonical_numbered_sections() -> None:
body = REQ.read_text(encoding="utf-8").split("---", 2)[2]
headings = re.findall(r"^## (\d+)\. ", body, re.M)
assert headings == [str(n) for n in range(1, 12)]
assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M)
assert body.count("### REQ-003.") == 20
assert "### REQ-003.01-AC01" in body
assert "### REQ-003.10-AC01" in body
def test_d01_d06_semantics_are_explicit_and_canonical() -> None:
req = REQ.read_text(encoding="utf-8")
data = _text("docs/data-model.md")
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
all_docs = req + data + interface + runtime
for marker in (
"principal", "reported_principal_kind", "reported_principal_label",
"no generic label", "parent scope", "cyclic", "self parent",
"one child", "reason", "no-write", "account-scoped evidence identity",
"explicit key", "key/source mismatch", "last_balance", "external current comparison",
"opening evidence", "INCOMPLETE_CALCULATION", "investment unavailable",
"loan purpose", "loan definition", "ambiguity", "actual valuation_date",
"age", "carry", "verification", "run", "approval", "correction",
"warnings", "exclusions", "close response", "closed", "live", "timeseries",
"monthly", "monthly_source_snapshot_id", "read-model alias", "not a persistence field",
"init_db()", "read_db()", "mode=ro", "db_not_initialized", "no mkdir", "no migrate", "no commit",
):
assert marker in all_docs, marker
assert "generic `label`" not in all_docs
assert "`monthly_source_snapshot_id`" in all_docs
assert "missing database returns `db_not_initialized`" in interface
assert "initialized database read failures return `db_error`" in interface
assert "top-level `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`" in interface
assert "nested `provenance`" in interface
def test_documented_functions_fields_and_regression_tests_are_real() -> None:
import ast
expected = {
"jarca_ledger_mcp/application/account_valuation_service.py": {
"register_account_valuation", "list_account_valuations",
"select_account_valuation", "correct_account_valuation",
},
"jarca_ledger_mcp/application/assets_service.py": {
"preview_asset_snapshot_close", "close_asset_snapshot", "get_asset_snapshot_report",
},
}
structure = _text("docs/project-structure.md")
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
for rel, names in expected.items():
tree = ast.parse((ROOT / rel).read_text(encoding="utf-8"))
actual = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
assert set(names) <= set(actual), rel
for name in names:
documented = interface
node = actual[name]
parameter_names = [a.arg for a in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs)]
if node.args.vararg:
parameter_names.append(node.args.vararg.arg)
if node.args.kwarg:
parameter_names.append(node.args.kwarg.arg)
signature_line = next((line for line in documented.splitlines() if line.startswith(name + "(") and ": " in line), "")
assert signature_line, name
assert all(param in signature_line for param in parameter_names), name
assert "jarca_ledger_mcp/application/asset_account_services.py" in structure
assert (ROOT / "jarca_ledger_mcp/application/asset_account_services.py").exists()
tests = "\n".join(p.read_text(encoding="utf-8") for p in (ROOT / "tests").glob("test_issue_12_review59_*.py"))
for name in (
"test_valuation_round_trip_preserves_null_zero_and_physical_amount_mapping",
"test_source_ref_is_account_scoped_evidence_identity_without_explicit_key",
"test_direct_correction_requires_matching_existing_parent_and_reason",
"test_investment_without_verified_valuation_and_without_opening_balance_is_incomplete",
"test_timeseries_and_monthly_expose_canonical_asset_provenance",
"test_read_queries_missing_nested_db_return_stable_error_without_creating_path",
):
assert f"def {name}(" in tests, name
for marker in (
"success", "error_code", "error", "close_status", "warnings", "exclusions",
"snapshot_run_id", "valuation_date", "monthly_source_snapshot_id",
):
assert marker in interface + runtime
def test_d01_preservation_matrix_is_item_by_item_and_evidenced() -> None:
structure = _text("docs/project-structure.md")
required_items = {
"schema 11 legacy rows",
"auth gate and cookie lifecycle",
"REQ-002 menu labels/order",
"MCP envelope and tools",
"reconciliation and monthly read models",
}
matrix = structure.split("## D01 preservation matrix", 1)[1].split("## ", 1)[0]
assert "| item | source evidence | test evidence |" in matrix
for item in required_items:
rows = [line for line in matrix.splitlines() if item in line]
assert len(rows) == 1, item
assert rows[0].count("|") >= 4, item
assert "`" in rows[0], item
def test_d04_close_contract_enumerates_all_fields_and_aliases() -> None:
interface = _text("docs/interface-contract.md")
section = interface.split("## D04 snapshot close output contract", 1)[1].split("## ", 1)[0]
top_level = {
"snapshot_run_id", "close_status", "approval", "correction", "warnings",
"excluded_items", "carried_items", "provenance", "valuation_date", "age_days",
"verification_status", "evidence_kind", "monthly_source_snapshot_id",
}
nested = {"approval", "correction", "warnings", "excluded_items", "carried_items", "valuation_date", "age_days", "verification_status", "evidence_kind"}
for field in top_level:
assert f"`{field}`" in section, field
assert "top-level fields" in section
assert "nested `provenance` fields" in section
for field in nested:
assert f"`{field}`" in section, field
assert "alias" in section and "not a persistence field" in section
def test_d05_structure_maps_canonical_ownership_and_boundaries() -> None:
structure = _text("docs/project-structure.md")
section = structure.split("## D05 canonical ownership", 1)[1].split("## ", 1)[0]
required = {
"monthly settlement", "reconciliation", "policy", "read-only", "redaction",
"monthly_settlement_service.py", "reconciliation_service.py", "assets_service.py",
}
for marker in required:
assert marker in section, marker
assert "owner" in section and "boundary" in section
assert "preview" in section and "no write" in section
def test_d06_runtime_flow_states_cache_reconciliation_and_preview_guards() -> None:
runtime = _text("docs/runtime-flow.md")
section = runtime.split("## D06 runtime decision guards", 1)[1].split("## ", 1)[0]
for marker in (
"current cache", "no rollback", "reconciliation complete", "reconciliation unavailable",
"preview", "drift", "revalidation", "close", "no partial write",
):
assert marker in section, marker
assert "complete" in section and "unavailable" in section
def test_d04_contract_matches_final_close_and_replay_implementation() -> None:
interface = _text("docs/interface-contract.md")
req = _text("docs/requirements/req-003_account-valuation-history.md")
section = interface.split("## D04 snapshot close output contract", 1)[1].split("## ", 1)[0]
for marker in (
"replay payload equality",
"no replay-only reused",
"recursive close_metadata allowlist",
"redaction",
"source_type", "source_id", "authority_source", "valuation_id",
"state_hash", "applied_evidence", "excluded_evidence", "carried_evidence",
):
assert marker in section + req, marker
assert "replay response is reconstructed from the persisted run" in section
assert "unknown nested keys are dropped" in section
def test_d04_preview_fingerprint_signature_and_drift_are_exact() -> None:
interface = _text("docs/interface-contract.md")
runtime = _text("docs/runtime-flow.md")
section = interface.split("## D04 snapshot close output contract", 1)[1].split("## ", 1)[0]
assert "preview_fingerprint: str = \"\"" in interface
assert "preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes" in section
assert "preview output includes preview_fingerprint" in section
assert "authority_drift" in section + runtime
assert "no partial write" in section + runtime
def test_d05_dashboard_read_contract_and_d06_reconciliation_evidence_are_exact() -> None:
interface = _text("docs/interface-contract.md")
structure = _text("docs/project-structure.md")
runtime = _text("docs/runtime-flow.md")
req = _text("docs/requirements/req-003_account-valuation-history.md")
all_docs = interface + structure + runtime + req
for marker in (
"missing DB stable unavailable",
"no-create",
"initialized DB read failure is db_error",
"initialized read has no write",
"opening evidence",
"coverage",
"INCOMPLETE_CALCULATION",
"reconciliation unavailable",
"no inferred balance",
"no fake transaction",
):
assert marker in all_docs, marker
assert "Dashboard gateway" in interface + structure
assert "read_db()" in interface and "mode=ro" in interface
+1 -1
View File
@@ -80,7 +80,7 @@ def test_reconciliation_session_round_trip_and_report_integration(tmp_path: Path
assert report["success"] is True
item = report["items"][0]
assert item["reconciliation_session"]["id"] == session["session_id"]
assert item["status"] == "REVIEW_NEEDED"
assert item["status"] == "INCOMPLETE_CALCULATION"
closed = service.close_reconciliation_session(session_id=session["session_id"], db_path=db_path)
assert closed["success"] is True
+2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from jarca_ledger_mcp import service
from jarca_ledger_mcp.infrastructure.sqlite import init_db
from jarca_ledger_mcp.seoulpay_xlsx import parse_seoulpay_xlsx
@@ -42,6 +43,7 @@ def test_parse_seoulpay_xlsx_plain_file(tmp_path):
def test_import_seoulpay_xlsx_creates_account_and_expenses(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
init_db(db_path)
file_path = tmp_path / "서울페이.xlsx"
_write_sample_seoulpay_xlsx(file_path)
+9 -9
View File
@@ -216,8 +216,8 @@ def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
assert report["success"] is True
assert report["items"][0]["calculated_balance"] == 901_500
assert report["items"][0]["difference"] == 0
assert report["items"][0]["status"] == "OK"
assert report["items"][0]["difference"] is None
assert report["items"][0]["status"] == "INCOMPLETE_CALCULATION"
updated = service.update_ledger_account(account_id, statement_status="partial_import", last_balance=905_000, db_path=db_path)
assert updated["success"] is True
@@ -226,9 +226,9 @@ def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
review = service.get_balance_reconciliation_report(account_id=account_id, baseline_balance=905_000, baseline_at="2026-06", db_path=db_path)
assert review["success"] is True
assert review["baseline_at"] == "2026-06-30T23:59:00+09:00"
assert review["items"][0]["difference"] == 3_500
assert review["items"][0]["status"] == "REVIEW_NEEDED"
assert "확인필요" in review["items"][0]["recommended_decisions"]
assert review["items"][0]["difference"] is None
assert review["items"][0]["status"] == "INCOMPLETE_CALCULATION"
assert review["items"][0]["recommended_decisions"] == []
def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omitted(tmp_path):
@@ -251,8 +251,8 @@ def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omi
assert report["baseline_at"] is None
assert report["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00"
assert report["items"][0]["calculated_balance"] == 1_000
assert report["items"][0]["difference"] == 0
assert report["items"][0]["status"] == "OK"
assert report["items"][0]["difference"] is None
assert report["items"][0]["status"] == "INCOMPLETE_CALCULATION"
explicit = service.get_balance_reconciliation_report(
account_id=account_id,
@@ -264,8 +264,8 @@ def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omi
assert explicit["baseline_at"] == "2026-02-28T23:59:00+09:00"
assert explicit["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00"
assert explicit["items"][0]["calculated_balance"] == 800
assert explicit["items"][0]["difference"] == 0
assert explicit["items"][0]["status"] == "OK"
assert explicit["items"][0]["difference"] is None
assert explicit["items"][0]["status"] == "INCOMPLETE_CALCULATION"
def test_invalid_statement_status_rejected(tmp_path):