fix: harden valuation evidence selection
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import sqlite3
|
||||
import inspect
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.application.account_valuation_service import _public_candidate
|
||||
from jarca_ledger_mcp.application.account_valuation_service import (
|
||||
_public_candidate,
|
||||
record_account_valuation_candidate,
|
||||
)
|
||||
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
@@ -158,6 +162,24 @@ def test_import_candidate_helper_reuses_deterministic_duplicate_on_same_connecti
|
||||
assert first is None and second is None and count == 1
|
||||
|
||||
|
||||
def test_candidate_idempotency_rejects_changed_payload(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
first = record_account_valuation_candidate(
|
||||
conn, account, "2026-06-30", 100, "KRW", "import",
|
||||
"source-a", "candidate-key", "memo-a",
|
||||
)
|
||||
conflict = record_account_valuation_candidate(
|
||||
conn, account, "2026-07-01", 200, "KRW", "import",
|
||||
"source-b", "candidate-key", "memo-b",
|
||||
)
|
||||
assert first["success"] is True
|
||||
assert conflict["success"] is False
|
||||
assert conflict["error_code"] == "idempotency_conflict"
|
||||
|
||||
|
||||
def test_valuation_currency_and_loan_kind_are_rejected(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
bank = service.create_ledger_account("은행", currency="KRW", db_path=path)["account"]["id"]
|
||||
@@ -224,6 +246,82 @@ def test_snapshot_report_redacts_unverified_candidate_sensitive_fields(tmp_path)
|
||||
assert set(candidates[0]) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"}
|
||||
|
||||
|
||||
def test_preview_warns_about_unverified_candidate_when_verified_value_is_selected(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account(
|
||||
"투자", type="investment", db_path=path,
|
||||
)["account"]["id"]
|
||||
service.register_account_valuation(
|
||||
account, "2026-06-30", 100, valuation_kind="total_value",
|
||||
verification_status="verified", idempotency_key="verified", db_path=path,
|
||||
)
|
||||
service.register_account_valuation(
|
||||
account, "2026-06-30", 999, valuation_kind="total_value",
|
||||
verification_status="unverified", idempotency_key="candidate", db_path=path,
|
||||
)
|
||||
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
|
||||
warning = next(item for item in preview["warnings"] if item["code"] == "unverified_candidate")
|
||||
assert warning["source_id"] == account
|
||||
assert warning["candidates"][0]["verification_status"] == "unverified"
|
||||
closed = service.close_asset_snapshot("2026-06-30", db_path=path)
|
||||
assert closed["success"] is True
|
||||
with sqlite3.connect(path) as conn:
|
||||
evidence = json.loads(conn.execute(
|
||||
"SELECT excluded_evidence FROM asset_snapshot_runs WHERE id=?",
|
||||
(closed["snapshot_run_id"],),
|
||||
).fetchone()[0])
|
||||
assert evidence[0]["reason"] == "unverified_candidate"
|
||||
assert evidence[0]["candidate"]["verification_status"] == "unverified"
|
||||
|
||||
|
||||
def test_snapshot_idempotency_binds_redacted_candidate_state(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account(
|
||||
"투자", type="investment", db_path=path,
|
||||
)["account"]["id"]
|
||||
service.register_account_valuation(
|
||||
account, "2026-06-30", 100, valuation_kind="total_value",
|
||||
verification_status="verified", idempotency_key="verified", db_path=path,
|
||||
)
|
||||
candidate = service.register_account_valuation(
|
||||
account, "2026-06-30", 999, valuation_kind="total_value",
|
||||
verification_status="unverified", idempotency_key="candidate", db_path=path,
|
||||
)["valuation"]
|
||||
first = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="close-candidate", db_path=path,
|
||||
)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE account_valuations SET amount=?, source_ref=?, memo=? WHERE id=?",
|
||||
(1000, "changed-source", "changed-memo", candidate["id"]),
|
||||
)
|
||||
retry = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="close-candidate", db_path=path,
|
||||
)
|
||||
assert first["success"] is True
|
||||
assert retry["success"] is False
|
||||
assert retry["error_code"] == "idempotency_conflict"
|
||||
|
||||
|
||||
def test_reconciliation_fails_closed_on_ambiguous_verified_leaf(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account(
|
||||
"투자", type="investment", last_balance=100,
|
||||
last_synced_at="2026-06-30", db_path=path,
|
||||
)["account"]["id"]
|
||||
for key, amount in (("v1", 100), ("v2", 200)):
|
||||
service.register_account_valuation(
|
||||
account, "2026-06-30", amount, valuation_kind="total_value",
|
||||
verification_status="verified", idempotency_key=key, db_path=path,
|
||||
)
|
||||
item = service.get_balance_reconciliation_report(
|
||||
account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path,
|
||||
)["items"][0]
|
||||
assert item["status"] == "AMBIGUOUS_VALUATION"
|
||||
assert item["external_confirmed_value"] is None
|
||||
assert item["valuation_id"] == ""
|
||||
|
||||
|
||||
def test_snapshot_rejects_bogus_selection_policy_and_manual_usd_is_excluded(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
assert service.preview_asset_snapshot_close("2026-06-30", selection_policy="bogus", db_path=path)["success"] is False
|
||||
|
||||
Reference in New Issue
Block a user