Fix Review 64 valuation authority regressions
This commit is contained in:
@@ -1,10 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account("투자 계좌", type="investment", db_path=db_path)
|
||||
account_id = account["account"]["id"]
|
||||
verified = service.register_account_valuation(
|
||||
account_id, "2026-06-30", 100, verification_status="verified",
|
||||
idempotency_key="valuation-verified", db_path=db_path,
|
||||
)
|
||||
candidate = service.register_account_valuation(
|
||||
account_id, "2026-06-30", 110, verification_status="unverified",
|
||||
idempotency_key="valuation-candidate", db_path=db_path,
|
||||
)
|
||||
|
||||
first = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
|
||||
)
|
||||
assert first["success"] is True
|
||||
assert first["included_items"][0]["unverified_candidates"] == [{
|
||||
"id": candidate["valuation"]["id"],
|
||||
"basis_date": "2026-06-30",
|
||||
"currency": "KRW",
|
||||
"valuation_kind": "balance",
|
||||
"verification_status": "unverified",
|
||||
}]
|
||||
assert {warning["blocking"] for warning in first["warnings"] if warning["code"] == "unverified_candidate"} == {False}
|
||||
assert first["included_items"][0]["valuation_id"] == verified["valuation"]["id"]
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
persisted = conn.execute("SELECT applied_evidence, excluded_evidence FROM asset_snapshot_runs WHERE id=?", (first["snapshot_run_id"],)).fetchone()
|
||||
applied_evidence, excluded_evidence = map(json.loads, persisted)
|
||||
assert applied_evidence[0]["unverified_candidates"] == first["included_items"][0]["unverified_candidates"]
|
||||
candidate_evidence = [item for item in excluded_evidence if item.get("reason") == "unverified_candidate"]
|
||||
assert candidate_evidence[0]["candidate"] == first["included_items"][0]["unverified_candidates"][0]
|
||||
assert candidate_evidence[0]["blocking"] is False
|
||||
assert candidate_evidence[0]["state_hash"]
|
||||
|
||||
corrected = service.close_asset_snapshot(
|
||||
"2026-06-30", correction_reason="preserve evidence", idempotency_key="candidate-close-b",
|
||||
db_path=db_path,
|
||||
)
|
||||
assert corrected["success"] is True
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||
replay = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
|
||||
)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
after_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||
|
||||
assert replay == first
|
||||
assert replay["included_items"] == first["included_items"]
|
||||
assert replay["warnings"] == first["warnings"]
|
||||
assert after_replay == before_replay
|
||||
assert after_runs == before_runs
|
||||
|
||||
|
||||
def test_replay_preserves_blocking_candidate_warning_shape(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = service.create_ledger_account(
|
||||
"미확인 계좌", type="investment", db_path=db_path,
|
||||
)["account"]["id"]
|
||||
service.register_account_valuation(
|
||||
account_id, "2026-06-30", 110, verification_status="unverified",
|
||||
idempotency_key="only-candidate", db_path=db_path,
|
||||
)
|
||||
|
||||
first = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="blocking-candidate-close",
|
||||
allow_missing_exclusions=True, approved_by="reviewer",
|
||||
approval_reason="exclude unverified evidence",
|
||||
approval_reference="synthetic-approval", db_path=db_path,
|
||||
)
|
||||
assert first["success"] is True
|
||||
warning = next(item for item in first["warnings"] if item["code"] == "unverified_candidate")
|
||||
assert "blocking" not in warning
|
||||
|
||||
replay = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="blocking-candidate-close",
|
||||
allow_missing_exclusions=True, approved_by="reviewer",
|
||||
approval_reason="exclude unverified evidence",
|
||||
approval_reference="synthetic-approval", db_path=db_path,
|
||||
)
|
||||
|
||||
assert replay == first
|
||||
replay_warning = next(item for item in replay["warnings"] if item["code"] == "unverified_candidate")
|
||||
assert "blocking" not in replay_warning
|
||||
|
||||
|
||||
def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
||||
first = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
|
||||
)
|
||||
assert first["success"] is True
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
|
||||
("{malformed", first["snapshot_run_id"]),
|
||||
)
|
||||
before_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||
|
||||
report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path)
|
||||
replay = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="malformed-evidence-close", db_path=db_path,
|
||||
)
|
||||
timeseries = service.get_net_worth_timeseries(
|
||||
"2026-06-01", "2026-06-30", db_path=db_path,
|
||||
)
|
||||
|
||||
for result in (report, replay, timeseries):
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "data_integrity_error"
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == before_rows
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == before_runs
|
||||
|
||||
|
||||
def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_manual_asset("현금", 100, "2026-06-30", "cash", db_path=db_path)
|
||||
|
||||
@@ -65,6 +65,42 @@ def test_late_import_current_balance_is_authoritative_by_evidence_basis_date(mon
|
||||
}
|
||||
|
||||
|
||||
def test_empty_balance_with_basis_date_is_rejected_without_mutation_before_import(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account("KB", institution="KB국민은행", db_path=path)["account"]["id"]
|
||||
before = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||
before_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||
|
||||
rejected = service.update_ledger_account(
|
||||
account,
|
||||
last_balance="",
|
||||
last_balance_basis_date="2026-07-01",
|
||||
db_path=path,
|
||||
)
|
||||
|
||||
after = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||
after_valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||
assert rejected["success"] is False
|
||||
assert rejected["error_code"] == "validation_error"
|
||||
assert after == before
|
||||
assert after_valuations == before_valuations
|
||||
|
||||
monkeypatch.setattr(
|
||||
imports_service,
|
||||
"parse_kb_bank_pdf",
|
||||
lambda *_: _parsed(_transaction("2026-06-30", 900, 1), "hash-valid"),
|
||||
)
|
||||
monkeypatch.setattr(imports_service, "now_iso", lambda: "2026-07-02T09:00:00")
|
||||
imported = imports_service.import_kb_bank_pdf("synthetic", "pw", account_id=account, db_path=path)
|
||||
|
||||
current = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"][0]
|
||||
valuations = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||
assert imported["success"] is True
|
||||
assert current["last_balance"] == 900
|
||||
assert current["last_balance_basis_date"] == "2026-06-30"
|
||||
assert {(item["basis_date"], item["amount"]) for item in valuations} == {("2026-06-30", 900)}
|
||||
|
||||
|
||||
def test_account_write_sync_timestamp_does_not_infer_balance_basis_or_candidate(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
unknown = service.create_ledger_account(
|
||||
|
||||
@@ -43,6 +43,25 @@ def test_interface_contract_documents_public_valuation_enums_validation_and_mapp
|
||||
assert marker in interface, marker
|
||||
|
||||
|
||||
def test_data_model_documents_valuation_selection_contract_and_index_mapping() -> None:
|
||||
data_model = _text("docs/data-model.md")
|
||||
|
||||
required_contract = (
|
||||
"select_account_valuation",
|
||||
"account_id is mandatory",
|
||||
"currency and valuation_kind/purpose are optional filters",
|
||||
"basis_date exact match or <= upper bound",
|
||||
"verified leaf valuations only",
|
||||
"future-dated and unverified valuations are excluded",
|
||||
"idx_account_valuations_account_date supports account/date selection",
|
||||
"idx_account_valuations_status_date supports status/date selection",
|
||||
"neither index covers account + currency + purpose",
|
||||
"idempotency and single_correction indexes enforce write integrity, not selection",
|
||||
)
|
||||
for marker in required_contract:
|
||||
assert marker in data_model, marker
|
||||
|
||||
|
||||
def test_loan_cache_writers_document_no_inferred_valuation_history() -> None:
|
||||
data_model = _text("docs/data-model.md")
|
||||
interface = _text("docs/interface-contract.md")
|
||||
|
||||
Reference in New Issue
Block a user