fix: harden valuation concurrency and currency scope

This commit is contained in:
2026-10-06 09:22:19 +00:00
parent a4cad342a6
commit 6455c70413
3 changed files with 98 additions and 7 deletions
+86
View File
@@ -4,6 +4,7 @@ import json
from concurrent.futures import ThreadPoolExecutor
from jarca_ledger_mcp import service
from jarca_ledger_mcp.application import assets_service
from jarca_ledger_mcp.application.account_valuation_service import (
_public_candidate,
record_account_valuation_candidate,
@@ -147,10 +148,31 @@ def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_p
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: service.close_asset_snapshot("2026-06-30", db_path=other_path), range(2)))
assert sum(bool(result.get("success")) for result in results) == 1
conflict = next(result for result in results if not result.get("success"))
assert "detail" not in conflict
with sqlite3.connect(other_path) as conn:
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE basis_date='2026-06-30'").fetchone()[0] == 1
def test_snapshot_integrity_conflict_does_not_expose_sqlite_detail(tmp_path, monkeypatch):
path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=path)
first = service.close_asset_snapshot("2026-06-30", db_path=path)
real_new_id = assets_service.new_id
def colliding_run_id(prefix):
return first["snapshot_run_id"] if prefix == "snaprun" else real_new_id(prefix)
monkeypatch.setattr(assets_service, "new_id", colliding_run_id)
conflict = service.close_asset_snapshot(
"2026-06-30", correction_reason="retry collision", db_path=path,
)
assert conflict["success"] is False
assert conflict["error_code"] == "snapshot_conflict"
assert conflict["error"] == "another snapshot close already owns this basis date"
assert "detail" not in conflict
def test_import_candidate_helper_reuses_deterministic_duplicate_on_same_connection(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
@@ -200,6 +222,42 @@ def test_correction_idempotency_compares_effective_preserved_payload(tmp_path):
assert retry_with_omitted_memo["error_code"] == "idempotency_conflict"
def test_concurrent_valuation_idempotency_reuses_same_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
def register(_):
return service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified",
idempotency_key="same", db_path=path,
)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(register, range(2)))
assert all(result["success"] for result in results)
assert len({result["valuation"]["id"] for result in results}) == 1
assert sorted(result["reused"] for result in results) == [False, True]
def test_concurrent_correction_idempotency_reuses_same_payload(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
original = service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified", db_path=path,
)["valuation"]
def correct(_):
return service.correct_account_valuation(
original["id"], 200, "정정", idempotency_key="same-correction", db_path=path,
)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(correct, range(2)))
assert all(result["success"] for result in results)
assert len({result["valuation"]["id"] for result in results}) == 1
assert sorted(result["reused"] for result in results) == [False, True]
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"]
@@ -351,6 +409,34 @@ def test_snapshot_rejects_bogus_selection_policy_and_manual_usd_is_excluded(tmp_
assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"])
def test_non_krw_account_valuation_is_excluded_without_fx_conversion(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("달러 계좌", currency="USD", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, currency="USD",
verification_status="verified", db_path=path,
)
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
assert report["total_assets"] == 0
assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"])
def test_rejected_valuation_is_not_an_unverified_candidate(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
service.register_account_valuation(
account, "2026-06-30", 100, verification_status="verified", db_path=path,
)
service.register_account_valuation(
account, "2026-06-30", 999, verification_status="rejected", db_path=path,
)
selected = service.select_account_valuation(account, "2026-06-30", db_path=path)
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=path)
assert selected.get("warning") is None
assert selected.get("unverified_candidates", []) == []
assert not any(warning.get("code") == "unverified_candidate" for warning in preview["warnings"])
def test_legacy_snapshot_rows_do_not_override_live_authority_or_claim_closed(tmp_path):
path = str(tmp_path / "ledger.sqlite3")
service.create_asset_snapshot(