fix: address account valuation review findings
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user