[verified] Implement account valuation history for issue 12
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def _account(db_path, currency="KRW"):
|
||||
result = service.create_ledger_account("주계좌", currency=currency, db_path=db_path)
|
||||
assert result["success"]
|
||||
return result["account"]["id"]
|
||||
|
||||
|
||||
def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
|
||||
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
|
||||
assert version == "12"
|
||||
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
|
||||
|
||||
|
||||
def test_register_same_idempotency_payload_reuses_row_and_changed_payload_conflicts(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = _account(path)
|
||||
first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
|
||||
same = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
|
||||
conflict = service.register_account_valuation(account_id, "2026-06-30", 1200, idempotency_key="sync-1", verification_status="verified", source_type="import", source_ref="stmt-1", db_path=path)
|
||||
assert first["success"] and same["success"]
|
||||
assert same["reused"] is True and same["valuation"]["id"] == first["valuation"]["id"]
|
||||
assert conflict["success"] is False and conflict["error_code"] == "idempotency_conflict"
|
||||
|
||||
|
||||
def test_select_exact_and_as_of_excludes_unverified_and_reports_carry_forward(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = _account(path)
|
||||
service.register_account_valuation(account_id, "2026-06-01", 1000, verification_status="verified", idempotency_key="v1", db_path=path)
|
||||
service.register_account_valuation(account_id, "2026-06-15", 2000, verification_status="unverified", idempotency_key="v2", db_path=path)
|
||||
exact = service.select_account_valuation(account_id, "2026-06-15", db_path=path)
|
||||
as_of = service.select_account_valuation(account_id, "2026-06-20", as_of=True, db_path=path)
|
||||
assert exact["success"] and exact["valuation"] is None
|
||||
assert exact["warning"] == "unverified_candidate"
|
||||
assert as_of["success"] and as_of["valuation"]["amount"] == 1000
|
||||
assert as_of["carried_forward"] is True and as_of["valuation_age_days"] == 19
|
||||
|
||||
|
||||
def test_correction_is_append_only_reasoned_and_single_child(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = _account(path)
|
||||
original = service.register_account_valuation(account_id, "2026-06-30", 1000, verification_status="verified", idempotency_key="v1", db_path=path)
|
||||
corrected = service.correct_account_valuation(original["valuation"]["id"], 1200, correction_reason="은행 원장 정정", idempotency_key="v2", db_path=path)
|
||||
duplicate = service.correct_account_valuation(original["valuation"]["id"], 1300, correction_reason="두 번째 분기", idempotency_key="v3", db_path=path)
|
||||
listed = service.list_account_valuations(account_id, db_path=path)
|
||||
assert corrected["success"] and corrected["valuation"]["supersedes_id"] == original["valuation"]["id"]
|
||||
assert duplicate["success"] is False and duplicate["error_code"] == "correction_conflict"
|
||||
assert listed["count"] == 2
|
||||
selected = service.select_account_valuation(account_id, "2026-06-30", db_path=path)
|
||||
assert selected["valuation"]["amount"] == 1200
|
||||
|
||||
|
||||
def test_account_balance_write_records_unverified_candidate_only_with_real_basis_date(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account("동기화 계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path)
|
||||
service.create_ledger_account("날짜 없는 계좌", last_balance=700, db_path=path)
|
||||
values = service.list_account_valuations(created["account"]["id"], db_path=path)
|
||||
assert values["count"] == 1
|
||||
assert values["valuations"][0]["verification_status"] == "unverified"
|
||||
assert values["valuations"][0]["basis_date"] == "2026-06-30"
|
||||
|
||||
|
||||
def test_idempotency_scope_includes_basis_and_correction_metadata(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = _account(path)
|
||||
first = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", db_path=path)
|
||||
changed_basis = service.register_account_valuation(account_id, "2026-07-01", 1000, idempotency_key="same", db_path=path)
|
||||
changed_reason = service.register_account_valuation(account_id, "2026-06-30", 1000, idempotency_key="same", supersedes_id="other", correction_reason="정정", db_path=path)
|
||||
assert first["success"]
|
||||
assert changed_basis["error_code"] == "idempotency_conflict"
|
||||
assert changed_reason["error_code"] == "idempotency_conflict"
|
||||
|
||||
|
||||
def test_correction_preserves_optional_fields_unless_overridden(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = _account(path, currency="USD")
|
||||
original = service.register_account_valuation(account_id, "2026-06-30", 1000, currency="USD", cost_basis=900, net_contributed=950, profit=50, return_rate=0.055, principal=800, interest=20, fee=5, as_of_at="2026-06-30T12:00:00+09:00", timezone="Asia/Seoul", db_path=path)
|
||||
corrected = service.correct_account_valuation(original["valuation"]["id"], 1100, correction_reason="정정", idempotency_key="corr", profit=200, db_path=path)
|
||||
assert corrected["success"]
|
||||
value = corrected["valuation"]
|
||||
assert value["cost_basis"] == 900 and value["net_contributed"] == 950
|
||||
assert value["profit"] == 200 and value["return_rate"] == 0.055
|
||||
assert value["principal"] == 800 and value["interest"] == 20 and value["fee"] == 5
|
||||
assert value["as_of_at"] == "2026-06-30T12:00:00+09:00" and value["timezone"] == "Asia/Seoul"
|
||||
|
||||
|
||||
def test_account_name_only_update_does_not_create_candidate(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account("계좌", last_balance=500, last_synced_at="2026-06-30", db_path=path)
|
||||
account_id = created["account"]["id"]
|
||||
updated = service.update_ledger_account(account_id, name="새 이름", db_path=path)
|
||||
values = service.list_account_valuations(account_id, db_path=path)
|
||||
assert updated["success"] and values["count"] == 1
|
||||
@@ -25,6 +25,8 @@ def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path):
|
||||
stocks = service.create_ledger_account("투자", type="investment", last_balance=2_000_000, liquidity_bucket="investment", db_path=db_path)
|
||||
missing = service.create_ledger_account("미평가 계좌", liquidity_bucket="deposit", db_path=db_path)
|
||||
assert cash["success"] and stocks["success"] and missing["success"]
|
||||
assert service.register_account_valuation(cash["account"]["id"], "2026-06-30", 1_000_000, verification_status="verified", idempotency_key="cash-v1", db_path=db_path)["success"]
|
||||
assert service.register_account_valuation(stocks["account"]["id"], "2026-06-30", 2_000_000, verification_status="verified", idempotency_key="stocks-v1", db_path=db_path)["success"]
|
||||
|
||||
house = service.create_manual_asset("전세보증금", 300_000_000, "2026-06-30", "real_estate", source_note="synthetic appraisal", db_path=db_path)
|
||||
loan = service.create_manual_asset("주담대", 120_000_000, "2026-06-30", "loan", source_note="synthetic loan balance", db_path=db_path)
|
||||
@@ -92,6 +94,7 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
|
||||
loan = service.create_manual_asset("대출", 120_000_000, "2026-06-30", "loan", source_note="synthetic", db_path=db_path)
|
||||
dormant = service.create_manual_asset("해지 예금", 5_000_000, "2026-06-30", "deposit", source_note="synthetic", db_path=db_path)
|
||||
assert account["success"] and asset["success"] and loan["success"] and dormant["success"]
|
||||
assert service.register_account_valuation(account["account"]["id"], "2026-06-30", 1_000_000, verification_status="verified", idempotency_key="cash-v1", db_path=db_path)["success"]
|
||||
|
||||
updated = service.update_manual_asset(asset["manual_asset"]["id"], valuation_amount=310_000_000, memo="updated synthetic", db_path=db_path)
|
||||
assert updated["success"] is True
|
||||
|
||||
@@ -67,6 +67,11 @@ def _build_contract_fixture(db_path: str) -> None:
|
||||
)
|
||||
assert account["success"] is True
|
||||
account_id = account["account"]["id"]
|
||||
assert service.register_account_valuation(
|
||||
account_id, "2026-06-30", 1_000_000, verification_status="verified",
|
||||
source_type="manual", source_ref="contract-fixture", idempotency_key="contract-account-v1",
|
||||
db_path=db_path,
|
||||
)["success"] is True
|
||||
assert service.create_ledger_transaction(
|
||||
"2026-06-01",
|
||||
"income",
|
||||
@@ -114,8 +119,9 @@ def test_list_report_and_mutation_success_envelope_contract(tmp_path: Path) -> N
|
||||
db_path = str(tmp_path / "contract-fixture.sqlite3")
|
||||
_build_contract_fixture(db_path)
|
||||
|
||||
mutation = service.create_ledger_account("Synthetic Savings", type="bank", liquidity_bucket="deposit", db_path=db_path)
|
||||
mutation = service.create_ledger_account("Synthetic Savings", type="bank", liquidity_bucket="deposit", last_balance=0, last_synced_at="2026-06-30", db_path=db_path)
|
||||
assert mutation["success"] is True
|
||||
assert service.register_account_valuation(mutation["account"]["id"], "2026-06-30", 0, verification_status="verified", idempotency_key="savings-v1", db_path=db_path)["success"]
|
||||
_assert_subset_schema(mutation, contract["mutation_success_envelope"], "mutation_success_envelope")
|
||||
|
||||
manual_asset = service.create_manual_asset("Synthetic Deposit", 2_000_000, "2026-06-30", "deposit", source_note="synthetic contract", db_path=db_path)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import sqlite3
|
||||
import inspect
|
||||
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.imports_service import _record_import_valuation_candidate
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def test_empty_conditional_close_is_closed_and_second_close_requires_correction(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_ledger_account("미확인 계좌", db_path=path)
|
||||
first = service.close_asset_snapshot(
|
||||
"2026-06-30", selection_policy="exact", allow_missing_exclusions=True,
|
||||
approved_by="reviewer", approval_reason="approved empty close", approval_reference="APR-1", db_path=path,
|
||||
)
|
||||
assert first["success"] and first["close_status"] == "conditional" and first["inserted_count"] == 0
|
||||
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
|
||||
assert report["is_closed_snapshot"] is True and report["snapshot_count"] == 0
|
||||
assert report["close_metadata"]["snapshot_run_id"] == first["snapshot_run_id"]
|
||||
second = service.close_asset_snapshot(
|
||||
"2026-06-30", selection_policy="exact", allow_missing_exclusions=True,
|
||||
approved_by="reviewer", approval_reason="again", approval_reference="APR-2", db_path=path,
|
||||
)
|
||||
assert second["success"] is False and second["error_code"] == "validation_error"
|
||||
|
||||
|
||||
def test_reconciliation_uses_corrected_investment_leaf_without_transaction_count_change(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"]
|
||||
original = service.register_account_valuation(account, "2026-06-30", 100, valuation_kind="total_value", verification_status="verified", idempotency_key="v1", db_path=path)
|
||||
corrected = service.correct_account_valuation(original["valuation"]["id"], 250, correction_reason="정정", idempotency_key="v2", db_path=path)
|
||||
assert corrected["success"]
|
||||
report = service.get_balance_reconciliation_report(account_id=account, baseline_at="2026-06-30T00:00:00+09:00", db_path=path)
|
||||
assert report["items"][0]["external_confirmed_value"] == 250
|
||||
assert report["items"][0]["transaction_count"] == 0
|
||||
|
||||
|
||||
def test_reconciliation_prefers_investment_total_value_over_balance(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"]
|
||||
total = service.register_account_valuation(
|
||||
account, "2026-06-30", 250, valuation_kind="total_value",
|
||||
verification_status="verified", idempotency_key="total", db_path=path,
|
||||
)
|
||||
service.register_account_valuation(
|
||||
account, "2026-06-30", 100, valuation_kind="balance",
|
||||
verification_status="verified", idempotency_key="balance", 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["external_confirmed_value"] == 250
|
||||
assert item["valuation_id"] == total["valuation"]["id"]
|
||||
assert item["valuation_kind"] == "total_value"
|
||||
|
||||
|
||||
def test_historical_account_update_preserves_current_and_adds_one_candidate(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account("계좌", last_balance=900, last_synced_at="2026-06-30", db_path=path)
|
||||
account = created["account"]["id"]
|
||||
updated = service.update_ledger_account(account, last_balance=700, last_synced_at="2026-06-01", db_path=path)
|
||||
assert updated["account"]["last_balance"] == 900 and updated["account"]["last_synced_at"] == "2026-06-30"
|
||||
values = service.list_account_valuations(account, db_path=path)["valuations"]
|
||||
assert len(values) == 2 and sum(v["basis_date"] == "2026-06-01" for v in values) == 1
|
||||
|
||||
|
||||
def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_path):
|
||||
path = str(tmp_path / "legacy.sqlite3")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE accounts (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'bank',
|
||||
institution TEXT NOT NULL DEFAULT '', currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
active INTEGER NOT NULL DEFAULT 1, memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE transactions (
|
||||
id TEXT PRIMARY KEY, transaction_date TEXT NOT NULL, type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL, category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '', person TEXT NOT NULL DEFAULT '',
|
||||
account_id TEXT NOT NULL DEFAULT '', dedupe_key TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX legacy_transactions_date ON transactions(transaction_date);
|
||||
CREATE TABLE asset_snapshots (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO schema_meta(key,value) VALUES('schema_version','11');
|
||||
INSERT INTO accounts(id,name,type,currency,created_at,updated_at) VALUES('a','old','bank','KRW','t','t');
|
||||
INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES('t','2026-01-01','expense',10,'t','t');
|
||||
INSERT INTO asset_snapshots(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
|
||||
""")
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT name FROM accounts WHERE id='a'").fetchone()
|
||||
assert conn.execute("SELECT id FROM transactions WHERE id='t'").fetchone()
|
||||
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
|
||||
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
|
||||
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "12"
|
||||
|
||||
|
||||
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
|
||||
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", source_note="source-a", memo="memo-a",
|
||||
idempotency_key="close-1", db_path=path,
|
||||
)
|
||||
same = service.close_asset_snapshot(
|
||||
"2026-06-30", source_note="source-a", memo="memo-a",
|
||||
idempotency_key="close-1", db_path=path,
|
||||
)
|
||||
changed = service.close_asset_snapshot(
|
||||
"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 changed["success"] is False and changed["error_code"] == "idempotency_conflict"
|
||||
|
||||
other_asset = first["included_items"][0]["source_id"]
|
||||
service.update_manual_asset(other_asset, memo="changed evidence metadata", db_path=path)
|
||||
changed_evidence = service.close_asset_snapshot(
|
||||
"2026-06-30", source_note="source-a", memo="memo-a",
|
||||
idempotency_key="close-1", db_path=path,
|
||||
)
|
||||
assert changed_evidence["success"] is False
|
||||
assert changed_evidence["error_code"] == "idempotency_conflict"
|
||||
|
||||
other_path = str(tmp_path / "concurrent.sqlite3")
|
||||
service.create_manual_asset("현금", 100, "2026-06-30", "cash", currency="KRW", db_path=other_path)
|
||||
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
|
||||
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_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"]
|
||||
with sqlite3.connect(path) as raw:
|
||||
raw.row_factory = sqlite3.Row
|
||||
first = _record_import_valuation_candidate(raw, account, 100, "2026-06-30", "sha")
|
||||
second = _record_import_valuation_candidate(raw, account, 100, "2026-06-30", "sha")
|
||||
count = raw.execute("SELECT count(*) FROM account_valuations WHERE account_id=?", (account,)).fetchone()[0]
|
||||
assert first is None and second is None and count == 1
|
||||
|
||||
|
||||
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"]
|
||||
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||
assert service.register_account_valuation(bank, "2026-06-30", 1, currency="USD", db_path=path)["success"] is False
|
||||
assert service.register_account_valuation(bank, "2026-06-30", 1, valuation_kind="loan", representative_liability_definition="principal_only", db_path=path)["success"] is False
|
||||
assert service.register_account_valuation(loan, "2026-06-30", 1, valuation_kind="loan", representative_liability_definition="principal_only", db_path=path)["success"] is True
|
||||
|
||||
|
||||
def test_loan_representative_is_always_aggregated_as_liability(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
loan = service.create_ledger_account(
|
||||
"대출", type="loan", last_balance=100,
|
||||
last_synced_at="2026-06-30", db_path=path,
|
||||
)["account"]["id"]
|
||||
service.register_account_valuation(
|
||||
loan, "2026-06-30", 100, valuation_kind="loan",
|
||||
representative_liability_definition="principal_only",
|
||||
verification_status="verified", db_path=path,
|
||||
)
|
||||
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
|
||||
assert report["total_assets"] == 0
|
||||
assert report["total_liabilities"] == 100
|
||||
assert report["net_worth"] == -100
|
||||
assert report["included_items"][0]["liquidity_bucket"] == "loan"
|
||||
|
||||
|
||||
def test_loan_balance_fallback_without_representative_definition_is_excluded(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
loan = service.create_ledger_account(
|
||||
"대출", type="loan", last_balance=100,
|
||||
last_synced_at="2026-06-30", db_path=path,
|
||||
)["account"]["id"]
|
||||
service.register_account_valuation(
|
||||
loan, "2026-06-30", 100, valuation_kind="balance",
|
||||
verification_status="verified", db_path=path,
|
||||
)
|
||||
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
|
||||
assert report["total_liabilities"] == 0
|
||||
assert report["net_worth"] == 0
|
||||
assert report["excluded_items"][0]["reason"] == "missing_representative_liability_definition"
|
||||
|
||||
|
||||
def test_warning_candidate_public_payload_is_minimal():
|
||||
class Row(dict):
|
||||
__getattr__ = dict.__getitem__
|
||||
row = Row(id="v", basis_date="2026-06-30", currency="KRW", valuation_kind="balance", verification_status="unverified", memo="secret", source_ref="secret", name="secret")
|
||||
payload = _public_candidate(row)
|
||||
assert set(payload) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"}
|
||||
|
||||
|
||||
def test_snapshot_report_redacts_unverified_candidate_sensitive_fields(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", 10, verification_status="unverified",
|
||||
source_ref="TOPSECRET", memo="PASSWORD=secret", idempotency_key="private",
|
||||
db_path=path,
|
||||
)
|
||||
report = service.get_asset_snapshot_report("2026-06-30", db_path=path)
|
||||
encoded = repr(report)
|
||||
assert "TOPSECRET" not in encoded and "PASSWORD=secret" not in encoded
|
||||
candidates = report["excluded_items"][0]["unverified_candidates"]
|
||||
assert set(candidates[0]) == {"id", "basis_date", "currency", "valuation_kind", "verification_status"}
|
||||
|
||||
|
||||
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
|
||||
service.create_manual_asset("달러", 100, "2026-06-30", "other", currency="USD", db_path=path)
|
||||
report = service.get_asset_liquidity_report(db_path=path)
|
||||
assert report["success"] and report["total_assets"] == 0
|
||||
assert any(item.get("reason") == "non_krw_without_fx" for item in report["excluded_items"])
|
||||
|
||||
|
||||
def test_account_write_rolls_back_when_conn_level_candidate_write_fails(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
import jarca_ledger_mcp.application.accounts_write_services as writes
|
||||
monkeypatch.setattr(writes, "_record_balance_candidate", lambda *_args: (_ for _ in ()).throw(RuntimeError("injected")))
|
||||
try:
|
||||
service.create_ledger_account("원자성", last_balance=1, last_synced_at="2026-06-30", db_path=path)
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert service.list_ledger_accounts(active_only=False, db_path=path)["count"] == 0
|
||||
|
||||
|
||||
def test_mcp_registry_keeps_category_report_and_correction_signature_is_explicit():
|
||||
from jarca_ledger_mcp.adapters.mcp import server
|
||||
source = inspect.getsource(server)
|
||||
assert "def get_category_spending_report" in source
|
||||
signature = inspect.signature(server.correct_account_valuation)
|
||||
assert not any(param.kind is inspect.Parameter.VAR_KEYWORD for param in signature.parameters.values())
|
||||
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
|
||||
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
health = service.health_check(db_path)
|
||||
assert health["schema_version"] == "11"
|
||||
assert health["schema_version"] == "12"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
DOCS = ROOT / "docs"
|
||||
REQ = DOCS / "requirements" / "req-003_account-valuation-history.md"
|
||||
|
||||
|
||||
def _text(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_requirement_docs_are_canonical_and_traceable() -> None:
|
||||
text = REQ.read_text(encoding="utf-8")
|
||||
_, raw, body = text.split("---", 2)
|
||||
metadata = json.loads(raw)
|
||||
assert metadata == {
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-003",
|
||||
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
|
||||
"revision": "1.0",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#12",
|
||||
"implementation_pr": "미정",
|
||||
}
|
||||
assert re.search(r"^# REQ-003 — ", body, re.M)
|
||||
assert "${" not in text
|
||||
requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M)
|
||||
assert requirements == [f"{n:02d}" for n in range(1, 11)]
|
||||
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
|
||||
assert len(acs) == 10
|
||||
assert body.count("**영향 범위**") == 10
|
||||
assert body.count("**Cases (정상/경계/실패)**") == 10
|
||||
assert body.count("**State Invariants**") == 10
|
||||
assert body.count("**판정 기준**") == 10
|
||||
assert body.count("**검증**") == 10
|
||||
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", "호환성",
|
||||
):
|
||||
assert marker in text, marker
|
||||
|
||||
|
||||
def test_canonical_docs_have_required_sections_and_no_placeholders() -> None:
|
||||
paths = [
|
||||
"docs/data-model.md",
|
||||
"docs/interface-contract.md",
|
||||
"docs/project-structure.md",
|
||||
"docs/runtime-flow.md",
|
||||
]
|
||||
for rel in paths:
|
||||
text = _text(rel)
|
||||
assert "${" not in text, rel
|
||||
data = _text("docs/data-model.md")
|
||||
for marker in ("## 목적과 범위", "## 정본 엔터티", "## 관계와 불변조건", "## 저장과 마이그레이션", "schema 12", "account-valuations", "asset-snapshot-runs"):
|
||||
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"):
|
||||
assert marker in interface
|
||||
structure = _text("docs/project-structure.md")
|
||||
for marker in ("## 목적과 경계", "## 디렉터리 구조", "## 의존 방향", "## 변경 규칙", "## 검증", "account_valuation_service.py", "assets_service.py", "Missing semantic items: 0", "Ambiguous semantic items: 0"):
|
||||
assert marker in structure
|
||||
runtime = _text("docs/runtime-flow.md")
|
||||
for marker in ("## 목적과 범위", "## 흐름", "## 실패와 복구", "### 동시성과 순서", "## 관찰 가능성", "## 검증", "valuation-register", "snapshot-preview-close", "close-04", "prop-03"):
|
||||
assert marker in runtime
|
||||
|
||||
|
||||
def test_documented_paths_and_symbols_exist() -> None:
|
||||
paths = [
|
||||
"jarca_ledger_mcp/application/account_valuation_service.py",
|
||||
"jarca_ledger_mcp/application/assets_service.py",
|
||||
"jarca_ledger_mcp/application/accounts_write_services.py",
|
||||
"jarca_ledger_mcp/application/imports_service.py",
|
||||
"jarca_ledger_mcp/application/reconciliation_service.py",
|
||||
"jarca_ledger_mcp/infrastructure/sqlite/migrations.py",
|
||||
"jarca_ledger_mcp/adapters/mcp/server.py",
|
||||
"jarca_ledger_mcp/adapters/streamlit/accounts_gateway.py",
|
||||
"jarca_ledger_mcp/adapters/streamlit/assets_gateway.py",
|
||||
"dashboard/page_views/accounts.py",
|
||||
"dashboard/page_views/assets.py",
|
||||
"tests/test_account_valuations.py",
|
||||
"tests/test_asset_snapshots.py",
|
||||
"tests/test_contract_regression.py",
|
||||
]
|
||||
for rel in paths:
|
||||
assert (ROOT / rel).exists(), rel
|
||||
service = _text("jarca_ledger_mcp/application/account_valuation_service.py")
|
||||
for symbol in ("def register_account_valuation(", "def list_account_valuations(", "def select_account_valuation(", "def correct_account_valuation("):
|
||||
assert symbol in service
|
||||
assets = _text("jarca_ledger_mcp/application/assets_service.py")
|
||||
for symbol in ("def preview_asset_snapshot_close(", "def close_asset_snapshot(", "def get_asset_snapshot_report("):
|
||||
assert symbol in assets
|
||||
migrations = _text("jarca_ledger_mcp/infrastructure/sqlite/migrations.py")
|
||||
for marker in ("CREATE TABLE IF NOT EXISTS account_valuations", "CREATE TABLE IF NOT EXISTS asset_snapshot_runs", "SCHEMA_VERSION"):
|
||||
assert marker in migrations
|
||||
|
||||
|
||||
def test_existing_auth_menu_evidence_inventory_is_preserved() -> None:
|
||||
interface = _text("docs/interface-contract.md")
|
||||
runtime = _text("docs/runtime-flow.md")
|
||||
structure = _text("docs/project-structure.md")
|
||||
for marker in (
|
||||
"POST /api/auth/login", "GET /api/auth/cleanup", "POST /api/auth/logout",
|
||||
"require_dashboard_authentication()", "__Host-jarca_dashboard_auth",
|
||||
"월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류",
|
||||
"분류 규칙", "카테고리", "계좌", "Import", "st.query_params[\"page\"]",
|
||||
"streamlit_browser_evidence.py", "auth-before-DB", "cookie/cleanup/logout",
|
||||
):
|
||||
assert marker in interface + runtime + structure, marker
|
||||
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
|
||||
assert status["integrity_check"] == "ok"
|
||||
assert status["table_counts"]["review_decisions"] == 0
|
||||
assert status["table_counts"]["reconciliation_sessions"] == 0
|
||||
assert service.health_check(db_path)["schema_version"] == "11"
|
||||
assert service.health_check(db_path)["schema_version"] == "12"
|
||||
|
||||
|
||||
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
|
||||
db_path = tmp_path / "ledger.sqlite3"
|
||||
result = service.health_check(str(db_path))
|
||||
assert result["success"] is True
|
||||
assert result["schema_version"] == "11"
|
||||
assert result["schema_version"] == "12"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user