fix: preserve schema 15 compatibility for v0.1.8
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.application.account_valuation_service import record_account_valuation_candidate
|
||||
from jarca_ledger_mcp.application.imports_service import _record_import_valuation_candidate
|
||||
|
||||
|
||||
def test_bank_to_loan_conversion_rejects_existing_undefined_valuation_without_mutation(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account(
|
||||
"은행", type="bank", last_balance=100, last_synced_at="2026-06-30",
|
||||
last_balance_basis_date="2026-06-30", db_path=path,
|
||||
)
|
||||
account_id = created["account"]["id"]
|
||||
before = service.list_account_valuations(account_id, db_path=path)
|
||||
assert before["count"] == 1
|
||||
assert before["valuations"][0]["representative_liability_definition"] is None
|
||||
|
||||
rejected = service.update_ledger_account(account_id, type="loan", db_path=path)
|
||||
|
||||
assert rejected["success"] is False
|
||||
assert rejected["error_code"] == "validation_error"
|
||||
accounts = service.list_ledger_accounts(active_only=False, db_path=path)["accounts"]
|
||||
assert next(account for account in accounts if account["id"] == account_id)["type"] == "bank"
|
||||
after = service.list_account_valuations(account_id, db_path=path)
|
||||
assert after["count"] == 1
|
||||
assert after["valuations"] == before["valuations"]
|
||||
|
||||
|
||||
def test_bank_to_loan_conversion_without_valuations_remains_allowed(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account_id = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
|
||||
|
||||
converted = service.update_ledger_account(account_id, type="loan", db_path=path)
|
||||
|
||||
assert converted["success"] is True
|
||||
assert converted["account"]["type"] == "loan"
|
||||
assert service.list_account_valuations(account_id, db_path=path)["count"] == 0
|
||||
|
||||
|
||||
def test_loan_balance_requires_representative_liability_definition(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
bank = service.create_ledger_account("은행", type="bank", db_path=path)["account"]["id"]
|
||||
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||
|
||||
missing_definition = service.register_account_valuation(
|
||||
loan,
|
||||
"2026-06-30",
|
||||
100,
|
||||
valuation_kind="balance",
|
||||
verification_status="verified",
|
||||
db_path=path,
|
||||
)
|
||||
assert missing_definition["success"] is False
|
||||
assert missing_definition["error_code"] == "validation_error"
|
||||
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||
|
||||
ordinary_balance = service.register_account_valuation(
|
||||
bank,
|
||||
"2026-06-30",
|
||||
100,
|
||||
valuation_kind="balance",
|
||||
verification_status="verified",
|
||||
db_path=path,
|
||||
)
|
||||
defined_loan_balance = service.register_account_valuation(
|
||||
loan,
|
||||
"2026-06-30",
|
||||
100,
|
||||
valuation_kind="balance",
|
||||
representative_liability_definition="principal_only",
|
||||
verification_status="verified",
|
||||
db_path=path,
|
||||
)
|
||||
assert ordinary_balance["success"] is True
|
||||
assert defined_loan_balance["success"] is True
|
||||
|
||||
|
||||
def test_correction_replays_by_evidence_and_conflicts_on_changed_payload(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
account = service.create_ledger_account("계좌", db_path=path)["account"]["id"]
|
||||
parent = service.register_account_valuation(
|
||||
account,
|
||||
"2026-06-30",
|
||||
100,
|
||||
source_ref="statement-1",
|
||||
verification_status="verified",
|
||||
db_path=path,
|
||||
)["valuation"]
|
||||
|
||||
first = service.correct_account_valuation(
|
||||
parent["id"],
|
||||
110,
|
||||
"statement correction",
|
||||
db_path=path,
|
||||
)
|
||||
replay = service.correct_account_valuation(
|
||||
parent["id"],
|
||||
110,
|
||||
"statement correction",
|
||||
db_path=path,
|
||||
)
|
||||
changed_amount = service.correct_account_valuation(
|
||||
parent["id"],
|
||||
111,
|
||||
"statement correction",
|
||||
db_path=path,
|
||||
)
|
||||
changed_reason = service.correct_account_valuation(
|
||||
parent["id"],
|
||||
110,
|
||||
"different correction",
|
||||
db_path=path,
|
||||
)
|
||||
changed_source = service.correct_account_valuation(
|
||||
parent["id"],
|
||||
110,
|
||||
"statement correction",
|
||||
source_ref="statement-2",
|
||||
db_path=path,
|
||||
)
|
||||
|
||||
assert first["success"] is True
|
||||
assert replay["success"] is True
|
||||
assert replay["reused"] is True
|
||||
assert replay["valuation"]["id"] == first["valuation"]["id"]
|
||||
for changed in (changed_amount, changed_reason, changed_source):
|
||||
assert changed["success"] is False
|
||||
assert changed["error_code"] == "correction_conflict"
|
||||
assert service.list_account_valuations(account, db_path=path)["count"] == 2
|
||||
|
||||
|
||||
def test_direct_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rejected = record_account_valuation_candidate(
|
||||
conn, loan, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
|
||||
)
|
||||
|
||||
assert rejected["success"] is False
|
||||
assert rejected["error_code"] == "validation_error"
|
||||
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||
|
||||
|
||||
def test_import_loan_candidate_requires_liability_definition_and_writes_nothing(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
assert _record_import_valuation_candidate(conn, loan, 100, "2026-06-30", "source-hash") is None
|
||||
|
||||
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||
|
||||
|
||||
def test_dated_loan_create_and_historical_update_preserve_current_cache_without_valuations(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account(
|
||||
"대출", type="loan", last_balance=900, last_synced_at="2026-06-30",
|
||||
last_balance_basis_date="2026-06-30", db_path=path,
|
||||
)
|
||||
loan = created["account"]["id"]
|
||||
assert created["account"]["last_balance"] == 900
|
||||
assert created["account"]["last_synced_at"] == "2026-06-30"
|
||||
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||
|
||||
updated = service.update_ledger_account(
|
||||
loan, last_balance=700, last_synced_at="2026-06-01",
|
||||
last_balance_basis_date="2026-06-01", db_path=path,
|
||||
)
|
||||
|
||||
assert updated["account"]["last_balance"] == 900
|
||||
assert updated["account"]["last_synced_at"] == "2026-06-30"
|
||||
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||
|
||||
|
||||
def test_loan_cash_balance_and_total_value_are_rejected_without_writes(tmp_path):
|
||||
path = str(tmp_path / "ledger.sqlite3")
|
||||
loan = service.create_ledger_account("대출", type="loan", db_path=path)["account"]["id"]
|
||||
|
||||
for valuation_kind in ("cash_balance", "total_value"):
|
||||
rejected = service.register_account_valuation(
|
||||
loan, "2026-06-30", 100, valuation_kind=valuation_kind, db_path=path,
|
||||
)
|
||||
assert rejected["success"] is False
|
||||
assert rejected["error_code"] == "validation_error"
|
||||
|
||||
assert service.list_account_valuations(loan, db_path=path)["count"] == 0
|
||||
|
||||
|
||||
def test_non_loan_candidate_still_succeeds_and_reuses_idempotently(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", "candidate", "memo",
|
||||
)
|
||||
replay = record_account_valuation_candidate(
|
||||
conn, account, "2026-06-30", 100, "KRW", "import", "source", "candidate", "memo",
|
||||
)
|
||||
|
||||
assert first["success"] is True
|
||||
assert first["reused"] is False
|
||||
assert replay["success"] is True
|
||||
assert replay["reused"] is True
|
||||
assert replay["valuation"]["id"] == first["valuation"]["id"]
|
||||
assert service.list_account_valuations(account, db_path=path)["count"] == 1
|
||||
|
||||
|
||||
def test_candidate_invalid_amount_returns_controlled_error_with_existing_row(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", "candidate", "memo",
|
||||
)
|
||||
rejected = record_account_valuation_candidate(
|
||||
conn, account, "2026-06-30", "not-an-integer", "KRW", "import", "source", "candidate", "memo",
|
||||
)
|
||||
|
||||
assert first["success"] is True
|
||||
assert rejected["success"] is False
|
||||
assert rejected["error_code"] == "validation_error"
|
||||
assert service.list_account_valuations(account, db_path=path)["count"] == 1
|
||||
@@ -107,3 +107,39 @@ def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(le
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_mcp_preserves_legacy_full_argument_contract_and_monthly_dispatch(ledger, monkeypatch):
|
||||
from jarca_ledger_mcp.adapters.mcp import server
|
||||
path, settlement_id = ledger
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(path))
|
||||
legacy = server.register_account_valuation(
|
||||
"acct-1", "2026-10-31", 150, "KRW", "balance", "manual", "statement-1",
|
||||
"verified", "legacy memo", "legacy-key", "2026-10-31T10:00:00+09:00",
|
||||
"Asia/Seoul", 100, 100, "cost_basis", "Initial cost", 50, 0.5,
|
||||
"cost_basis", None, None, None, None,
|
||||
)
|
||||
assert legacy["success"], legacy
|
||||
assert legacy["valuation"]["cost_basis"] == 100
|
||||
assert legacy["valuation"]["source_ref"] == "statement-1"
|
||||
monthly = server.register_account_valuation(
|
||||
"acct-1", "2026-10-31", 150, monthly_settlement_id=settlement_id,
|
||||
liquidity_bucket="cash",
|
||||
)
|
||||
assert monthly["success"], monthly
|
||||
assert monthly["verification_status"] == "unverified"
|
||||
valuations = server.list_account_valuations("acct-1")["valuations"]
|
||||
assert {item["id"] for item in valuations} == {
|
||||
legacy["valuation"]["id"], monthly["snapshot_id"],
|
||||
}
|
||||
assert valuations == sorted(
|
||||
valuations,
|
||||
key=lambda item: (item["basis_date"], item["recorded_at"], item["id"]),
|
||||
)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_valuations").fetchone() == (1,)
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (1,)
|
||||
advertised = server.mcp._tool_manager._tools["register_account_valuation"].parameters
|
||||
assert {"source_ref", "cost_basis", "monthly_settlement_id"} <= advertised["properties"].keys()
|
||||
assert "db_path" not in advertised["properties"]
|
||||
assert {"create_manual_asset", "list_manual_assets", "update_manual_asset", "deactivate_manual_asset"} <= server.mcp._tool_manager._tools.keys()
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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 == "15"
|
||||
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, last_balance_basis_date="2026-06-30")
|
||||
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, last_balance_basis_date="2026-06-30")
|
||||
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
|
||||
|
||||
|
||||
def test_list_account_valuations_includes_legacy_and_monthly_snapshot_rows(tmp_path):
|
||||
path = str(tmp_path / "coexisting-valuations.sqlite3")
|
||||
account_id = _account(path)
|
||||
legacy = service.register_account_valuation(
|
||||
account_id, "2026-08-31", 900, verification_status="verified",
|
||||
idempotency_key="legacy-value", db_path=path,
|
||||
)
|
||||
settlement = service.create_monthly_settlement("2026-09", db_path=path)
|
||||
canonical = service.register_account_valuation(
|
||||
account_id, "2026-09-30", 1000, monthly_settlement_id=settlement["settlement_id"],
|
||||
verification_status="unverified", db_path=path,
|
||||
)
|
||||
|
||||
listed = service.list_account_valuations(account_id, db_path=path)
|
||||
|
||||
assert legacy["success"] and canonical["success"] and listed["success"]
|
||||
assert listed["count"] == 2
|
||||
assert [item["id"] for item in listed["valuations"]] == [
|
||||
legacy["valuation"]["id"], canonical["snapshot_id"],
|
||||
]
|
||||
snapshot = listed["valuations"][1]
|
||||
assert set(listed["valuations"][0]) <= set(snapshot)
|
||||
assert snapshot["account_id"] == account_id
|
||||
assert snapshot["monthly_settlement_id"] == settlement["settlement_id"]
|
||||
assert snapshot["recorded_at"] == snapshot["created_at"]
|
||||
|
||||
|
||||
def test_list_account_valuations_globally_orders_canonical_before_newer_legacy(tmp_path):
|
||||
path = str(tmp_path / "chronological-valuations.sqlite3")
|
||||
account_id = _account(path)
|
||||
settlement = service.create_monthly_settlement("2026-09", db_path=path)
|
||||
canonical = service.register_account_valuation(
|
||||
account_id, "2026-09-30", 900,
|
||||
monthly_settlement_id=settlement["settlement_id"],
|
||||
source_note="monthly-close", db_path=path,
|
||||
)
|
||||
legacy = service.register_account_valuation(
|
||||
account_id, "2026-10-31", 1000,
|
||||
verification_status="verified", source_ref="legacy-statement",
|
||||
idempotency_key="legacy-october", db_path=path,
|
||||
)
|
||||
|
||||
listed = service.list_account_valuations(account_id, db_path=path)
|
||||
|
||||
assert listed["success"] and listed["count"] == 2
|
||||
assert [item["id"] for item in listed["valuations"]] == [
|
||||
canonical["snapshot_id"], legacy["valuation"]["id"],
|
||||
]
|
||||
canonical_row, legacy_row = listed["valuations"]
|
||||
assert canonical_row["monthly_settlement_id"] == settlement["settlement_id"]
|
||||
assert canonical_row["source_note"] == "monthly-close"
|
||||
assert canonical_row["recorded_at"] == canonical_row["created_at"]
|
||||
assert legacy_row["source_ref"] == "legacy-statement"
|
||||
assert legacy_row["idempotency_key"] == "legacy-october"
|
||||
@@ -22,7 +22,7 @@ def _tables(path):
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
# Sourced from schema.py plus migration-created settlement draft tables.
|
||||
"schema_meta",
|
||||
"schema_meta", "manual_assets", "asset_snapshots", "asset_snapshot_runs", "account_valuations",
|
||||
"accounts",
|
||||
"transactions",
|
||||
"budgets",
|
||||
@@ -40,11 +40,11 @@ EXPECTED_TABLES = {
|
||||
}
|
||||
|
||||
|
||||
def test_fresh_init_has_no_account_valuations_and_exact_schema_tables(tmp_path):
|
||||
def test_fresh_init_preserves_account_valuations_and_exact_schema_tables(tmp_path):
|
||||
path = tmp_path / "fresh.sqlite3"
|
||||
init_db(str(path))
|
||||
tables = _tables(path)
|
||||
assert "account_valuations" not in tables
|
||||
assert "account_valuations" in tables
|
||||
assert tables == EXPECTED_TABLES
|
||||
|
||||
|
||||
@@ -55,26 +55,25 @@ def test_init_is_idempotent(tmp_path):
|
||||
assert _tables(path) == EXPECTED_TABLES
|
||||
|
||||
|
||||
def test_legacy_nonempty_account_valuations_row_is_dropped_only(tmp_path):
|
||||
def test_legacy_nonempty_account_valuations_row_is_preserved(tmp_path):
|
||||
path = tmp_path / "legacy.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("create table account_valuations (id text primary key, account_id text, amount integer)")
|
||||
conn.execute("insert into account_valuations values ('legacy-1','acct-1',123)")
|
||||
conn.execute("insert into account_valuations (id,account_id,amount,basis_date,recorded_at) values ('legacy-1','acct-1',123,'2026-09-30','t')")
|
||||
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Keep me','bank',1,'t','t')")
|
||||
conn.commit()
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("select count(*) from accounts where id='acct-1'").fetchone()[0] == 1
|
||||
assert conn.execute("select name from sqlite_master where type='table' and name='account_valuations'").fetchone() is None
|
||||
assert conn.execute("select amount from account_valuations where id='legacy-1'").fetchone() == (123,)
|
||||
|
||||
|
||||
def test_unsupported_valuation_operations_do_not_create_or_access_db(tmp_path):
|
||||
def test_invalid_valuation_operations_do_not_create_db(tmp_path):
|
||||
path = tmp_path / "must-not-exist.sqlite3"
|
||||
result = register_account_valuation("acct", "2026-09-30", 1, db_path=str(path))
|
||||
result = register_account_valuation("acct", "invalid-date", 1, db_path=str(path))
|
||||
listed = list_account_valuations("acct", db_path=str(path))
|
||||
assert result["success"] is False and result["error_code"] == "validation_error"
|
||||
assert listed["success"] is False and listed["error_code"] == "db_error"
|
||||
assert listed["success"] is False and listed["error_code"] == "db_not_initialized"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@@ -102,10 +101,11 @@ def test_active_account_draft_returns_review_blocker_without_sql_error(tmp_path)
|
||||
assert not any("sql" in str(v).lower() for v in result.get("review", {}).values())
|
||||
|
||||
|
||||
def test_manual_asset_flow_is_unsupported_after_removal(tmp_path):
|
||||
def test_manual_asset_flow_remains_supported(tmp_path):
|
||||
path = tmp_path / "manual.sqlite3"
|
||||
init_db(str(path))
|
||||
created = create_manual_asset("Emergency fund", 100000, "2026-09-30", "cash", db_path=str(path))
|
||||
listed = list_manual_assets(db_path=str(path))
|
||||
assert created == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
|
||||
assert listed == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
|
||||
assert created["success"] and listed["success"]
|
||||
assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"]
|
||||
assert listed["manual_assets"][0]["valuation_amount"] == 100000
|
||||
|
||||
@@ -48,13 +48,13 @@ def test_replay_preserves_redacted_unverified_candidates_after_correction(tmp_pa
|
||||
assert corrected["success"] is True
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
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 account_snapshot").fetchone()[0]
|
||||
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
|
||||
@@ -109,7 +109,7 @@ def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_p
|
||||
"UPDATE asset_snapshot_runs SET excluded_evidence=? WHERE id=?",
|
||||
("{malformed", first["snapshot_run_id"]),
|
||||
)
|
||||
before_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
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)
|
||||
@@ -124,7 +124,7 @@ def test_malformed_persisted_evidence_fails_closed_without_snapshot_writes(tmp_p
|
||||
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 account_snapshot").fetchone()[0] == before_rows
|
||||
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
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(t
|
||||
assert corrected["snapshot_run_id"] not in first["inserted_snapshot_ids"]
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
before_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
|
||||
replay = service.close_asset_snapshot(
|
||||
"2026-06-30",
|
||||
@@ -186,7 +186,7 @@ def test_exact_run_replay_after_correction_reconstructs_original_persisted_run(t
|
||||
)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
|
||||
assert replay == first
|
||||
assert replay["snapshot_run_id"] == first["snapshot_run_id"]
|
||||
|
||||
@@ -13,47 +13,46 @@ def tables(path):
|
||||
return {r[0] for r in c.execute("select name from sqlite_master where type='table'")}
|
||||
|
||||
|
||||
def test_fresh_init_has_no_asset_snapshot_runs(tmp_path):
|
||||
def test_fresh_init_retains_asset_snapshot_runs(tmp_path):
|
||||
path = tmp_path / "fresh.sqlite3"
|
||||
init_db(str(path))
|
||||
assert "asset_snapshot_runs" not in tables(path)
|
||||
assert "asset_snapshot_runs" in tables(path)
|
||||
|
||||
|
||||
def test_init_twice_is_idempotent_and_schema16(tmp_path):
|
||||
def test_init_twice_is_idempotent_and_schema15(tmp_path):
|
||||
path = tmp_path / "repeat.sqlite3"
|
||||
init_db(str(path)); init_db(str(path))
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute("select value from schema_meta where key='schema_version'").fetchone()[0] == "15"
|
||||
assert "asset_snapshot_runs" not in tables(path)
|
||||
assert "asset_snapshot_runs" in tables(path)
|
||||
|
||||
|
||||
def test_legacy_child_rows_require_deterministic_parent_mapping(tmp_path):
|
||||
path = tmp_path / "legacy.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("create table asset_snapshot_runs (id text primary key, basis_date text not null)")
|
||||
c.execute("insert into asset_snapshot_runs values ('run-1','2026-09-30')")
|
||||
c.execute("insert into asset_snapshot_runs (id,basis_date,close_status,created_at) values ('run-1','2026-09-30','complete_exact','t')")
|
||||
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values ('month-1','2026-09','2026-09-30','draft',1,'test','now','test','now')")
|
||||
c.execute("insert into settlement_cashflow_snapshots values ('cf-1','month-1','income','','',10,1,'KRW')")
|
||||
c.execute("insert into settlement_cashflow_snapshots (id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) values ('cf-1','month-1','income','','',10,1,'KRW')")
|
||||
c.commit()
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute("select monthly_settlement_id, amount from settlement_cashflow_snapshots").fetchone() == ('month-1', 10)
|
||||
assert "asset_snapshot_runs" not in tables(path)
|
||||
assert "asset_snapshot_runs" in tables(path)
|
||||
assert "monthly_settlement_id" in (c.execute("select sql from sqlite_master where name='settlement_cashflow_snapshots'").fetchone()[0] or "")
|
||||
|
||||
|
||||
def test_unsupported_legacy_operations_do_not_touch_missing_db(tmp_path):
|
||||
path = tmp_path / "missing.sqlite3"
|
||||
assert register_account_valuation("a", "2026-09-30", 1, db_path=str(path))["error_code"] == "validation_error"
|
||||
assert list_account_valuations("a", db_path=str(path))["error_code"] == "db_error"
|
||||
assert register_account_valuation("a", "invalid-date", 1, db_path=str(path))["error_code"] == "validation_error"
|
||||
assert list_account_valuations("a", db_path=str(path))["error_code"] == "db_not_initialized"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_finalize_and_close_remain_unsupported_without_db_access(tmp_path):
|
||||
def test_invalid_close_and_read_do_not_create_db(tmp_path):
|
||||
path = tmp_path / "missing.sqlite3"
|
||||
assert close_asset_snapshot("2026-09-30", db_path=str(path))["error_code"] == "unsupported"
|
||||
assert get_asset_snapshot_report("2026-09-30", db_path=str(path))["error_code"] == "unsupported"
|
||||
assert close_asset_snapshot("invalid-date", db_path=str(path))["error_code"] == "validation_error"
|
||||
assert get_asset_snapshot_report("2026-09-30", db_path=str(path))["error_code"] == "db_not_initialized"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ def test_asset_snapshot_schema_is_additive_and_idempotent(tmp_path):
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
manual_cols = {row[1] for row in conn.execute("PRAGMA table_info(manual_assets)")}
|
||||
snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")}
|
||||
snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshots)")}
|
||||
|
||||
assert {"id", "name", "valuation_amount", "valuation_date", "liquidity_bucket", "source_note", "memo", "active"} <= manual_cols
|
||||
assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "monthly_settlement_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols
|
||||
assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "snapshot_run_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols
|
||||
|
||||
|
||||
def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path):
|
||||
@@ -105,9 +105,9 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
|
||||
assert deactivated["success"] is True
|
||||
assert deactivated["manual_asset"]["active"] is False
|
||||
|
||||
before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 0)
|
||||
before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0)
|
||||
preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path)
|
||||
after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 0)
|
||||
after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0)
|
||||
assert preview["success"] is True
|
||||
assert preview["source"] == "live_authority"
|
||||
assert preview["is_closed_snapshot"] is False
|
||||
@@ -146,5 +146,5 @@ def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_
|
||||
assert corrected_report["close_metadata"]["snapshot_run_id"] == corrected["snapshot_run_id"]
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot WHERE basis_date='2026-06-30'").fetchone()[0]
|
||||
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date='2026-06-30'").fetchone()[0]
|
||||
assert row_count == 6
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi
|
||||
review = review_result
|
||||
ok(api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "synthetic-reviewer", "approved", "ref", db_path=path))
|
||||
with sqlite3.connect(path) as db:
|
||||
target = {"header":"asset_snapshot_runs", "assets":"account_snapshot", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
|
||||
target = {"header":"asset_snapshot_runs", "assets":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
|
||||
# Missing future table is an explicit RED assertion, not a SQL error masquerading as rollback.
|
||||
assert db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (target,)).fetchone(), f"required comprehensive table missing: {target}"
|
||||
event = "UPDATE" if failure_point == "completion" else "INSERT"
|
||||
@@ -160,7 +160,7 @@ def test_mid_close_failure_rolls_back_all_settlement_state(tmp_path, failure_poi
|
||||
assert failed.get("success") is False
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
|
||||
|
||||
@@ -176,7 +176,7 @@ def test_close_rejects_stored_detail_sum_tampering(tmp_path):
|
||||
result = close_draft(path, draft)
|
||||
assert result.get("success") is False, result
|
||||
with sqlite3.connect(path) as db:
|
||||
for table in ("asset_snapshot_runs", "account_snapshot", "settlement_cashflow_snapshots"):
|
||||
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
assert db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
|
||||
assert db.execute("SELECT status FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone()[0] == "active"
|
||||
|
||||
@@ -324,6 +324,25 @@ def test_legacy_reader_preserves_unrecorded_currency_and_marks_legacy(tmp_path):
|
||||
assert all(value is None for value in result["totals"].values())
|
||||
|
||||
|
||||
def test_month_lookup_ignores_later_direct_asset_correction(tmp_path):
|
||||
path = str(tmp_path / "reader-interleaved-runs.sqlite3")
|
||||
_seed_reader_run(path, True, {"transactions": [], "assets": []})
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshot_runs
|
||||
(id,basis_date,close_status,correction_of_run_id,correction_reason,created_at)
|
||||
VALUES ('direct-correction','2026-09-30','conditional','synthetic-run','asset correction','2026-10-01')"""
|
||||
)
|
||||
|
||||
monthly = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id="direct-correction", db_path=path))
|
||||
|
||||
assert monthly["run_id"] == "synthetic-run"
|
||||
assert monthly["is_comprehensive"] is True
|
||||
assert exact["run_id"] == "direct-correction"
|
||||
assert exact["legacy"] is True
|
||||
|
||||
|
||||
def test_legacy_reader_accepts_original_header_without_migration(tmp_path):
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import get_monthly_settlement_snapshot
|
||||
path = str(tmp_path / "reader-old-columns.sqlite3")
|
||||
@@ -368,7 +387,7 @@ def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
|
||||
ok(service.approve_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
if target.startswith("asset_"):
|
||||
column, expression = ("amount", "amount+1") if target == "asset_amount" else ("currency", "'USD'")
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON account_snapshot BEGIN UPDATE account_snapshot SET {column}={expression} WHERE id=NEW.id; END"
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
|
||||
else:
|
||||
column, expression = {"header_total": ("income_total", "income_total+1"), "header_source": ("source_snapshot_json", "'{}'"), "header_policy": ("cashflow_policy_json", "'{\"savings_categories\":[{\"category\":\"Other\",\"subcategory\":\"Pair\"}],\"category_decisions\":[]}'"), "header_version": ("aggregation_version", "'unknown-version'")}[target]
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON settlement_cashflow_snapshots BEGIN UPDATE asset_snapshot_runs SET {column}={expression} WHERE id=NEW.settlement_run_id; END"
|
||||
@@ -377,7 +396,7 @@ def test_close_rejects_persisted_header_and_asset_tampering(tmp_path, target):
|
||||
result = service.finalize_settlement_draft(draft["id"], draft["version"], review["review_fingerprint"], "tamper-close", db_path=path)
|
||||
assert result["success"] is False, "persisted header/detail must match the approved result before commit"
|
||||
with sqlite3.connect(path) as conn:
|
||||
for table in ("asset_snapshot_runs", "account_snapshot", "settlement_cashflow_snapshots"):
|
||||
for table in ("asset_snapshot_runs", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT status,final_run_id,final_response FROM settlement_drafts WHERE id=?", (draft["id"],)).fetchone() == ("active", "", "")
|
||||
|
||||
@@ -672,12 +691,7 @@ def test_schema_eleven_legacy_reader_returns_empty_evidence_without_writes(tmp_p
|
||||
from test_legacy_snapshot_migration import _legacy_closed_db
|
||||
path, _ = _legacy_closed_db(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
header = conn.execute(
|
||||
"SELECT id, close_status, correction_of_run_id, correction_reason FROM asset_snapshot_runs"
|
||||
).fetchone()
|
||||
assert tuple(header) == (
|
||||
"legacy-run-correction", "legacy_read_only", "legacy-initial", "legacy correction"
|
||||
)
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id="legacy-run-correction", db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-06", db_path=path))
|
||||
|
||||
@@ -82,6 +82,9 @@ def test_dashboard_exposes_additive_policy_and_saved_history_without_implicit_mu
|
||||
source = drafts.read_text(encoding="utf-8")
|
||||
monthly_source = monthly.read_text(encoding="utf-8")
|
||||
combined = source + "\n" + monthly_source
|
||||
legacy_source = (Path(__file__).parents[1] / "dashboard/page_views/legacy_monthly_settlement.py").read_text()
|
||||
combined += legacy_source
|
||||
monthly_source += legacy_source
|
||||
assert "종합 월정산 정책" in combined
|
||||
assert "저장된 월정산 스냅샷" in combined
|
||||
assert POLICY in source or POLICY in monthly_source
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX legacy_transactions_date ON transactions(transaction_date);
|
||||
CREATE TABLE account_snapshot (
|
||||
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',
|
||||
@@ -112,17 +112,17 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
|
||||
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 account_snapshot(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'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 account_snapshot WHERE id='s'").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] == "16"
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
|
||||
|
||||
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
|
||||
|
||||
@@ -16,22 +16,10 @@ def _legacy_closed_db(tmp_path):
|
||||
init_db(path)
|
||||
asset = service.create_manual_asset("legacy cash", 100, "2026-06-01", "cash", db_path=path)["manual_asset"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
# Rebuild the historical schema-11 table independently; do not insert
|
||||
# removed columns into the current schema-16 table.
|
||||
conn.execute("DROP TABLE account_snapshot")
|
||||
conn.execute("""CREATE TABLE account_snapshot (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL, 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 '',
|
||||
snapshot_run_id TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '',
|
||||
correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0,
|
||||
closed_at TEXT NOT NULL DEFAULT '', valuation_id TEXT NOT NULL DEFAULT '',
|
||||
selection_policy TEXT NOT NULL DEFAULT 'exact', verification_status TEXT NOT NULL DEFAULT 'unverified',
|
||||
evidence_kind TEXT NOT NULL DEFAULT 'applied', exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
)""")
|
||||
# These are schema-11 close rows after the additive schema-12 migration:
|
||||
# run identity is absent, but row close/correction fields remain frozen.
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
"""INSERT INTO asset_snapshots
|
||||
(id, basis_date, source_type, source_id, name, amount, liquidity_bucket,
|
||||
valuation_source, snapshot_run_id, correction_of_run_id, correction_reason,
|
||||
is_correction, closed_at, created_at)
|
||||
@@ -44,7 +32,7 @@ def _legacy_closed_db(tmp_path):
|
||||
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
return path, asset["id"]
|
||||
|
||||
|
||||
@@ -114,18 +102,6 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
|
||||
path = str(tmp_path / "legacy-corrections.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("DROP TABLE account_snapshot")
|
||||
conn.execute("""CREATE TABLE account_snapshot (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL, 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 '',
|
||||
snapshot_run_id TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '',
|
||||
correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0,
|
||||
closed_at TEXT NOT NULL DEFAULT '', valuation_id TEXT NOT NULL DEFAULT '',
|
||||
selection_policy TEXT NOT NULL DEFAULT 'exact', verification_status TEXT NOT NULL DEFAULT 'unverified',
|
||||
evidence_kind TEXT NOT NULL DEFAULT 'applied', exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
)""")
|
||||
rows = [
|
||||
("old-a", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 40),
|
||||
("old-b", "legacy-run-1", "", "", 0, "2026-06-30T10:00:00+09:00", 60),
|
||||
@@ -134,7 +110,7 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
|
||||
]
|
||||
for row_id, run_id, prior, reason, correction, closed_at, amount in rows:
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
"""INSERT INTO asset_snapshots
|
||||
(id, basis_date, source_type, source_id, name, amount, currency,
|
||||
liquidity_bucket, valuation_source, snapshot_run_id,
|
||||
correction_of_run_id, correction_reason, is_correction,
|
||||
@@ -148,7 +124,7 @@ def test_schema11_latest_legacy_correction_run_is_selected_as_a_complete_group(t
|
||||
conn.execute("UPDATE schema_meta SET value='11' WHERE key='schema_version'")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
|
||||
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
report = service.get_asset_snapshot_report(BASIS, db_path=path)
|
||||
|
||||
@@ -14,33 +14,34 @@ def tables(path):
|
||||
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
|
||||
|
||||
|
||||
def test_fresh_and_repeated_init_never_create_manual_assets(tmp_path):
|
||||
def test_fresh_and_repeated_init_retain_manual_assets(tmp_path):
|
||||
path = str(tmp_path / "fresh.sqlite3")
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
assert "manual_assets" not in tables(path)
|
||||
assert "manual_assets" in tables(path)
|
||||
|
||||
|
||||
def test_existing_legacy_manual_assets_is_dropped_without_other_row_loss(tmp_path):
|
||||
def test_existing_legacy_manual_assets_is_preserved_without_other_row_loss(tmp_path):
|
||||
path = str(tmp_path / "legacy.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("create table manual_assets (id text primary key, name text)")
|
||||
conn.execute("insert into manual_assets values ('old', 'legacy')")
|
||||
conn.execute("insert into manual_assets (id,name,created_at,updated_at) values ('old', 'legacy','t','t')")
|
||||
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('a','Keep','bank',1,'t','t')")
|
||||
conn.commit()
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("select count(*) from accounts where id='a'").fetchone()[0] == 1
|
||||
assert "manual_assets" not in tables(path)
|
||||
assert conn.execute("select name from manual_assets where id='old'").fetchone() == ("legacy",)
|
||||
assert "manual_assets" in tables(path)
|
||||
|
||||
|
||||
def test_manual_asset_public_operations_are_unsupported(tmp_path):
|
||||
path = str(tmp_path / "unsupported.sqlite3")
|
||||
def test_manual_asset_public_operations_are_supported(tmp_path):
|
||||
path = str(tmp_path / "supported.sqlite3")
|
||||
init_db(path)
|
||||
expected = {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
|
||||
assert create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path) == expected
|
||||
assert list_manual_assets(db_path=path) == expected
|
||||
created = create_manual_asset("Emergency", 100, "2026-09-30", "cash", db_path=path)
|
||||
listed = list_manual_assets(db_path=path)
|
||||
assert created["success"] and listed["success"]
|
||||
assert listed["manual_assets"][0]["id"] == created["manual_asset"]["id"]
|
||||
|
||||
|
||||
def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path):
|
||||
@@ -55,7 +56,7 @@ def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_p
|
||||
assert preview_asset_snapshot_close("2026-09-30", db_path=path)["success"] is True
|
||||
|
||||
|
||||
def test_manual_assets_absent_after_initialization_with_existing_accounts(tmp_path):
|
||||
def test_manual_assets_present_after_initialization_with_existing_accounts(tmp_path):
|
||||
path = str(tmp_path / "accounts.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
@@ -63,4 +64,4 @@ def test_manual_assets_absent_after_initialization_with_existing_accounts(tmp_pa
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("select count(*) from accounts").fetchone()[0] == before
|
||||
assert "manual_assets" not in tables(path)
|
||||
assert "manual_assets" in tables(path)
|
||||
|
||||
@@ -43,16 +43,22 @@ def test_migration_is_idempotent_and_preserves_legacy_drafts(tmp_path):
|
||||
assert conn.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_schema15_upgrade_drops_only_latest_run_id_and_preserves_closed_row(tmp_path):
|
||||
def test_schema15_upgrade_removes_schema16_run_link_and_preserves_rows(tmp_path):
|
||||
path = str(tmp_path / "schema15.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-1','2026-09-30','complete_exact','t')")
|
||||
conn.execute("ALTER TABLE monthly_settlement ADD COLUMN latest_run_id TEXT REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT")
|
||||
conn.execute("UPDATE schema_meta SET value='15' WHERE key='schema_version'")
|
||||
conn.execute("""INSERT INTO monthly_settlement
|
||||
(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at,
|
||||
approved_by,approved_at,approved_version,approved_fingerprint,closed_by,closed_at,latest_run_id)
|
||||
VALUES ('m1','2026-09','2026-09-30','closed',2,'u','t','u','t','r','t',1,'fp','c','t','run-1')""")
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
(id,basis_date,source_type,source_id,name,amount,monthly_settlement_id,created_at)
|
||||
VALUES ('snapshot-1','2026-09-30','account','account-1','Main',100,'m1','t')"""
|
||||
)
|
||||
before = conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall()
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
@@ -60,7 +66,15 @@ def test_schema15_upgrade_drops_only_latest_run_id_and_preserves_closed_row(tmp_
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
assert conn.execute("SELECT id,month,basis_date,status,version,memo,approved_by,closed_by FROM monthly_settlement").fetchall() == before
|
||||
assert "latest_run_id" not in {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
|
||||
assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == []
|
||||
assert conn.execute("SELECT id FROM asset_snapshot_runs").fetchall() == [("run-1",)]
|
||||
assert conn.execute("SELECT id,monthly_settlement_id FROM account_snapshot").fetchall() == [("snapshot-1", "m1")]
|
||||
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
migrated_shape = [row[1:] for row in conn.execute("PRAGMA table_info(monthly_settlement)")]
|
||||
canonical_path = str(tmp_path / "canonical.sqlite3")
|
||||
init_db(canonical_path)
|
||||
with sqlite3.connect(canonical_path) as conn:
|
||||
canonical_shape = [row[1:] for row in conn.execute("PRAGMA table_info(monthly_settlement)")]
|
||||
assert migrated_shape == canonical_shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)])
|
||||
|
||||
@@ -102,3 +102,40 @@ def test_empty_close_is_saved_without_fake_marker(tmp_path):
|
||||
def test_double_close_is_idempotent(tmp_path):
|
||||
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path); first = close_monthly_settlement(sid, 1, db_path=str(path)); second = close_monthly_settlement(sid, 999, db_path=str(path))
|
||||
assert first["success"] and second["success"] and second["idempotent"] is True
|
||||
|
||||
|
||||
def test_legacy_finalization_and_correction_preserve_closed_canonical_month(tmp_path):
|
||||
from jarca_ledger_mcp import service
|
||||
path = _db(tmp_path, [_row("t1")])
|
||||
sid, _, _ = _review_approve(path)
|
||||
assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
|
||||
canonical = get_settlement_cashflow_snapshot(sid, db_path=str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
parent = conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
|
||||
asset = service.create_manual_asset("Legacy cash", 500, "2026-10-31", "cash", db_path=str(path))
|
||||
assert asset["success"]
|
||||
previous = ""
|
||||
for number in range(2):
|
||||
draft = service.create_settlement_draft(
|
||||
"2026-10", "tester", f"legacy-draft-{number}", db_path=str(path),
|
||||
correction_of_run_id=previous, correction_reason="correct" if previous else "",
|
||||
)
|
||||
assert draft["success"], draft
|
||||
did = draft["draft"]["id"]
|
||||
review = service.review_settlement_draft(did, db_path=str(path))
|
||||
assert review["success"]
|
||||
approved = service.approve_settlement_draft(
|
||||
did, review["version"], review["review_fingerprint"], "tester", "reviewed", "fixture", db_path=str(path),
|
||||
)
|
||||
assert approved["success"], approved
|
||||
closed = service.finalize_settlement_draft(
|
||||
did, review["version"], review["review_fingerprint"], f"legacy-close-{number}", db_path=str(path),
|
||||
)
|
||||
assert closed["success"], closed
|
||||
previous = closed["snapshot_run_id"]
|
||||
assert get_settlement_cashflow_snapshot(sid, db_path=str(path)) == canonical
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT * FROM monthly_settlement WHERE id=?", (sid,)).fetchone() == parent
|
||||
assert conn.execute("SELECT COUNT(DISTINCT settlement_run_id) FROM settlement_cashflow_snapshots").fetchone() == (2,)
|
||||
assert conn.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id=?", (sid,)).fetchone() == (1,)
|
||||
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_same_version_existing_db_upgrade_is_idempotent_and_preserves_rows(tmp_p
|
||||
assert conn.execute("SELECT name, sql FROM sqlite_master WHERE type IN ('table', 'index') AND name NOT LIKE 'monthly_settlement%' AND name <> 'sqlite_sequence' ORDER BY name").fetchall() == before_tables
|
||||
|
||||
|
||||
def test_schema16_db_missing_field_gets_only_targeted_additive_column(tmp_path):
|
||||
def test_schema15_db_missing_field_gets_only_targeted_additive_column(tmp_path):
|
||||
path = str(tmp_path / "legacy16.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
@@ -69,7 +69,7 @@ def test_schema16_db_missing_field_gets_only_targeted_additive_column(tmp_path):
|
||||
)
|
||||
conn.execute("INSERT INTO monthly_settlement SELECT id,month,basis_date,status,version,memo,created_by,created_at,updated_by,updated_at,reviewed_by,reviewed_at,review_fingerprint,approved_by,approved_at,approved_version,approved_fingerprint,approval_reason,approval_flags_json,closed_by,closed_at,cancelled_by,cancelled_at,cancellation_reason FROM monthly_settlement_without_fingerprint")
|
||||
conn.execute("DROP TABLE monthly_settlement_without_fingerprint")
|
||||
conn.execute("INSERT INTO schema_meta(key,value) VALUES('schema_version','16') ON CONFLICT(key) DO UPDATE SET value='16'")
|
||||
conn.execute("INSERT INTO schema_meta(key,value) VALUES('schema_version','15') ON CONFLICT(key) DO UPDATE SET value='15'")
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) "
|
||||
"VALUES ('m1','2026-10','2026-10-31','draft',1,'u','t','u','t')"
|
||||
|
||||
@@ -34,14 +34,10 @@ def api(name):
|
||||
def dbfile(tmp_path):
|
||||
path = str(tmp_path / "synthetic-ledger.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ("monthly-2026-09", "2026-09", "2026-09-30", "draft", 1, "tester", "now", "tester", "now"))
|
||||
return path
|
||||
|
||||
|
||||
def create(path, month="2026-09", key="create-1", **kwargs):
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", ("monthly-" + month, month, month + "-30", "draft", 1, "tester", "now", "tester", "now"))
|
||||
return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs)
|
||||
|
||||
|
||||
@@ -64,8 +60,8 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
|
||||
assert result["items"] == []
|
||||
assert isinstance(result["review"], dict)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert fetched["draft"]["id"] == draft["id"]
|
||||
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
|
||||
@@ -132,32 +128,64 @@ def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_p
|
||||
assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert before["draft"]["version"] == draft["version"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
if snapshot is not None:
|
||||
assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot
|
||||
|
||||
|
||||
def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
acct = service.create_ledger_account("Exact account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
result = service.register_account_valuation(acct, "2026-10-05", 700, currency="KRW", monthly_settlement_id="missing", verification_status="pending", idempotency_key="future", db_path=path)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "not_found"
|
||||
a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path)
|
||||
late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path)
|
||||
assert late.get("success")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"]))
|
||||
d = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
|
||||
rows = review["items"]
|
||||
auto_future = [x for x in rows if x.get("source_id") == a]
|
||||
assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future)
|
||||
item = next(x for x in rows if x.get("source_id") == b)
|
||||
assert item.get("valuation_id") == late["valuation"]["id"]
|
||||
assert item.get("selection_mode", "exact") == "exact"
|
||||
assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True
|
||||
revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
|
||||
selected = next(x for x in revised["items"] if x.get("source_id") == a)
|
||||
assert selected.get("valuation_date") == "2026-10-05"
|
||||
assert selected.get("date_difference_days") == 5
|
||||
assert selected.get("selection_mode") == "manual_substitute"
|
||||
|
||||
|
||||
def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
result = service.register_account_valuation(acct, "2026-09-30", 900, db_path=path)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "validation_error"
|
||||
assert "currency" in result["error"]
|
||||
unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path)
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
row = next(x for x in review["items"] if x.get("source_id") == acct)
|
||||
assert row.get("amount") is None
|
||||
assert row.get("candidate_amount") == 900
|
||||
child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path)
|
||||
assert child.get("success")
|
||||
changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert any(x.get("source_id") == acct for x in changed["missing_items"])
|
||||
assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True
|
||||
|
||||
|
||||
def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
result = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)
|
||||
assert result == {"success": False, "error_code": "unsupported", "error": "manual assets are no longer supported"}
|
||||
manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"]
|
||||
future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"]
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"])
|
||||
service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path)
|
||||
drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"])
|
||||
refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path)
|
||||
assert refreshed.get("success") is True
|
||||
|
||||
|
||||
def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path):
|
||||
@@ -170,10 +198,10 @@ def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_pat
|
||||
assert mutated.get("success") is True
|
||||
stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path)
|
||||
assert stale.get("success") is False
|
||||
assert stale.get("error_code") == "unsupported"
|
||||
assert stale.get("error_code") in {"conflict", "validation_error"}
|
||||
|
||||
|
||||
def test_finalize_is_explicitly_unsupported_after_canonical_close_migration(tmp_path):
|
||||
def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=path))
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
@@ -181,7 +209,23 @@ def test_finalize_is_explicitly_unsupported_after_canonical_close_migration(tmp_
|
||||
approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, db_path=path)
|
||||
assert approval.get("success")
|
||||
final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
|
||||
assert final == {"success": False, "error_code": "unsupported", "error": "run history unavailable"}
|
||||
assert final.get("success")
|
||||
replay = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
|
||||
assert replay == final
|
||||
conflict = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
|
||||
assert conflict == final
|
||||
with sqlite3.connect(path) as conn:
|
||||
run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
assert run_count == 1 and row_count >= 1
|
||||
assert final["draft_id"] == draft["id"]
|
||||
assert final["draft_version"] == draft["version"]
|
||||
assert final["review_fingerprint"] == review["review_fingerprint"]
|
||||
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
|
||||
series = service.get_net_worth_timeseries(start_date="2026-09-01", end_date="2026-09-30", db_path=path)
|
||||
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
|
||||
assert report["success"] and series["success"] and monthly["success"]
|
||||
assert report["close_metadata"].get("draft_id") == draft["id"]
|
||||
|
||||
|
||||
def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
|
||||
@@ -192,23 +236,27 @@ def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
|
||||
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path)
|
||||
assert approved.get("success")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
|
||||
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
|
||||
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
|
||||
assert result.get("success") is False
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert after["draft"]["status"] == "active"
|
||||
|
||||
|
||||
def test_schema13_migration_is_additive_and_idempotent(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')")
|
||||
conn.commit()
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
|
||||
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
|
||||
assert conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is None
|
||||
|
||||
|
||||
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
|
||||
@@ -220,7 +268,7 @@ def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
|
||||
results = list(pool.map(attempt, range(4)))
|
||||
successes = [r for r in results if r.get("success")]
|
||||
assert len(successes) == 1
|
||||
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error", "db_error"} for r in results)
|
||||
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results)
|
||||
d = draft_of(successes[0])
|
||||
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
|
||||
approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import (
|
||||
@@ -7,17 +8,56 @@ from jarca_ledger_mcp.application.monthly_settlement_service import (
|
||||
)
|
||||
|
||||
|
||||
def test_monthly_history_is_explicitly_unsupported_in_schema15(tmp_path: Path):
|
||||
def _seed_history(path: str) -> None:
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshot_runs
|
||||
(id,basis_date,close_status,draft_version,approved_at,correction_of_run_id,
|
||||
correction_reason,created_at,settlement_month,income_total,expense_total,
|
||||
net_cashflow,net_worth)
|
||||
VALUES ('run-1','2026-09-30','complete_exact',3,'2026-10-01','','',
|
||||
'2026-10-01','2026-09',100,40,60,1000)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshot_runs
|
||||
(id,basis_date,close_status,draft_version,approved_at,correction_of_run_id,
|
||||
correction_reason,created_at,settlement_month,income_total,expense_total,
|
||||
net_cashflow,net_worth)
|
||||
VALUES ('run-2','2026-09-30','conditional',4,'2026-10-02','run-1','fix',
|
||||
'2026-10-02','2026-09',100,45,55,995)"""
|
||||
)
|
||||
|
||||
|
||||
def test_monthly_history_lists_close_and_correction_records(tmp_path: Path):
|
||||
path = str(tmp_path / "history.sqlite3")
|
||||
init_db(path)
|
||||
result = list_monthly_settlement_history(db_path=path)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "unsupported"
|
||||
_seed_history(path)
|
||||
|
||||
result = list_monthly_settlement_history("2026-09", db_path=path)
|
||||
|
||||
assert result["success"] is True
|
||||
assert [row["run_id"] for row in result["history"]] == ["run-2", "run-1"]
|
||||
assert result["history"][0]["correction_target"] == "run-1"
|
||||
assert result["read_only"] is True
|
||||
|
||||
|
||||
def test_monthly_history_record_read_is_explicitly_unsupported(tmp_path: Path):
|
||||
def test_monthly_history_record_reads_exact_run(tmp_path: Path):
|
||||
path = str(tmp_path / "history-read.sqlite3")
|
||||
init_db(path)
|
||||
_seed_history(path)
|
||||
|
||||
result = get_monthly_settlement_history_record("run-1", db_path=path)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["history"]["run_id"] == "run-1"
|
||||
assert result["history"]["net_cashflow"] == 60
|
||||
assert result["history"]["net_worth"] == 1000
|
||||
|
||||
|
||||
def test_monthly_history_record_reports_missing_run(tmp_path: Path):
|
||||
path = str(tmp_path / "history-missing.sqlite3")
|
||||
init_db(path)
|
||||
|
||||
result = get_monthly_settlement_history_record("missing", db_path=path)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "unsupported"
|
||||
assert result["error_code"] == "not_found"
|
||||
|
||||
@@ -69,6 +69,10 @@ class _FakeStreamlit:
|
||||
def popover(self, *args, **kwargs):
|
||||
return self._Column()
|
||||
|
||||
def link_button(self, label, url, **kwargs):
|
||||
self.buttons.append((label, {"url": url, **kwargs}))
|
||||
return False
|
||||
|
||||
def rerun(self):
|
||||
return None
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from dashboard.page_views import settlement_drafts as view
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.adapters.mcp import server, tool_contracts
|
||||
|
||||
|
||||
HISTORY_TOOLS = (
|
||||
"list_monthly_settlement_history",
|
||||
"get_monthly_settlement_history_record",
|
||||
)
|
||||
|
||||
|
||||
class _FakeStreamlit:
|
||||
def __init__(self) -> None:
|
||||
self.session_state: dict[str, object] = {}
|
||||
self.selectbox_calls: list[tuple[str, list[str], dict]] = []
|
||||
self.captions: list[str] = []
|
||||
self.json_values: list[object] = []
|
||||
self.dataframes: list[object] = []
|
||||
self.info_values: list[str] = []
|
||||
self.error_values: list[str] = []
|
||||
|
||||
def selectbox(self, label, options, **kwargs):
|
||||
values = list(options)
|
||||
self.selectbox_calls.append((label, values, kwargs))
|
||||
return values[-1]
|
||||
|
||||
def caption(self, value):
|
||||
self.captions.append(str(value))
|
||||
|
||||
def json(self, value):
|
||||
self.json_values.append(value)
|
||||
|
||||
def dataframe(self, value, **kwargs):
|
||||
self.dataframes.append(value)
|
||||
|
||||
def info(self, value):
|
||||
self.info_values.append(str(value))
|
||||
|
||||
def error(self, value):
|
||||
self.error_values.append(str(value))
|
||||
|
||||
|
||||
|
||||
def test_history_tools_are_registered_with_public_no_db_path_schemas():
|
||||
tools = server.mcp._tool_manager._tools
|
||||
|
||||
for name in HISTORY_TOOLS:
|
||||
assert name in tool_contracts.TOOL_CONTRACTS
|
||||
assert name in tool_contracts.__all__
|
||||
assert name in tools
|
||||
schema = tools[name].parameters if hasattr(tools[name], "parameters") else tools[name].inputSchema
|
||||
properties = schema.get("properties", {})
|
||||
assert "db_path" not in properties
|
||||
assert set(properties) == set(inspect.signature(getattr(service, name)).parameters) - {"db_path"}
|
||||
|
||||
|
||||
|
||||
def test_history_control_opens_selected_run_and_render_fetches_exact_record(monkeypatch):
|
||||
fake = _FakeStreamlit()
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
calls: list[tuple] = []
|
||||
|
||||
def list_history(month="", *, db_path=None):
|
||||
calls.append(("list", month, db_path))
|
||||
return {
|
||||
"success": True,
|
||||
"history": [
|
||||
{"run_id": "run-old", "settlement_month": month, "version": 2, "closed_at": "2026-10-01", "close_status": "complete_exact"},
|
||||
{"run_id": "run-new", "settlement_month": month, "version": 3, "closed_at": "2026-10-02", "close_status": "conditional", "correction_target": "run-old", "reason": "fixed"},
|
||||
],
|
||||
}
|
||||
|
||||
def get_record(run_id, *, db_path=None):
|
||||
calls.append(("get", run_id, db_path))
|
||||
return {"success": True, "history": {"run_id": run_id, "net_cashflow": 55, "net_worth": 995}}
|
||||
|
||||
monkeypatch.setattr(view.gateway, "list_monthly_settlement_history", list_history)
|
||||
monkeypatch.setattr(view.gateway, "get_monthly_settlement_history_record", get_record)
|
||||
|
||||
view._open_settlement_history("draft-1", "run-old")
|
||||
assert fake.session_state["settlement_history_open_draft"] == "draft-1"
|
||||
assert fake.session_state["settlement_history_run_draft-1"] == "run-old"
|
||||
|
||||
view._render_settlement_history("fixture.sqlite3", "2026-09", "draft-1")
|
||||
|
||||
assert calls == [("list", "2026-09", "fixture.sqlite3"), ("get", "run-new", "fixture.sqlite3")]
|
||||
assert fake.selectbox_calls[0][0] == "불변 마감 run 선택"
|
||||
assert fake.selectbox_calls[0][1] == ["run-old", "run-new"]
|
||||
assert fake.json_values == [{"run_id": "run-new", "net_cashflow": 55, "net_worth": 995}]
|
||||
|
||||
|
||||
def test_public_history_contract_and_immutable_run_model_are_documented():
|
||||
root = Path(__file__).parents[1]
|
||||
interface = (root / "docs/interface-contract.md").read_text(encoding="utf-8")
|
||||
data_model = (root / "docs/data-model.md").read_text(encoding="utf-8")
|
||||
|
||||
for operation in HISTORY_TOOLS:
|
||||
assert operation in interface
|
||||
assert "MCP signatures omit internal `db_path`" in interface
|
||||
assert "마감내역보기" in interface
|
||||
assert "append-only immutable settlement history" in data_model
|
||||
assert "correction_of_run_id" in data_model
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.adapters.mcp import server
|
||||
@@ -33,6 +34,10 @@ def test_real_estate_appears_in_list_and_asset_preview(tmp_path):
|
||||
listed = service.list_ledger_accounts(db_path=db_path)
|
||||
assert listed["success"] is True
|
||||
assert listed["accounts"][0]["liquidity_bucket"] == "real_estate"
|
||||
assert service.register_account_valuation(
|
||||
created["account"]["id"], "2026-10-16", 500_000_000,
|
||||
verification_status="verified", db_path=db_path,
|
||||
)["success"]
|
||||
preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path)
|
||||
assert preview["success"] is True
|
||||
assert any(
|
||||
@@ -55,3 +60,9 @@ def test_unknown_account_type_remains_rejected(tmp_path):
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "validation_error"
|
||||
|
||||
|
||||
def test_data_model_documents_real_estate_account_type():
|
||||
data_model = (Path(__file__).parents[1] / "docs" / "data-model.md").read_text()
|
||||
type_row = next(line for line in data_model.splitlines() if line.startswith("| `type`"))
|
||||
assert "real_estate" in type_row
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_documented_paths_and_symbols_exist() -> None:
|
||||
"dashboard/page_views/accounts.py",
|
||||
"dashboard/page_views/assets.py",
|
||||
"tests/test_account_valuations.py",
|
||||
"tests/test_account_snapshot.py",
|
||||
"tests/test_asset_snapshots.py",
|
||||
"tests/test_contract_regression.py",
|
||||
]
|
||||
for rel in paths:
|
||||
|
||||
@@ -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"] == "16"
|
||||
assert service.health_check(db_path)["schema_version"] == "15"
|
||||
|
||||
|
||||
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,8 +34,8 @@ def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path):
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
assert db.execute("SELECT amount, closed_at FROM account_snapshot WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00")
|
||||
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is None
|
||||
assert db.execute("SELECT amount, closed_at FROM asset_snapshots WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00")
|
||||
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is not None
|
||||
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
|
||||
assert _rows(path, "accounts") == before_accounts
|
||||
@@ -47,9 +46,9 @@ def test_actual_v14_migration_is_idempotent_and_snapshot_unchanged(tmp_path):
|
||||
path = tmp_path / "ledger.sqlite3"
|
||||
_v14_db(path)
|
||||
init_db(str(path))
|
||||
first = _rows(path, "account_snapshot")
|
||||
first = _rows(path, "asset_snapshots")
|
||||
init_db(str(path))
|
||||
assert _rows(path, "account_snapshot") == first
|
||||
assert _rows(path, "asset_snapshots") == first
|
||||
|
||||
|
||||
def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch):
|
||||
@@ -75,3 +74,48 @@ def test_new_init_has_same_contract(tmp_path):
|
||||
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")}
|
||||
assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols
|
||||
|
||||
|
||||
def test_populated_v14_upgrade_preserves_evidence_corrections_and_cashflow(tmp_path):
|
||||
path = tmp_path / "populated.sqlite3"
|
||||
_v14_db(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("INSERT INTO manual_assets(id,name,valuation_amount,created_at,updated_at) VALUES('manual','House',500,'t','t')")
|
||||
db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at) VALUES('v1','a1','2026-01-31',100,'t')")
|
||||
db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at,supersedes_id,correction_reason) VALUES('v2','a1','2026-01-31',110,'t','v1','correct')")
|
||||
db.execute("UPDATE asset_snapshots SET snapshot_run_id='run1' WHERE id='s1'")
|
||||
# A direct asset close can have no settlement_month. A correction shares
|
||||
# its basis date, but its frozen cashflow must never merge with run1.
|
||||
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at,correction_of_run_id,correction_reason) VALUES('run2','2026-01-31','conditional','t','run1','correct')")
|
||||
for run, amount in [("run1", 10), ("run2", 20)]:
|
||||
db.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,'income','','',?,1,'KRW')", ("cf-" + run, run, amount))
|
||||
db.execute("CREATE INDEX custom_valuation_amount ON account_valuations(amount)")
|
||||
tables = ("accounts", "transactions", "manual_assets", "account_valuations", "asset_snapshots", "asset_snapshot_runs")
|
||||
with sqlite3.connect(path) as db:
|
||||
columns = {table: ",".join(row[1] for row in db.execute(f"PRAGMA table_info({table})")) for table in tables}
|
||||
before = {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables}
|
||||
init_db(str(path))
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as db:
|
||||
assert {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables} == before
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT settlement_run_id,monthly_settlement_id,amount FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == [("run1", None, 10), ("run2", None, 20)]
|
||||
assert db.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone() == (0,)
|
||||
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (0,)
|
||||
assert db.execute("SELECT name FROM sqlite_master WHERE name='custom_valuation_amount'").fetchone()
|
||||
assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",)
|
||||
assert db.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
|
||||
def test_orphan_cashflow_migration_rolls_back_without_deleting_legacy_data(tmp_path):
|
||||
path = tmp_path / "orphan.sqlite3"
|
||||
_v14_db(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("INSERT INTO settlement_cashflow_snapshots VALUES('orphan','missing','income','','',1,1,'KRW')")
|
||||
before = _rows(path, "settlement_cashflow_snapshots")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
init_db(str(path))
|
||||
assert _rows(path, "settlement_cashflow_snapshots") == before
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() == ("14",)
|
||||
assert not db.execute("SELECT name FROM sqlite_master WHERE name='account_snapshot'").fetchone()
|
||||
|
||||
@@ -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"] == "16"
|
||||
assert result["schema_version"] == "15"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
|
||||
@@ -36,22 +36,6 @@ def _add_tx(day, typ, amount, category, subcategory, path):
|
||||
return result["transaction"]["id"]
|
||||
|
||||
|
||||
def _add_snapshot(name, amount, basis_date, bucket, path):
|
||||
"""Create the schema-15 account snapshot evidence used by settlement review."""
|
||||
account = _ok(service.create_ledger_account(name, type="bank", currency="KRW", liquidity_bucket=bucket, db_path=path))["account"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO monthly_settlement "
|
||||
"(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
("monthly-2026-09", basis_date[:7], basis_date, "draft", 1, "test", "now", "test", "now"),
|
||||
)
|
||||
return _ok(service.register_account_valuation(
|
||||
account["id"], basis_date, amount, currency="KRW", monthly_settlement_id="monthly-2026-09",
|
||||
liquidity_bucket=bucket, db_path=path,
|
||||
))
|
||||
|
||||
|
||||
def test_explicit_savings_refund_and_movement_precedence(tmp_path):
|
||||
path = str(tmp_path / "policy-precedence.sqlite3")
|
||||
init_db(path)
|
||||
@@ -120,11 +104,11 @@ def test_unknown_classification_blocks_and_explicit_exclusion_unblocks(tmp_path)
|
||||
def test_policy_edit_persists_versions_and_invalidates_approval(tmp_path):
|
||||
path = str(tmp_path / "policy-approval.sqlite3")
|
||||
init_db(path)
|
||||
_ok(_add_snapshot("Synthetic cash", 100, "2026-09-30", "cash", path))
|
||||
_ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert not reviewed["cashflow_blockers"]
|
||||
_ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path))
|
||||
_ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
result = _ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "editor", _policy(), db_path=path))
|
||||
saved = _ok(service.get_settlement_draft(draft["id"], db_path=path))["draft"]
|
||||
assert saved["cashflow_policy"] == _policy()
|
||||
@@ -148,14 +132,23 @@ def test_review_contract_and_stored_reader_never_recomputes_live_preview(tmp_pat
|
||||
init_db(path)
|
||||
reader = _capability("get_monthly_settlement_snapshot")
|
||||
empty = reader(month="2026-09", db_path=path)
|
||||
assert empty["success"] is False and empty["error_code"] == "unsupported"
|
||||
_ok(_add_snapshot("Synthetic cash", 100, "2026-09-30", "cash", path))
|
||||
assert empty["success"] is False and empty["error_code"] == "not_found"
|
||||
_ok(service.create_manual_asset("Synthetic cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
assert "review_fingerprint" in reviewed and "review" not in reviewed
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path))
|
||||
final = service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path)
|
||||
assert final == {"success": False, "error_code": "unsupported", "error": "run history unavailable"}
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
final = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "close-key", db_path=path))
|
||||
assert "final_response" not in final and "snapshot_run_id" in final
|
||||
snapshot = _ok(reader(run_id=final["snapshot_run_id"], db_path=path))
|
||||
assert snapshot["is_closed_snapshot"] is True
|
||||
assert snapshot["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
assert snapshot["total_assets"] == 100
|
||||
assert _ok(reader(month="2026-09", db_path=path))["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
_ok(service.create_manual_asset("Later live cash", 900, "2026-09-30", "cash", db_path=path))
|
||||
persisted = _ok(reader(run_id=final["snapshot_run_id"], db_path=path))
|
||||
assert persisted["total_assets"] == 100 and persisted["snapshot_run_id"] == final["snapshot_run_id"]
|
||||
assert "live_authority" not in persisted.values()
|
||||
|
||||
|
||||
def test_month_boundaries_refund_month_negative_total_and_fx_blocker(tmp_path):
|
||||
@@ -199,7 +192,7 @@ def test_policy_capabilities_are_checked_at_call_time(tmp_path):
|
||||
init_db(path)
|
||||
assert callable(_capability("set_settlement_cashflow_policy"))
|
||||
assert callable(_capability("get_monthly_settlement_snapshot"))
|
||||
assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "unsupported"
|
||||
assert _capability("get_monthly_settlement_snapshot")(month="", run_id="", db_path=path)["error_code"] == "not_found"
|
||||
|
||||
|
||||
def _private_cashflow_review(path, policy=None):
|
||||
@@ -257,7 +250,7 @@ def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
|
||||
path = str(tmp_path / "inactive-category-fallback.sqlite3")
|
||||
init_db(path)
|
||||
# A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize.
|
||||
_ok(_add_snapshot("Synthetic close cash", 100, "2026-09-30", "cash", path))
|
||||
_ok(service.create_manual_asset("Synthetic close cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
cases = [
|
||||
# SQL fallback is category-based: ordinary legacy categories become expense,
|
||||
# while canonical legacy categories retain their established classifications.
|
||||
@@ -290,9 +283,15 @@ def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
|
||||
assert row["category_cashflow_kind"] is None and row["category_active"] is None
|
||||
assert reviewed["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37
|
||||
assert reviewed["income_total"] == 29
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", allow_missing_exclusions=True, db_path=path))
|
||||
closed = service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path)
|
||||
assert closed == {"success": False, "error_code": "unsupported", "error": "run history unavailable"}
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path))
|
||||
snapshot = _ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
persisted = {row["id"]: row for row in snapshot["source_snapshot"]["transactions"]}
|
||||
for txid in ids:
|
||||
assert persisted[txid]["sourceprovenance"] == "fallback"
|
||||
assert persisted[txid]["category_active"] is None
|
||||
assert persisted[txid]["category_cashflow_kind"] is None
|
||||
assert snapshot["expense_total"] == 123 and snapshot["income_total"] == 29
|
||||
|
||||
|
||||
def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path):
|
||||
|
||||
@@ -23,11 +23,11 @@ def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
|
||||
insert = "INSERT INTO settlement_cashflow_snapshots(id,monthly_settlement_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
|
||||
good = ("d1", "month-a", "expense", "", "", -7, 1, "KRW")
|
||||
db.execute(insert, good)
|
||||
for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "run-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "run-a", "expense", "", "", 7, 1, "KRW"), ("d5", "run-a", "expense", "", "", None, 1, "KRW"), ("d6", "run-a", "expense", "", "", 1, 0, "KRW"), ("d7", "run-a", "expense", None, "", 1, 1, "KRW"), ("d8", "run-a", "expense", "", None, 1, 1, "KRW")]:
|
||||
for values in [("d2", "missing", "expense", "x", "y", 1, 1, "KRW"), ("d3", "month-a", "other", "x", "y", 1, 1, "KRW"), ("d4", "month-a", "expense", "", "", 7, 1, "KRW"), ("d5", "month-a", "expense", "", "", None, 1, "KRW"), ("d6", "month-a", "expense", "", "", 1, 0, "KRW"), ("d7", "month-a", "expense", None, "", 1, 1, "KRW"), ("d8", "month-a", "expense", "", None, 1, 1, "KRW")]:
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute(insert, values)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute(insert, ("d9", "run-a", "expense", "", "", -7, 1, "KRW"))
|
||||
db.execute(insert, ("d9", "month-a", "expense", "", "", -7, 1, "KRW"))
|
||||
db.execute(insert, ("d10", "month-a", "income", "", "", 2, 1, "KRW"))
|
||||
indexes = {r[1]: r[2] for r in db.execute("PRAGMA index_list(settlement_cashflow_snapshots)")}
|
||||
unique_fields = [tuple(r[2] for r in db.execute(f"PRAGMA index_info('{name}')")) for name, unique in indexes.items() if unique]
|
||||
@@ -44,8 +44,57 @@ def test_init_is_idempotent_and_retains_schema_15_contract(tmp_path):
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '15'
|
||||
assert not db.execute("SELECT 1 FROM sqlite_master WHERE name='asset_snapshot_runs'").fetchone()
|
||||
assert db.execute("SELECT 1 FROM sqlite_master WHERE name='asset_snapshot_runs'").fetchone()
|
||||
first = db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0]
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first
|
||||
|
||||
|
||||
def test_legacy_migration_is_idempotent_and_keeps_unstored_totals_null(tmp_path):
|
||||
"""Schema-13 fixture uses the frozen production header and asset DDL."""
|
||||
path = str(tmp_path / "schema13.sqlite3")
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE schema_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
||||
INSERT INTO schema_meta VALUES('schema_version','13');
|
||||
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 '', snapshot_run_id TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '',
|
||||
valuation_id TEXT NOT NULL DEFAULT '', selection_policy TEXT NOT NULL DEFAULT 'exact',
|
||||
verification_status TEXT NOT NULL DEFAULT 'unverified', evidence_kind TEXT NOT NULL DEFAULT 'applied',
|
||||
exclusion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE asset_snapshot_runs (
|
||||
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
|
||||
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
||||
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
||||
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
||||
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL, draft_id TEXT NOT NULL DEFAULT '', draft_version INTEGER NOT NULL DEFAULT 0,
|
||||
review_fingerprint TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-09-30','complete_exact','then');
|
||||
INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,created_at,snapshot_run_id) VALUES('legacy-row','2026-09-30','manual_asset','synthetic','Synthetic',42,'then','legacy-run');
|
||||
""")
|
||||
init_db(path)
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
columns = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert "draft_id" in columns and "income_total" in columns
|
||||
first = db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()
|
||||
assert first[:4] == ("legacy-run", "2026-09-30", "complete_exact", "then")
|
||||
assert first[4:] == (None, None, None, None, None, None)
|
||||
old = db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall()
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT id,basis_date,close_status,created_at,income_total,expense_total,net_cashflow,total_assets,total_liabilities,net_worth FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone() == first
|
||||
assert db.execute("SELECT id,basis_date,source_type,source_id,name,amount,snapshot_run_id FROM asset_snapshots").fetchall() == old
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == 0
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1
|
||||
assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
|
||||
assert conn.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0
|
||||
assert conn.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path):
|
||||
|
||||
@@ -30,7 +30,7 @@ def approve(path, draft, **flags):
|
||||
|
||||
def counts(path):
|
||||
with sqlite3.connect(path) as conn:
|
||||
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'account_snapshot'))
|
||||
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sql', [
|
||||
|
||||
@@ -163,12 +163,12 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb
|
||||
d = choose(path, d, cash, cv)
|
||||
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON account_snapshot BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
|
||||
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
|
||||
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
|
||||
assert not failed['success'] and failed['error_code'] == 'db_error'
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
|
||||
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == 0
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
|
||||
c.execute('DROP TRIGGER synthetic_failure')
|
||||
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
|
||||
def finish():
|
||||
@@ -184,4 +184,4 @@ def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollb
|
||||
assert finish() == final
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1
|
||||
assert c.execute('SELECT COUNT(*) FROM account_snapshot').fetchone()[0] == final['inserted_count']
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count']
|
||||
|
||||
@@ -13,7 +13,7 @@ def ok(r):
|
||||
def counts(path):
|
||||
with sqlite3.connect(path) as c:
|
||||
return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
|
||||
for table in ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items')}
|
||||
for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
|
||||
|
||||
|
||||
def finish(path, draft):
|
||||
@@ -32,7 +32,7 @@ def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_co
|
||||
original_rows = counts(path)
|
||||
corrected_value = ok(s.correct_account_valuation(first_value, 125, 'synthetic valuation correction', idempotency_key='v2', db_path=path))['valuation']['id']
|
||||
assert counts(path)['asset_snapshot_runs'] == original_rows['asset_snapshot_runs']
|
||||
assert counts(path)['account_snapshot'] == original_rows['account_snapshot']
|
||||
assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots']
|
||||
|
||||
run_id = original['snapshot_run_id']
|
||||
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
|
||||
|
||||
@@ -37,7 +37,11 @@ def test_monthly_and_assets_pages_invoke_shared_panel():
|
||||
pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")]
|
||||
assert len(pages) == 2
|
||||
for page in pages:
|
||||
assert "render_settlement_drafts" in page.read_text(encoding="utf-8")
|
||||
source = page.read_text(encoding="utf-8")
|
||||
if page.name == "monthly_settlement.py":
|
||||
assert "page_legacy_monthly_settlement" in source
|
||||
source += (page.parent / "legacy_monthly_settlement.py").read_text(encoding="utf-8")
|
||||
assert "render_settlement_drafts" in source
|
||||
|
||||
|
||||
def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path):
|
||||
|
||||
@@ -174,7 +174,7 @@ def test_settlement_draft_runtime_cancel_and_recovery_contract() -> None:
|
||||
for clause in ("cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason, db_path=None)",
|
||||
"active", "nonempty", "cancelled_by", "cancellation_reason", "cancelled_at",
|
||||
"approval_fingerprint", "approval_version", "version", "updated_at", "BEGIN IMMEDIATE",
|
||||
"retained", "account_valuations", "asset_snapshot_runs", "account_snapshot",
|
||||
"retained", "account_valuations", "asset_snapshot_runs", "asset_snapshots",
|
||||
"rollback", "no-write", "completed", "cancelled", "fresh create key",
|
||||
"same create key", "latest prior run", "correction_of_run_id", "correction_reason",
|
||||
"tests/test_settlement_draft_correction_contract.py"):
|
||||
@@ -207,7 +207,7 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_
|
||||
names = {
|
||||
"asset_snapshot_runs": ("settlement_month", "currency", "income_total", "expense_total", "net_cashflow", "total_assets", "total_liabilities", "net_worth", "cashflow_policy_json", "aggregation_version", "snapshot_schema_version", "source_snapshot_json"),
|
||||
"settlement_drafts": ("cashflow_policy_json", "aggregation_version"),
|
||||
"settlement_cashflow_snapshots": ("id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
|
||||
"settlement_cashflow_snapshots": ("id", "monthly_settlement_id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"),
|
||||
}
|
||||
database = tmp_path / "synthetic-doc-schema.sqlite3"
|
||||
init_db(str(database))
|
||||
@@ -220,7 +220,12 @@ def test_settlement_cashflow_model_documents_schema_and_legacy_reproduction(tmp_
|
||||
reproduction = _comprehensive_section(doc, "### Comprehensive snapshot evidence inputs")
|
||||
for field in ("source_snapshot_json", "transactions", "assets", "transaction_date", "originalcashflow_kind", "applied_amount", "valuation_id", "valuation_date", "authority_source", "cashflow_policy_json", "legacy", "NULL"):
|
||||
assert field in reproduction, field
|
||||
for constraint in ("ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0", "idx_settlement_cashflow_snapshots_run"):
|
||||
for constraint in (
|
||||
"ON DELETE RESTRICT", "UNIQUE", "transaction_count > 0",
|
||||
"idx_settlement_cashflow_snapshots_run", "exactly one parent",
|
||||
"(settlement_run_id,cashflow_kind,category,subcategory,currency)",
|
||||
"(monthly_settlement_id,cashflow_kind,category,subcategory,currency)",
|
||||
):
|
||||
assert constraint in doc, constraint
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from mcp.client.stdio import stdio_client
|
||||
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
|
||||
'update_settlement_draft', 'refresh_settlement_draft_targets',
|
||||
'set_settlement_draft_item', 'review_settlement_draft',
|
||||
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft')
|
||||
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft',
|
||||
'list_monthly_settlement_history', 'get_monthly_settlement_history_record')
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@@ -44,7 +45,7 @@ def test_required_public_field_assertion_detects_omission():
|
||||
|
||||
|
||||
def rows(db, table):
|
||||
assert table in {'asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items'}
|
||||
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
|
||||
with closing(sqlite3.connect(db)) as connection:
|
||||
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
|
||||
|
||||
@@ -138,7 +139,7 @@ async def _exercise_stdio_lifecycle(tmp_path):
|
||||
assert field in provenance, f'final provenance missing {field}'
|
||||
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
|
||||
before = (await call('get_settlement_draft', {'draft_id':draft_id}))
|
||||
tables = ('asset_snapshot_runs', 'account_snapshot', 'settlement_drafts', 'settlement_draft_items')
|
||||
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
|
||||
before_replay = {table: rows(db, table) for table in tables}
|
||||
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
|
||||
archived = await call('get_settlement_draft', {'draft_id':draft_id})
|
||||
@@ -147,6 +148,16 @@ async def _exercise_stdio_lifecycle(tmp_path):
|
||||
assert archived == before
|
||||
assert {table: rows(db, table) for table in tables} == before_replay
|
||||
|
||||
history = await call('list_monthly_settlement_history', {'month': '2026-09'})
|
||||
require_fields(history, {'success', 'history', 'month', 'read_only'}, 'history list stdio result')
|
||||
assert history['success'] and history['read_only'] is True
|
||||
assert [item['run_id'] for item in history['history']] == [closed['run_id']]
|
||||
record = await call('get_monthly_settlement_history_record', {'run_id': closed['run_id']})
|
||||
require_fields(record, {'success', 'history', 'read_only'}, 'history record stdio result')
|
||||
assert record['success'] and record['read_only'] is True
|
||||
assert record['history']['run_id'] == closed['run_id']
|
||||
assert record['history']['net_worth'] == 100
|
||||
|
||||
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
|
||||
assert second['success']
|
||||
cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
|
||||
|
||||
@@ -259,7 +259,7 @@ def test_legacy_amount_approval_requires_fresh_review_after_policy_correction(tm
|
||||
assert rejected["error_code"] == "conflict"
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
row = db.execute("SELECT status,version,final_run_id FROM settlement_drafts WHERE id=?", (d["id"],)).fetchone()
|
||||
assert row == ("active", d["version"], "")
|
||||
assert frozen_items(path, d["id"]) == frozen_before
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
|
||||
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]),
|
||||
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]),
|
||||
"2026-09-30T02:00:00Z"))
|
||||
conn.execute("INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,monthly_settlement_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate',NULL,'','2026-09-30T02:00:00Z')")
|
||||
conn.execute("INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,snapshot_run_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate','run-draft','','2026-09-30T02:00:00Z')")
|
||||
conn.commit()
|
||||
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
|
||||
assert report["success"]
|
||||
|
||||
Reference in New Issue
Block a user