feat: deliver schema 15 monthly settlement lifecycle
This commit is contained in:
Vendored
+289
@@ -0,0 +1,289 @@
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'bank',
|
||||
institution TEXT NOT NULL DEFAULT '',
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
account_number_masked TEXT NOT NULL DEFAULT '',
|
||||
holder_name TEXT NOT NULL DEFAULT '',
|
||||
last_balance INTEGER,
|
||||
last_balance_basis_date TEXT NOT NULL DEFAULT '',
|
||||
last_synced_at TEXT NOT NULL DEFAULT '',
|
||||
statement_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
transaction_date TEXT NOT NULL,
|
||||
posted_date TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
account_id TEXT NOT NULL DEFAULT '',
|
||||
payment_method TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_fixed INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
import_batch_id TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
dedupe_key TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
transaction_datetime TEXT NOT NULL DEFAULT '',
|
||||
counterparty TEXT NOT NULL DEFAULT '',
|
||||
balance_after INTEGER,
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
raw_summary TEXT NOT NULL DEFAULT '',
|
||||
raw_payload TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
month TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
imported_at TEXT NOT NULL,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
memo TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS category_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
match_field TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL DEFAULT 'contains',
|
||||
pattern TEXT NOT NULL,
|
||||
amount_min INTEGER,
|
||||
amount_max INTEGER,
|
||||
day_of_month INTEGER,
|
||||
transaction_type TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
is_fixed INTEGER,
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
valuation_amount INTEGER,
|
||||
valuation_date TEXT NOT NULL DEFAULT '',
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
settlement_month TEXT,
|
||||
currency TEXT,
|
||||
income_total INTEGER,
|
||||
expense_total INTEGER,
|
||||
net_cashflow INTEGER,
|
||||
total_assets INTEGER,
|
||||
total_liabilities INTEGER,
|
||||
net_worth INTEGER,
|
||||
cashflow_policy_json TEXT,
|
||||
aggregation_version TEXT,
|
||||
snapshot_schema_version INTEGER,
|
||||
source_snapshot_json TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
|
||||
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
|
||||
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
|
||||
currency TEXT NOT NULL,
|
||||
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS review_decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
decision_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
reason_code TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
operator TEXT NOT NULL DEFAULT 'operator',
|
||||
decided_at TEXT NOT NULL DEFAULT '',
|
||||
review_after TEXT NOT NULL DEFAULT '',
|
||||
target_payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reconciliation_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
basis_date TEXT NOT NULL,
|
||||
expected_balance INTEGER,
|
||||
actual_balance INTEGER,
|
||||
difference INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
closed_at TEXT NOT NULL DEFAULT '',
|
||||
source_note TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
operator TEXT NOT NULL DEFAULT 'operator',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_valuations (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
basis_date TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
valuation_kind TEXT NOT NULL DEFAULT 'balance',
|
||||
source_type TEXT NOT NULL DEFAULT 'manual',
|
||||
source_ref TEXT NOT NULL DEFAULT '',
|
||||
verification_status TEXT NOT NULL DEFAULT 'unverified',
|
||||
recorded_at TEXT NOT NULL,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
supersedes_id TEXT REFERENCES account_valuations(id),
|
||||
correction_reason TEXT NOT NULL DEFAULT '',
|
||||
as_of_at TEXT,
|
||||
timezone TEXT,
|
||||
cost_basis INTEGER,
|
||||
net_contributed INTEGER,
|
||||
reported_principal_kind TEXT,
|
||||
reported_principal_label TEXT,
|
||||
profit INTEGER,
|
||||
return_rate REAL,
|
||||
return_basis TEXT,
|
||||
principal INTEGER,
|
||||
interest INTEGER,
|
||||
fee INTEGER,
|
||||
representative_liability_definition TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL;
|
||||
|
||||
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '14');
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from jarca_ledger_mcp.application.account_valuation_service import (
|
||||
register_account_valuation,
|
||||
update_account_snapshot_category,
|
||||
)
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def _ledger(tmp_path, *, account_type="bank", bucket="cash"):
|
||||
path = tmp_path / "category.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO accounts(id,name,type,currency,liquidity_bucket,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
|
||||
("acct-1", "계좌", account_type, "KRW", bucket, "2026-10-01", "2026-10-01"),
|
||||
)
|
||||
conn.commit()
|
||||
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
|
||||
return path, settlement
|
||||
|
||||
|
||||
def test_registration_uses_account_default_and_rejects_invalid_explicit_override(tmp_path):
|
||||
path, settlement = _ledger(tmp_path, bucket="deposit")
|
||||
result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement, db_path=str(path))
|
||||
assert result["success"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT liquidity_bucket FROM account_snapshot").fetchone() == ("deposit",)
|
||||
invalid_dir = tmp_path / "invalid"
|
||||
invalid_dir.mkdir()
|
||||
path2, settlement2 = _ledger(invalid_dir, bucket="cash")
|
||||
result = register_account_valuation("acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement2, liquidity_bucket="bogus", db_path=str(path2))
|
||||
assert result["success"] is False and result["error_code"] == "validation_error"
|
||||
|
||||
|
||||
def test_category_edit_only_changes_bucket_and_preserves_everything_else(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
result = register_account_valuation("acct-1", "2026-10-31", 123, "KRW", memo="keep", monthly_settlement_id=settlement, db_path=str(path))
|
||||
snapshot = result["snapshot_id"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
before = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
|
||||
changed = update_account_snapshot_category(snapshot, settlement, "loan", db_path=str(path))
|
||||
assert changed["success"] and changed["liquidity_bucket"] == "loan"
|
||||
with sqlite3.connect(path) as conn:
|
||||
after = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot,)).fetchone()
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
|
||||
assert dict(zip(cols, before)) | {"liquidity_bucket": "loan"} == dict(zip(cols, after))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["draft", "in_review"])
|
||||
def test_category_edit_allows_draft_and_in_review(tmp_path, status):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE monthly_settlement SET status=? WHERE id=?", (status, settlement))
|
||||
conn.commit()
|
||||
assert update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))["success"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["approved", "closed"])
|
||||
def test_category_edit_blocks_approved_and_closed(tmp_path, status):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE monthly_settlement SET status=?, approved_by='test', approved_at='2026-10-31', approved_version=1, approved_fingerprint='fp', closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END, closed_at=CASE WHEN ?='closed' THEN '2026-10-31' ELSE closed_at END WHERE id=?", (status, status, status, settlement))
|
||||
conn.commit()
|
||||
result = update_account_snapshot_category(snapshot, settlement, "investment", db_path=str(path))
|
||||
assert result["success"] is False and result["error_code"] == "invalid_settlement_status"
|
||||
|
||||
|
||||
def test_category_edit_rejects_wrong_monthly(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
|
||||
snapshot = register_account_valuation("acct-1", "2026-10-31", 1, "KRW", monthly_settlement_id=settlement, db_path=str(path))["snapshot_id"]
|
||||
result = update_account_snapshot_category(snapshot, other, "investment", db_path=str(path))
|
||||
assert result["success"] is False and result["error_code"] == "validation_error"
|
||||
|
||||
|
||||
def test_app_category_select_saves_clicked_snapshot_and_monthly(monkeypatch):
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
|
||||
calls = []
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}}})
|
||||
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *a, **k: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}})
|
||||
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *a, **k: {"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 1, "unregistered_count": 0, "snapshots": [{"account_id": "a1", "account_name": "대출", "amount": 100, "currency": "KRW", "basis_date": "2026-10-31", "snapshot_id": "snap-1", "display_status": "확인", "account_type": "loan", "account_liquidity_bucket": "loan", "snapshot_account_category": "loan"}]})
|
||||
monkeypatch.setattr(settlement_gateway, "update_account_snapshot_category", lambda snapshot_id, monthly_id, value, **k: calls.append((snapshot_id, monthly_id, value)) or {"success": True})
|
||||
app = AppTest.from_string("import streamlit as st\nst.query_params.update(page='monthly_settlement', id='ms-1')\nfrom dashboard.page_views.monthly_settlement import page_monthly_settlement\npage_monthly_settlement(db_path='unused.sqlite3')\n").run()
|
||||
dialog = [b for b in app.button if b.label == "✎"][0].click().run()
|
||||
assert dialog.selectbox[0].value == "loan"
|
||||
save = [button for button in dialog.button if button.label == "저장"][0]
|
||||
dialog.selectbox[0].set_value("deposit")
|
||||
save.click().run()
|
||||
assert save.label == "저장"
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import (
|
||||
create_monthly_settlement,
|
||||
list_account_snapshots_for_settlement,
|
||||
)
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
from dashboard.page_views.monthly_settlement import _account_evaluation_totals, _format_evaluation_total
|
||||
|
||||
|
||||
def test_account_evaluation_totals_native_currency_null_and_zero():
|
||||
result = _account_evaluation_totals([
|
||||
{"account_id": "a1", "snapshot_id": "s1", "amount": 0, "currency": "KRW"},
|
||||
{"account_id": "a2", "snapshot_id": "s2", "amount": -2500, "currency": "USD"},
|
||||
{"account_id": "a3", "snapshot_id": "s3", "amount": None, "currency": "KRW"},
|
||||
{"account_id": "a4", "snapshot_id": None, "amount": 999999, "currency": "KRW"},
|
||||
])
|
||||
assert result["totals"] == {"KRW": 0, "USD": -2500}
|
||||
assert result["unknown_count"] == 1
|
||||
assert result["registered_count"] == 3
|
||||
assert result["unregistered_count"] == 1
|
||||
assert _format_evaluation_total("KRW", 0) == "0원"
|
||||
assert _format_evaluation_total("USD", -2500) == "-2,500 USD"
|
||||
|
||||
|
||||
def test_account_evaluation_totals_missing_currency_is_separate():
|
||||
result = _account_evaluation_totals([
|
||||
{"account_id": "a1", "snapshot_id": "s1", "amount": 100, "currency": None},
|
||||
{"account_id": "a2", "snapshot_id": None, "amount": None, "currency": "KRW"},
|
||||
])
|
||||
assert result["totals"] == {"미지정": 100}
|
||||
|
||||
|
||||
|
||||
def _account(conn, ident, name, currency="KRW"):
|
||||
conn.execute(
|
||||
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(conn, ident, settlement, account, name, amount, currency="KRW"):
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
(id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(ident, "2026-10-31", "account", account, name, amount, currency, settlement, "2026-10-31T00:00:00Z"),
|
||||
)
|
||||
|
||||
|
||||
def test_draft_uses_all_accounts_and_exact_monthly_snapshot(tmp_path):
|
||||
path = tmp_path / "ledger.sqlite3"
|
||||
init_db(str(path))
|
||||
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
_account(conn, "a1", "계좌1")
|
||||
_account(conn, "a2", "계좌2")
|
||||
_snapshot(conn, "s1", settlement, "a1", "계좌1", 0)
|
||||
_snapshot(conn, "other", "other-month", "a2", "계좌2", 99)
|
||||
conn.commit()
|
||||
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
|
||||
assert [(x["source_id"], x["amount"], x["display_status"]) for x in result["snapshots"]] == [("a1", 0, "확인"), ("a2", None, "미등록")]
|
||||
assert result["count"] == 2 and result["registered_count"] == 1 and result["unregistered_count"] == 1
|
||||
|
||||
|
||||
def test_closed_uses_historical_rows_only_and_preserves_null_zero_and_duplicate(tmp_path):
|
||||
path = tmp_path / "ledger.sqlite3"
|
||||
init_db(str(path))
|
||||
settlement = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
_account(conn, "old", "과거계좌", "USD")
|
||||
_snapshot(conn, "s-null", settlement, "old", "보존이름", None, "USD")
|
||||
_snapshot(conn, "s-zero", settlement, "old", "보존이름", 0, "USD")
|
||||
conn.execute("UPDATE monthly_settlement SET status='closed', approved_by='test', approved_at='2026-10-31T00:00:00Z', approved_version=1, approved_fingerprint='fp', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?", (settlement,))
|
||||
_account(conn, "new", "신규계좌")
|
||||
conn.commit()
|
||||
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
|
||||
assert result["closed"] is True
|
||||
assert [x["amount"] for x in result["snapshots"]] == [None, 0]
|
||||
assert [x["display_status"] for x in result["snapshots"]] == ["미확인", "확인"]
|
||||
assert all(x["name"] == "보존이름" for x in result["snapshots"])
|
||||
assert all(x["source_id"] != "new" for x in result["snapshots"])
|
||||
|
||||
|
||||
def test_monthly_renderer_opens_real_registration_dialog_from_actual_button(monkeypatch):
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
|
||||
|
||||
monkeypatch.setattr(
|
||||
overview_gateway,
|
||||
"get_monthly_settlement_summary",
|
||||
lambda *args, **kwargs: {
|
||||
"success": True,
|
||||
"month": "2026-10",
|
||||
"selected_month": "2026-10",
|
||||
"basis_date": "2026-10-31",
|
||||
"actual_income": 0,
|
||||
"actual_expense": 0,
|
||||
"net_cashflow": 0,
|
||||
"reports": {"cashflow": {}, "budget": {}, "assets": {}},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settlement_gateway,
|
||||
"get_monthly_settlement_record",
|
||||
lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-1", "month": "2026-10", "status": "draft"}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settlement_gateway,
|
||||
"list_account_snapshots_for_settlement",
|
||||
lambda *args, **kwargs: {
|
||||
"success": True,
|
||||
"closed": False,
|
||||
"count": 3,
|
||||
"registered_count": 2,
|
||||
"unregistered_count": 1,
|
||||
"snapshots": [
|
||||
{"account_id": "acct-1", "account_name": "주계좌", "amount": 1630800, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-1", "account_type": "bank", "account_liquidity_bucket": "bank"},
|
||||
{"account_id": "acct-2", "account_name": "임시 주택담보대출", "amount": 1562300, "currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인", "snapshot_id": "snap-2", "account_type": "loan", "account_liquidity_bucket": "loan"},
|
||||
{"account_id": "acct-3", "account_name": "보조계좌", "amount": None, "currency": "USD", "basis_date": None, "display_status": "미등록", "snapshot_id": None},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"st.query_params.update(page='monthly_settlement', id='ms-1')\n"
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
|
||||
default_timeout=30,
|
||||
).run()
|
||||
assert not app.exception
|
||||
assert any(metric.label == "자산 합계 (KRW)" and metric.value == "1,630,800원" for metric in app.metric)
|
||||
assert any(metric.label == "부채 합계 (KRW)" and metric.value == "1,562,300원" for metric in app.metric)
|
||||
assert any(metric.label == "순자산 (KRW)" and metric.value == "68,500원" for metric in app.metric)
|
||||
metric_columns = [column for column in app.columns if column.metric]
|
||||
assert len(metric_columns) == 3
|
||||
assert [column.metric[0].label for column in metric_columns] == [
|
||||
"자산 합계 (KRW)", "부채 합계 (KRW)", "순자산 (KRW)",
|
||||
]
|
||||
assert any("평가등록 2 / 전체 3" in caption.value and "미등록 1" in caption.value for caption in app.caption)
|
||||
assert not [expander for expander in app.expander if expander.label == "필요 메모/근거"]
|
||||
account_buttons = [button for button in app.button if button.label == "보조계좌"]
|
||||
assert len(account_buttons) == 1
|
||||
assert not [button for button in app.button if button.label == "평가 등록"]
|
||||
|
||||
dialog = account_buttons[0].click().run()
|
||||
assert not dialog.exception
|
||||
inputs = {widget.label: widget for widget in dialog.text_input}
|
||||
assert inputs["계정"].value == "보조계좌"
|
||||
assert inputs["계정"].disabled is True
|
||||
assert inputs["통화"].value == "USD"
|
||||
assert dialog.date_input[0].value.isoformat() == "2026-10-31"
|
||||
assert "평가금액 (정수)" in inputs
|
||||
assert dialog.text_area[0].label == "메모 (선택)"
|
||||
|
||||
|
||||
def test_monthly_renderer_preserves_ledger_projection_and_source_choices(monkeypatch):
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
|
||||
|
||||
monkeypatch.setattr(
|
||||
overview_gateway,
|
||||
"get_monthly_settlement_summary",
|
||||
lambda *args, **kwargs: {
|
||||
"success": True, "month": "2026-10", "selected_month": "2026-10",
|
||||
"basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settlement_gateway,
|
||||
"get_monthly_settlement_record",
|
||||
lambda *args, **kwargs: {"success": True, "settlement": {"id": "ms-10", "month": "2026-10", "status": "draft"}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settlement_gateway,
|
||||
"list_account_snapshots_for_settlement",
|
||||
lambda *args, **kwargs: {
|
||||
"success": True, "closed": False, "count": 1, "registered_count": 0,
|
||||
"unregistered_count": 1,
|
||||
"snapshots": [{
|
||||
"account_id": "acct-10", "account_name": "실제계정10", "amount": None,
|
||||
"currency": "KRW", "basis_date": None, "display_status": "미등록",
|
||||
"snapshot_id": None,
|
||||
"previous_ledger": {"id": "prev-10", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
|
||||
"next_ledger": {"id": "next-10", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"st.query_params.update(page='monthly_settlement', id='ms-10')\n"
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
|
||||
default_timeout=30,
|
||||
).run()
|
||||
assert not app.exception
|
||||
dialog = [button for button in app.button if button.label == "실제계정10"][0].click().run()
|
||||
assert not dialog.exception
|
||||
source = dialog.selectbox[0]
|
||||
assert len(source.options) == 3
|
||||
assert source.value.startswith("이전 기록")
|
||||
inputs = {widget.label: widget for widget in dialog.text_input}
|
||||
assert inputs["평가금액 (정수)"].value == "1234"
|
||||
assert inputs["평가금액 (정수)"].disabled is True
|
||||
|
||||
assert any(option.startswith("이후 기록") for option in source.options)
|
||||
assert inputs["평가금액 (정수)"].disabled is True
|
||||
|
||||
|
||||
def test_monthly_renderer_mixed_rows_keeps_snapshot_id_for_unlink(monkeypatch):
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
|
||||
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
|
||||
"success": True, "month": "2026-10", "selected_month": "2026-10",
|
||||
"basis_date": "2026-10-31", "reports": {"cashflow": {}, "budget": {}, "assets": {}},
|
||||
})
|
||||
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
|
||||
"success": True, "settlement": {"id": "ms-mixed", "month": "2026-10", "status": "draft"},
|
||||
})
|
||||
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
|
||||
"success": True, "closed": False, "status": "draft", "count": 2,
|
||||
"registered_count": 1, "unregistered_count": 1,
|
||||
"snapshots": [
|
||||
{"account_id": "acct-1", "account_name": "등록계정", "amount": 100,
|
||||
"currency": "KRW", "basis_date": "2026-10-31", "display_status": "확인",
|
||||
"snapshot_id": "snap-1"},
|
||||
{"account_id": "acct-2", "account_name": "미등록계정", "amount": None,
|
||||
"currency": "USD", "basis_date": None, "display_status": "미등록",
|
||||
"snapshot_id": None},
|
||||
],
|
||||
})
|
||||
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"st.query_params.update(page='monthly_settlement', id='ms-mixed')\n"
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
|
||||
default_timeout=30,
|
||||
).run()
|
||||
assert not app.exception
|
||||
unlink = [button for button in app.button if button.label == "×"]
|
||||
assert len(unlink) == 1
|
||||
assert unlink[0].help == "연결 해제"
|
||||
dialog = unlink[0].click().run()
|
||||
assert not dialog.exception
|
||||
assert any("등록계정" in markdown.value for markdown in dialog.markdown)
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import (
|
||||
create_monthly_settlement,
|
||||
link_account_snapshot,
|
||||
list_account_snapshots_for_settlement,
|
||||
)
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def _account(conn, ident="acct-1", name="실제계정", currency="KRW"):
|
||||
conn.execute(
|
||||
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
(ident, name, currency, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(conn, ident, *, account="acct-1", basis_date="2026-10-31", currency="KRW", settlement=None, amount=1234):
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,
|
||||
monthly_settlement_id,valuation_id,verification_status,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(ident, basis_date, "account", account, "원본 스냅샷명", amount, currency, "cash", settlement,
|
||||
"valuation-1", "unverified", "2026-10-31T00:00:00Z"),
|
||||
)
|
||||
|
||||
|
||||
def _ledger(tmp_path):
|
||||
path = tmp_path / "link.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
draft = create_monthly_settlement("2026-10", db_path=str(path))["settlement_id"]
|
||||
conn.execute("UPDATE monthly_settlement SET basis_date='2026-10-31' WHERE id=?", (draft,))
|
||||
_account(conn)
|
||||
conn.commit()
|
||||
return path, draft
|
||||
|
||||
|
||||
def test_link_updates_only_monthly_fk_and_preserves_snapshot_fields(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
_snapshot(conn, "candidate")
|
||||
before = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
|
||||
columns = [row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")]
|
||||
result = link_account_snapshot("candidate", settlement, db_path=str(path))
|
||||
assert result["success"] is True
|
||||
with sqlite3.connect(path) as conn:
|
||||
after = conn.execute("SELECT * FROM account_snapshot WHERE id='candidate'").fetchone()
|
||||
assert len(before) == len(after) == len(columns)
|
||||
changed = {column: (old, new) for column, old, new in zip(columns, before, after) if old != new}
|
||||
assert changed == {"monthly_settlement_id": (None, settlement)}
|
||||
|
||||
|
||||
def test_actual_read_function_returns_unlinked_candidates_in_exact_order(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
_snapshot(conn, "old", basis_date="2026-09-30", amount=1)
|
||||
_snapshot(conn, "exact-old-created", basis_date="2026-10-31", amount=2)
|
||||
_snapshot(conn, "exact-new-created", basis_date="2026-10-31", amount=3)
|
||||
conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T02:00:00Z' WHERE id='exact-old-created'")
|
||||
conn.execute("UPDATE account_snapshot SET created_at='2026-10-31T03:00:00Z' WHERE id='exact-new-created'")
|
||||
conn.commit()
|
||||
result = list_account_snapshots_for_settlement(settlement, db_path=str(path))
|
||||
row = result["snapshots"][0]
|
||||
assert [item["snapshot_id"] for item in row["unlinked_candidates"]] == ["exact-new-created", "exact-old-created", "old"]
|
||||
|
||||
@pytest.mark.parametrize("status", ["approved", "closed"])
|
||||
def test_link_rejects_approved_and_closed(tmp_path, status):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
_snapshot(conn, "candidate")
|
||||
conn.execute("""UPDATE monthly_settlement SET status=?, approved_by='test',
|
||||
approved_at='2026-10-31T00:00:00Z', approved_version=1,
|
||||
approved_fingerprint='test', closed_by='test', closed_at='2026-10-31T00:00:00Z' WHERE id=?""", (status, settlement))
|
||||
conn.commit()
|
||||
result = link_account_snapshot("candidate", settlement, db_path=str(path))
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "invalid_settlement_status"
|
||||
|
||||
|
||||
def test_link_rejects_unrelated_month_and_already_linked_snapshot(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
other = create_monthly_settlement("2026-11", db_path=str(path))["settlement_id"]
|
||||
_snapshot(conn, "linked-other", settlement=other)
|
||||
_snapshot(conn, "linked-this", settlement=settlement)
|
||||
conn.commit()
|
||||
assert link_account_snapshot("linked-other", settlement, db_path=str(path))["error_code"] == "already_linked"
|
||||
assert link_account_snapshot("linked-this", settlement, db_path=str(path))["error_code"] == "already_linked"
|
||||
|
||||
|
||||
def test_basis_date_mismatch_requires_explicit_true(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
_snapshot(conn, "mismatch", basis_date="2026-09-30")
|
||||
conn.commit()
|
||||
rejected = link_account_snapshot("mismatch", settlement, db_path=str(path))
|
||||
assert rejected["error_code"] == "basis_date_mismatch"
|
||||
accepted = link_account_snapshot("mismatch", settlement, allow_basis_date_mismatch=True, db_path=str(path))
|
||||
assert accepted["success"] is True
|
||||
|
||||
|
||||
def test_link_rejects_account_currency_mismatch(tmp_path):
|
||||
path, settlement = _ledger(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
_snapshot(conn, "bad-currency", currency="USD")
|
||||
conn.commit()
|
||||
result = link_account_snapshot("bad-currency", settlement, db_path=str(path))
|
||||
assert result["error_code"] == "validation_error"
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
|
||||
def test_registration_dialog_exposes_real_account_snapshot_link_mode_and_fallback(monkeypatch):
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, settlement_gateway
|
||||
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *args, **kwargs: {
|
||||
"success": True, "month": "2026-10", "selected_month": "2026-10", "basis_date": "2026-10-31",
|
||||
"reports": {"cashflow": {}, "budget": {}, "assets": {}},
|
||||
})
|
||||
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_record", lambda *args, **kwargs: {
|
||||
"success": True, "settlement": {"id": "ms-link-ui", "month": "2026-10", "basis_date": "2026-10-31", "status": "draft"},
|
||||
})
|
||||
monkeypatch.setattr(settlement_gateway, "list_account_snapshots_for_settlement", lambda *args, **kwargs: {
|
||||
"success": True, "closed": False, "status": "draft", "count": 1, "registered_count": 0,
|
||||
"unregistered_count": 1,
|
||||
"snapshots": [{
|
||||
"account_id": "acct-ui", "account_name": "실제 원본계정", "amount": None, "currency": "KRW",
|
||||
"basis_date": None, "display_status": "미등록", "snapshot_id": None,
|
||||
"unlinked_candidates": [
|
||||
{"snapshot_id": "snap-exact", "basis_date": "2026-10-31", "amount": 777, "currency": "KRW", "verification_status": "unverified"},
|
||||
],
|
||||
"previous_ledger": {"id": "prev", "transaction_date": "2026-10-30", "balance_after": 1234, "currency": "KRW"},
|
||||
"next_ledger": {"id": "next", "transaction_date": "2026-11-01", "balance_after": 2345, "currency": "KRW"},
|
||||
}],
|
||||
})
|
||||
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"st.query_params.update(page='monthly_settlement', id='ms-link-ui')\n"
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement(db_path='synthetic-unused.sqlite3')\n",
|
||||
default_timeout=30,
|
||||
).run()
|
||||
assert not app.exception
|
||||
dialog = [button for button in app.button if button.label == "실제 원본계정"][0].click().run()
|
||||
assert not dialog.exception
|
||||
assert {widget.label: widget for widget in dialog.text_input}["계정"].value == "실제 원본계정"
|
||||
assert dialog.radio[0].value == "기존 스냅샷 연결"
|
||||
assert dialog.selectbox[0].label == "기존 스냅샷"
|
||||
assert "777" in dialog.selectbox[0].options[0]
|
||||
assert {widget.label: widget for widget in dialog.text_input}["선택 금액"].disabled is True
|
||||
|
||||
fallback = dialog.radio[0].set_value("새 평가 등록").run()
|
||||
assert not fallback.exception
|
||||
@@ -1,231 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp.application.account_valuation_service import register_account_valuation
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ledger(tmp_path):
|
||||
path = tmp_path / "actual-service.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO accounts(id,name,currency,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
("acct-1", "주계좌", "KRW", "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"),
|
||||
)
|
||||
conn.commit()
|
||||
settlement = create_monthly_settlement("2026-10", db_path=str(path))
|
||||
return path, settlement["settlement_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("amount", "expected_amount"),
|
||||
[(0, 0), (123456, 123456)],
|
||||
)
|
||||
def test_actual_service_saves_zero_and_amount_with_fk_and_unverified(ledger, amount, expected_amount):
|
||||
path, settlement_id = ledger
|
||||
result = register_account_valuation(
|
||||
"acct-1", "2026-10-31", amount, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
|
||||
)
|
||||
assert result["success"] is True
|
||||
with sqlite3.connect(path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT amount,currency,basis_date,monthly_settlement_id,verification_status,source_type,source_id FROM account_snapshot"
|
||||
).fetchone()
|
||||
assert row == (expected_amount, "KRW", "2026-10-31", settlement_id, "unverified", "account", "acct-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("existing_amount", [None, 0])
|
||||
def test_actual_service_rejects_duplicate_even_when_existing_amount_is_null_or_zero(ledger, existing_amount):
|
||||
path, settlement_id = ledger
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot
|
||||
(id,basis_date,source_type,source_id,name,amount,currency,monthly_settlement_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
("existing", "2026-10-31", "account", "acct-1", "주계좌", existing_amount, "KRW", settlement_id, "2026-10-31T00:00:00Z"),
|
||||
)
|
||||
conn.commit()
|
||||
result = register_account_valuation(
|
||||
account_id="acct-1", basis_date="2026-10-31", amount=99, currency="KRW",
|
||||
monthly_settlement_id=settlement_id, db_path=str(path),
|
||||
)
|
||||
assert result == {
|
||||
"success": False,
|
||||
"error_code": "duplicate",
|
||||
"error": "account evaluation already registered",
|
||||
"snapshot_id": "existing",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["closed", "approved"])
|
||||
def test_actual_service_blocks_closed_and_approved_settlements(ledger, status):
|
||||
path, settlement_id = ledger
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"""UPDATE monthly_settlement
|
||||
SET status=?, approved_by='test', approved_at='2026-10-31T00:00:00Z',
|
||||
approved_version=1, approved_fingerprint='fp',
|
||||
closed_by=CASE WHEN ?='closed' THEN 'test' ELSE closed_by END,
|
||||
closed_at=CASE WHEN ?='closed' THEN '2026-10-31T00:00:00Z' ELSE closed_at END
|
||||
WHERE id=?""",
|
||||
(status, status, status, settlement_id),
|
||||
)
|
||||
conn.commit()
|
||||
result = register_account_valuation(
|
||||
"acct-1", "2026-10-31", 10, "KRW", monthly_settlement_id=settlement_id, db_path=str(path)
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] in {"closed_settlement", "invalid_settlement_status"}
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "error_code"),
|
||||
[
|
||||
({"currency": "USD"}, "validation_error"),
|
||||
({"basis_date": "2026-09-30"}, "validation_error"),
|
||||
({"monthly_settlement_id": "missing"}, "not_found"),
|
||||
],
|
||||
)
|
||||
def test_actual_service_validates_currency_date_and_settlement_and_rolls_back(ledger, kwargs, error_code):
|
||||
path, settlement_id = ledger
|
||||
args = {
|
||||
"account_id": "acct-1", "basis_date": "2026-10-31", "amount": 10, "currency": "KRW",
|
||||
"monthly_settlement_id": settlement_id, "db_path": str(path),
|
||||
}
|
||||
args.update(kwargs)
|
||||
result = register_account_valuation(**args)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == error_code
|
||||
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
|
||||
@@ -1,102 +0,0 @@
|
||||
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 == "14"
|
||||
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
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.application.account_valuation_service import (
|
||||
list_account_valuations,
|
||||
register_account_valuation,
|
||||
)
|
||||
from jarca_ledger_mcp.application.assets_service import (
|
||||
create_manual_asset,
|
||||
list_manual_assets,
|
||||
preview_asset_snapshot_close,
|
||||
)
|
||||
from jarca_ledger_mcp.application.settlement_draft_service import create_settlement_draft
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def _tables(path):
|
||||
with sqlite3.connect(path) as conn:
|
||||
return {r[0] for r in conn.execute("select name from sqlite_master where type='table'")}
|
||||
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
# Sourced from schema.py plus migration-created settlement draft tables.
|
||||
"schema_meta",
|
||||
"accounts",
|
||||
"transactions",
|
||||
"budgets",
|
||||
"import_batches",
|
||||
"category_rules",
|
||||
"ledger_categories",
|
||||
|
||||
"account_snapshot",
|
||||
"settlement_cashflow_snapshots",
|
||||
"review_decisions",
|
||||
"reconciliation_sessions",
|
||||
"monthly_settlement",
|
||||
"settlement_drafts",
|
||||
"settlement_draft_items",
|
||||
}
|
||||
|
||||
|
||||
def test_fresh_init_has_no_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 tables == EXPECTED_TABLES
|
||||
|
||||
|
||||
def test_init_is_idempotent(tmp_path):
|
||||
path = tmp_path / "repeat.sqlite3"
|
||||
assert init_db(str(path)) == str(path)
|
||||
assert init_db(str(path)) == str(path)
|
||||
assert _tables(path) == EXPECTED_TABLES
|
||||
|
||||
|
||||
def test_legacy_nonempty_account_valuations_row_is_dropped_only(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 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
|
||||
|
||||
|
||||
def test_unsupported_valuation_operations_do_not_create_or_access_db(tmp_path):
|
||||
path = tmp_path / "must-not-exist.sqlite3"
|
||||
result = register_account_valuation("acct", "2026-09-30", 1, db_path=str(path))
|
||||
listed = list_account_valuations("acct", db_path=str(path))
|
||||
assert result["success"] is False and result["error_code"] == "unsupported"
|
||||
assert listed["success"] is False and listed["error_code"] == "unsupported"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_active_account_preview_reports_missing_without_sql_error(tmp_path):
|
||||
path = tmp_path / "preview.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
|
||||
conn.commit()
|
||||
result = preview_asset_snapshot_close("2026-09-30", db_path=str(path))
|
||||
assert result["success"] is True
|
||||
assert result["close_status"] in {"incomplete_draft", "complete_exact"}
|
||||
assert not any(w.get("code") == "db_error" for w in result["warnings"])
|
||||
|
||||
|
||||
def test_active_account_draft_returns_review_blocker_without_sql_error(tmp_path):
|
||||
path = tmp_path / "draft.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("insert into accounts (id,name,type,active,created_at,updated_at) values ('acct-1','Active','bank',1,'t','t')")
|
||||
conn.commit()
|
||||
result = create_settlement_draft("2026-09", "tester", "draft-key", db_path=str(path))
|
||||
assert result["success"] is True
|
||||
assert result.get("review", {}).get("approval_required") is True
|
||||
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):
|
||||
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"}
|
||||
@@ -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 asset_snapshots").fetchone()[0]
|
||||
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
before_runs = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||
replay = service.close_asset_snapshot(
|
||||
"2026-06-30", idempotency_key="candidate-close-a", db_path=db_path,
|
||||
)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
after_replay = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
|
||||
before_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0] == before_rows
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
|
||||
before_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").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 asset_snapshots").fetchone()[0]
|
||||
after_replay = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
|
||||
assert replay == first
|
||||
assert replay["snapshot_run_id"] == first["snapshot_run_id"]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.application.account_valuation_service import list_account_valuations, register_account_valuation
|
||||
from jarca_ledger_mcp.application.assets_service import close_asset_snapshot, get_asset_snapshot_report
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def tables(path):
|
||||
with sqlite3.connect(path) as c:
|
||||
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):
|
||||
path = tmp_path / "fresh.sqlite3"
|
||||
init_db(str(path))
|
||||
assert "asset_snapshot_runs" not in tables(path)
|
||||
|
||||
|
||||
def test_init_twice_is_idempotent_and_schema16(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] == "16"
|
||||
assert "asset_snapshot_runs" not 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 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.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 "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"] == "unsupported"
|
||||
assert list_account_valuations("a", db_path=str(path))["error_code"] == "unsupported"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_finalize_and_close_remain_unsupported_without_db_access(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 not path.exists()
|
||||
|
||||
|
||||
def test_monthly_overview_keeps_real_parent_row(tmp_path):
|
||||
path = tmp_path / "monthly.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("insert into monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) values (?,?,?,?,?,?,?,?,?)", ('m-1','2026-09','2026-09-30','draft',1,'test','t','test','t'))
|
||||
c.commit()
|
||||
result = list_monthly_settlement_overview(db_path=str(path))
|
||||
assert result["success"] and result["months"][0]["settlement_id"] == "m-1"
|
||||
assert result["months"][0]["income_total"] is None
|
||||
@@ -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(asset_snapshots)")}
|
||||
snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(account_snapshot)")}
|
||||
|
||||
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", "snapshot_run_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", "monthly_settlement_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("asset_snapshots", 0)
|
||||
before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 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("asset_snapshots", 0)
|
||||
after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("account_snapshot", 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 asset_snapshots WHERE basis_date='2026-06-30'").fetchone()[0]
|
||||
row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot 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":"asset_snapshots", "cashflow":"settlement_cashflow_snapshots", "completion":"settlement_drafts"}[failure_point]
|
||||
target = {"header":"asset_snapshot_runs", "assets":"account_snapshot", "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 asset_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM account_snapshot").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", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
for table in ("asset_snapshot_runs", "account_snapshot", "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"
|
||||
|
||||
@@ -368,7 +368,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 asset_snapshots BEGIN UPDATE asset_snapshots SET {column}={expression} WHERE id=NEW.id; END"
|
||||
trigger = f"CREATE TRIGGER synthetic_tamper AFTER INSERT ON account_snapshot BEGIN UPDATE account_snapshot 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 +377,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", "asset_snapshots", "settlement_cashflow_snapshots"):
|
||||
for table in ("asset_snapshot_runs", "account_snapshot", "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,7 +672,12 @@ 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:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
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"
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -283,6 +283,8 @@ def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approv
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
f"import streamlit as st\n"
|
||||
f"st.query_params.update(page='월정산', month='2026-09')\n"
|
||||
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
f"page_monthly_settlement({path!r})", default_timeout=30).run()
|
||||
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
|
||||
@@ -368,6 +370,8 @@ def test_saved_monthly_snapshot_renders_close_legacy_and_asset_evidence(monkeypa
|
||||
monkeypatch.setattr(settlement_gateway, "get_monthly_settlement_snapshot", lambda *a, **k: {"success": True, **snapshot})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
"import streamlit as st\n"
|
||||
"st.query_params.update(page='월정산', month='2026-09')\n"
|
||||
"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
"page_monthly_settlement('synthetic-unused.sqlite3')", default_timeout=30)
|
||||
app.run()
|
||||
|
||||
@@ -90,8 +90,8 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
|
||||
assert "상태: 정상" in body_text
|
||||
assert "자산 상세: 경고 3건 / 제외 1건" in body_text
|
||||
assert "월정산 결과 / Evidence" in body_text
|
||||
assert "읽기 전용 근거 요약" in body_text
|
||||
assert "이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다" in body_text
|
||||
assert "근거 요약" in body_text
|
||||
assert "정산 전 운영 블로커 점검" in body_text
|
||||
assert "다음 액션" in body_text
|
||||
assert "예산 조정 후보" in body_text
|
||||
assert "분류/검토 규칙 후보" in body_text
|
||||
@@ -120,7 +120,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
|
||||
assert "선택 월: 2026-06" in body_text
|
||||
assert "기준일: 2026-06-30" in body_text
|
||||
assert "기준 시각" in body_text
|
||||
assert "모드: 읽기 전용" in body_text
|
||||
assert "확인필요 큐로 이동" in body_text
|
||||
assert "계좌 화면에서 잔액대조 확인" in body_text
|
||||
assert "Import 화면에서 이력 확인" in body_text
|
||||
@@ -129,7 +128,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
|
||||
assert ".local/share/jarca/uploads" not in body_text
|
||||
assert "/home/" not in body_text
|
||||
assert "raw_payload" not in body_text
|
||||
assert "🔒 읽기 전용" in body_text
|
||||
|
||||
|
||||
def _dashboard_body(at):
|
||||
|
||||
@@ -43,6 +43,18 @@ def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
|
||||
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
|
||||
|
||||
|
||||
def test_sidebar_monthly_settlement_serializes_canonical_page(monkeypatch):
|
||||
query = {"page": "요약", "month": "2026-06"}
|
||||
state = {"dashboard_page": "월정산"}
|
||||
callback = {}
|
||||
monkeypatch.setattr(common.st, "query_params", query)
|
||||
monkeypatch.setattr(common.st, "session_state", state)
|
||||
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
|
||||
common.render_sidebar_nav(PAGES, "월정산")
|
||||
callback["fn"]()
|
||||
assert query == {"page": "monthly_settlement", "month": "2026-06"}
|
||||
|
||||
|
||||
def test_missing_or_unknown_page_falls_back_to_overview():
|
||||
assert common.validate_dashboard_page(None, PAGES) == "요약"
|
||||
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
|
||||
|
||||
@@ -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 asset_snapshots (
|
||||
CREATE TABLE account_snapshot (
|
||||
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 asset_snapshots(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
|
||||
INSERT INTO account_snapshot(id,basis_date,source_type,name,amount,created_at) VALUES('s','2026-01-01','account','old',10,'t');
|
||||
""")
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT name FROM accounts WHERE id='a'").fetchone()
|
||||
assert conn.execute("SELECT id FROM transactions WHERE id='t'").fetchone()
|
||||
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
|
||||
assert conn.execute("SELECT name FROM account_snapshot 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] == "14"
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
|
||||
|
||||
|
||||
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
|
||||
|
||||
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
|
||||
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
health = service.health_check(db_path)
|
||||
assert health["schema_version"] == "14"
|
||||
assert health["schema_version"] == "15"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
|
||||
@@ -16,10 +16,22 @@ 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:
|
||||
# These are schema-11 close rows after the additive schema-12 migration:
|
||||
# run identity is absent, but row close/correction fields remain frozen.
|
||||
# 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
|
||||
)""")
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots
|
||||
"""INSERT INTO account_snapshot
|
||||
(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)
|
||||
@@ -32,7 +44,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").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
|
||||
return path, asset["id"]
|
||||
|
||||
|
||||
@@ -102,6 +114,18 @@ 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),
|
||||
@@ -110,7 +134,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 asset_snapshots
|
||||
"""INSERT INTO account_snapshot
|
||||
(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,
|
||||
@@ -124,7 +148,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").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs WHERE close_status <> 'legacy_read_only'").fetchone()[0] == 0
|
||||
|
||||
before = hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
report = service.get_asset_snapshot_report(BASIS, db_path=path)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.application.accounts_write_services import create_ledger_account
|
||||
from jarca_ledger_mcp.application.assets_service import (
|
||||
create_manual_asset,
|
||||
list_manual_assets,
|
||||
preview_asset_snapshot_close,
|
||||
)
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def tables(path):
|
||||
with sqlite3.connect(path) as conn:
|
||||
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):
|
||||
path = str(tmp_path / "fresh.sqlite3")
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
assert "manual_assets" not in tables(path)
|
||||
|
||||
|
||||
def test_existing_legacy_manual_assets_is_dropped_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 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)
|
||||
|
||||
|
||||
def test_manual_asset_public_operations_are_unsupported(tmp_path):
|
||||
path = str(tmp_path / "unsupported.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
|
||||
|
||||
|
||||
def test_account_real_estate_creation_and_snapshot_preview_use_normal_path(tmp_path):
|
||||
path = str(tmp_path / "real-estate.sqlite3")
|
||||
init_db(path)
|
||||
created = create_ledger_account(
|
||||
"House", "other", liquidity_bucket="real_estate", last_balance=500000,
|
||||
last_balance_basis_date="2026-09-30", db_path=path,
|
||||
)
|
||||
assert created["success"] is True
|
||||
assert created["account"]["liquidity_bucket"] == "real_estate"
|
||||
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):
|
||||
path = str(tmp_path / "accounts.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
before = conn.execute("select count(*) from accounts").fetchone()[0]
|
||||
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)
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def _insert_sql():
|
||||
return """INSERT INTO monthly_settlement
|
||||
(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, 'draft', 1, 'tester', '2026-10-15T00:00:00+09:00', 'tester', '2026-10-15T00:00:00+09:00')"""
|
||||
|
||||
|
||||
def test_fresh_db_has_month_settlement_schema_16_without_latest_run_id(tmp_path):
|
||||
path = str(tmp_path / "fresh.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
|
||||
assert conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='monthly_settlement'").fetchone()[0] == 1
|
||||
columns = {row[1]: row for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
|
||||
assert columns["approval_flags_json"][4] == "'{}'"
|
||||
assert columns["memo"][4] == "''"
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(monthly_settlement)")}
|
||||
assert "latest_run_id" not in columns
|
||||
assert conn.execute("PRAGMA foreign_key_list(monthly_settlement)").fetchall() == []
|
||||
conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
|
||||
row = conn.execute("SELECT memo, approval_flags_json FROM monthly_settlement").fetchone()
|
||||
assert row == ("", "{}")
|
||||
|
||||
|
||||
def test_migration_is_idempotent_and_preserves_legacy_drafts(tmp_path):
|
||||
path = str(tmp_path / "existing.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO settlement_drafts (id, month, basis_date, status, version, created_by, created_at, updated_at, create_key) VALUES ('draft-1','2026-09','2026-09-30','active',1,'u','t','t','k')")
|
||||
before = conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall()
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT id, month, basis_date, status FROM settlement_drafts").fetchall() == before
|
||||
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):
|
||||
path = str(tmp_path / "schema15.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
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')""")
|
||||
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)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "16"
|
||||
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() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column,value", [("month", "2026-13"), ("month", "202609"), ("basis_date", "2026-02-30"), ("version", 0)])
|
||||
def test_month_settlement_rejects_invalid_values(tmp_path, column, value):
|
||||
path = str(tmp_path / "invalid.sqlite3")
|
||||
init_db(path)
|
||||
values = {"month": "2026-09", "basis_date": "2026-09-30", "version": 1}
|
||||
values[column] = value
|
||||
with sqlite3.connect(path) as conn, pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement (id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
("bad", values["month"], values["basis_date"], "draft", values["version"], "u", "t", "u", "t"),
|
||||
)
|
||||
|
||||
|
||||
def test_status_condition_checks_and_month_uniqueness(tmp_path):
|
||||
path = str(tmp_path / "conditions.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(_insert_sql(), ("m1", "2026-09", "2026-09-30"))
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(_insert_sql(), ("m2", "2026-09", "2026-09-30"))
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("UPDATE monthly_settlement SET status='approved' WHERE id='m1'")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("UPDATE monthly_settlement SET status='cancelled' WHERE id='m1'")
|
||||
conn.execute("UPDATE monthly_settlement SET status='approved', approved_by='reviewer', approved_at='t', approved_version=1, approved_fingerprint='fp' WHERE id='m1'")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("UPDATE monthly_settlement SET status='closed' WHERE id='m1'")
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import (
|
||||
approve_monthly_settlement,
|
||||
close_monthly_settlement,
|
||||
create_monthly_settlement,
|
||||
review_monthly_settlement,
|
||||
)
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
from jarca_ledger_mcp.application.transaction_report_services import get_settlement_cashflow_snapshot
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def _db(tmp_path, rows=()):
|
||||
path = tmp_path / "monthly-cashflow-actual.sqlite3"
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as c:
|
||||
for row in rows:
|
||||
c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (*row, "2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z"))
|
||||
c.commit()
|
||||
return path
|
||||
|
||||
|
||||
def _row(i, typ="expense", amount=100, currency="KRW", category="Food"):
|
||||
return (i, "2026-10-10", typ, amount, currency, category, "Main", "acct")
|
||||
|
||||
|
||||
def _review_approve(path):
|
||||
s = create_monthly_settlement("2026-10", db_path=str(path)); sid = s["settlement_id"]
|
||||
reviewed = review_monthly_settlement(sid, db_path=str(path))
|
||||
assert reviewed["success"] and reviewed["version"] == 1 and reviewed["review_fingerprint"]
|
||||
approved = approve_monthly_settlement(sid, 1, reviewed["review_fingerprint"], db_path=str(path))
|
||||
assert approved["success"] and approved["status"] == "approved" and approved["version"] == 1
|
||||
return sid, reviewed, approved
|
||||
|
||||
|
||||
def test_gateway_cancels_approval_against_actual_temp_db(tmp_path):
|
||||
path = _db(tmp_path, [_row("t1")])
|
||||
sid, _, _ = _review_approve(path)
|
||||
result = settlement_gateway.cancel_monthly_settlement_approval(sid, 1, db_path=str(path))
|
||||
assert result == {"success": True, "settlement_id": sid, "status": "in_review", "version": 2}
|
||||
with sqlite3.connect(path) as c:
|
||||
row = c.execute("SELECT status, version, approved_by, approved_at, approved_cashflow_fingerprint FROM monthly_settlement WHERE id=?", (sid,)).fetchone()
|
||||
assert row == ("in_review", 2, None, None, None)
|
||||
|
||||
|
||||
def test_review_approve_close_persists_and_freezes_original_ledger(tmp_path):
|
||||
path = _db(tmp_path, [_row("t1")]); sid, reviewed, _ = _review_approve(path)
|
||||
closed = close_monthly_settlement(sid, 1, db_path=str(path))
|
||||
assert closed["success"] and closed["status"] == "closed" and closed["snapshot_count"] == 1
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("UPDATE transactions SET amount=999 WHERE id='t1'"); c.commit()
|
||||
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path))
|
||||
assert saved["saved"] and saved["categories"][0]["amount"] == 100
|
||||
|
||||
|
||||
def test_review_then_ledger_edit_rejects_approval(tmp_path):
|
||||
path = _db(tmp_path, [_row("t1")]); s = create_monthly_settlement("2026-10", db_path=str(path)); r = review_monthly_settlement(s["settlement_id"], db_path=str(path))
|
||||
with sqlite3.connect(path) as c: c.execute("UPDATE transactions SET amount=101 WHERE id='t1'"); c.commit()
|
||||
out = approve_monthly_settlement(s["settlement_id"], 1, r["review_fingerprint"], db_path=str(path))
|
||||
assert not out["success"] and out["error_code"] == "stale_review"
|
||||
|
||||
|
||||
def test_approve_then_offset_transaction_rejects_close_even_same_total(tmp_path):
|
||||
path = _db(tmp_path, [_row("t1", amount=100)]); sid, _, _ = _review_approve(path)
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("UPDATE transactions SET amount=50 WHERE id='t1'")
|
||||
c.execute("INSERT INTO transactions(id,transaction_date,type,amount,currency,category,subcategory,account_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", _row("t2", amount=50) + ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z")); c.commit()
|
||||
out = close_monthly_settlement(sid, 1, db_path=str(path))
|
||||
assert not out["success"] and out["error_code"] == "stale_cashflow"
|
||||
|
||||
|
||||
def test_null_fingerprint_close_rejects(tmp_path):
|
||||
path = _db(tmp_path); sid, _, _ = _review_approve(path)
|
||||
with sqlite3.connect(path) as c: c.execute("UPDATE monthly_settlement SET approved_cashflow_fingerprint=NULL WHERE id=?", (sid,)); c.commit()
|
||||
out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"] and out["error_code"] == "missing_fingerprint"
|
||||
|
||||
|
||||
def test_close_snapshot_insert_failure_rolls_back_status(tmp_path, monkeypatch):
|
||||
path = _db(tmp_path, [_row("t1")]); sid, _, _ = _review_approve(path)
|
||||
with sqlite3.connect(path) as c: c.execute("CREATE TRIGGER fail_snapshot BEFORE INSERT ON settlement_cashflow_snapshots BEGIN SELECT RAISE(ABORT,'boom'); END;") ; c.commit()
|
||||
out = close_monthly_settlement(sid, 1, db_path=str(path)); assert not out["success"]
|
||||
with sqlite3.connect(path) as c: assert c.execute("SELECT status FROM monthly_settlement WHERE id=?", (sid,)).fetchone()[0] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("currency", ["KRW", "USD"])
|
||||
def test_multifx_rows_remain_separate(tmp_path, currency):
|
||||
path = _db(tmp_path, [_row("a", amount=100, currency="KRW"), _row("b", amount=7, currency=currency)])
|
||||
sid, _, _ = _review_approve(path); assert close_monthly_settlement(sid, 1, db_path=str(path))["success"]
|
||||
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert {x["currency"] for x in saved["categories"]} >= {"KRW", currency}
|
||||
|
||||
|
||||
def test_empty_close_is_saved_without_fake_marker(tmp_path):
|
||||
path = _db(tmp_path); sid, _, _ = _review_approve(path); closed = close_monthly_settlement(sid, 1, db_path=str(path)); assert closed["snapshot_count"] == 0
|
||||
saved = get_settlement_cashflow_snapshot(sid, db_path=str(path)); assert saved["saved"] and saved["categories"] == []
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
FIELD = "approved_cashflow_fingerprint"
|
||||
|
||||
|
||||
def _column(path: str) -> tuple:
|
||||
with sqlite3.connect(path) as conn:
|
||||
return next(row for row in conn.execute("PRAGMA table_info(monthly_settlement)") if row[1] == FIELD)
|
||||
|
||||
|
||||
def test_fresh_schema_has_nullable_text_field_without_default(tmp_path):
|
||||
path = str(tmp_path / "fresh.sqlite3")
|
||||
init_db(path)
|
||||
column = _column(path)
|
||||
assert column[2].upper() == "TEXT"
|
||||
assert column[3] == 0
|
||||
assert column[4] is None
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement "
|
||||
"(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
|
||||
"VALUES (?, ?, ?, 'draft', 1, 'tester', 'now', 'tester', 'now')",
|
||||
("m1", "2026-09", "2026-09-30"),
|
||||
)
|
||||
assert conn.execute(f"SELECT {FIELD} FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
|
||||
|
||||
|
||||
def test_same_version_existing_db_upgrade_is_idempotent_and_preserves_rows(tmp_path):
|
||||
path = str(tmp_path / "existing.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement "
|
||||
"(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at) "
|
||||
"VALUES ('m1', '2026-09', '2026-09-30', 'draft', 1, 'tester', 'now', 'tester', 'now')"
|
||||
)
|
||||
conn.execute(f"UPDATE monthly_settlement SET {FIELD}='cashflow-fp' WHERE id='m1'")
|
||||
before = conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall()
|
||||
before_tables = 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()
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
assert conn.execute("SELECT id, month, status, version, memo, approved_fingerprint, approved_cashflow_fingerprint FROM monthly_settlement").fetchall() == before
|
||||
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):
|
||||
path = str(tmp_path / "legacy16.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("ALTER TABLE monthly_settlement RENAME TO monthly_settlement_without_fingerprint")
|
||||
conn.execute(
|
||||
"""CREATE TABLE monthly_settlement (
|
||||
id TEXT PRIMARY KEY, month TEXT NOT NULL UNIQUE, basis_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL, version INTEGER NOT NULL, memo TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_by TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL, reviewed_by TEXT, reviewed_at TEXT, review_fingerprint TEXT,
|
||||
approved_by TEXT, approved_at TEXT, approved_version INTEGER, approved_fingerprint TEXT,
|
||||
approval_reason TEXT, approval_flags_json TEXT NOT NULL DEFAULT '{}', closed_by TEXT,
|
||||
closed_at TEXT, cancelled_by TEXT, cancelled_at TEXT, cancellation_reason TEXT
|
||||
)"""
|
||||
)
|
||||
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 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')"
|
||||
)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15"
|
||||
assert _column(path)[2].upper() == "TEXT"
|
||||
assert conn.execute("SELECT approved_cashflow_fingerprint FROM monthly_settlement WHERE id='m1'").fetchone() == (None,)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import create_monthly_settlement
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
|
||||
def test_create_monthly_settlement_persists_canonical_draft_and_rejects_duplicate(tmp_path):
|
||||
path = tmp_path / "ledger.sqlite3"
|
||||
init_db(str(path))
|
||||
|
||||
created = create_monthly_settlement("2026-10", db_path=str(path))
|
||||
assert created["success"]
|
||||
assert created["month"] == "2026-10"
|
||||
assert created["status"] == "draft"
|
||||
assert created["settlement_id"]
|
||||
|
||||
with sqlite3.connect(path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, month, basis_date, status FROM monthly_settlement WHERE id=?",
|
||||
(created["settlement_id"],),
|
||||
).fetchone()
|
||||
assert row == (created["settlement_id"], "2026-10", "2026-10-31", "draft")
|
||||
|
||||
duplicate = create_monthly_settlement("2026-10", db_path=str(path))
|
||||
assert duplicate["success"] is False
|
||||
assert duplicate["error_code"] == "duplicate_month"
|
||||
assert duplicate["settlement_id"] == created["settlement_id"]
|
||||
@@ -61,7 +61,7 @@ def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tm
|
||||
assert isinstance(result["review"], dict)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").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))
|
||||
@@ -216,7 +216,7 @@ def test_finalize_persists_immutable_provenance_replays_exact_response_and_confl
|
||||
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]
|
||||
row_count = conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0]
|
||||
assert run_count == 1 and row_count >= 1
|
||||
assert final["draft_id"] == draft["id"]
|
||||
assert final["draft_version"] == draft["version"]
|
||||
@@ -236,12 +236,12 @@ 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 asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
|
||||
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON account_snapshot 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 asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM account_snapshot").fetchone()[0] == 0
|
||||
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert after["draft"]["status"] == "active"
|
||||
|
||||
@@ -255,8 +255,8 @@ def test_schema13_migration_is_additive_and_idempotent(tmp_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] == '14'
|
||||
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','account_snapshot'}
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '16'
|
||||
|
||||
|
||||
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import (
|
||||
get_monthly_settlement_history_record,
|
||||
list_monthly_settlement_history,
|
||||
)
|
||||
|
||||
|
||||
def test_monthly_history_lists_close_and_correction_and_reads_one_record(tmp_path: Path):
|
||||
db = tmp_path / "history.sqlite3"
|
||||
init_db(str(db))
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshot_runs
|
||||
(id,basis_date,close_status,correction_of_run_id,correction_reason,
|
||||
created_at,settlement_month,draft_version,approved_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
("run-1", "2026-09-30", "complete_exact", "", "",
|
||||
"2026-10-01T00:00:00Z", "2026-09", 1, "2026-10-01T00:00:00Z"),
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshot_runs
|
||||
(id,basis_date,close_status,correction_of_run_id,correction_reason,
|
||||
created_at,settlement_month,draft_version,approved_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
("run-2", "2026-09-30", "conditional", "run-1", "누락 거래 반영",
|
||||
"2026-10-02T00:00:00Z", "2026-09", 2, "2026-10-02T00:00:00Z"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
listed = list_monthly_settlement_history("2026-09", db_path=str(db))
|
||||
assert listed["success"]
|
||||
assert [row["run_id"] for row in listed["history"]] == ["run-2", "run-1"]
|
||||
correction = listed["history"][0]
|
||||
assert correction["version"] == 2
|
||||
assert correction["correction_target"] == "run-1"
|
||||
assert correction["reason"] == "누락 거래 반영"
|
||||
|
||||
one = get_monthly_settlement_history_record("run-2", db_path=str(db))
|
||||
assert one["success"]
|
||||
assert one["history"]["run_id"] == "run-2"
|
||||
assert one["history"]["closed_at"] == "2026-10-02T00:00:00Z"
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from dashboard.page_views import monthly_settlement as view
|
||||
|
||||
|
||||
class _FakeStreamlit:
|
||||
def __init__(self, query=None):
|
||||
self.query_params = dict(query or {})
|
||||
self.session_state = {}
|
||||
self.dataframes = []
|
||||
self.errors = []
|
||||
self.headers = []
|
||||
self.titles = []
|
||||
self.buttons = []
|
||||
self.markdowns = []
|
||||
|
||||
def header(self, value):
|
||||
self.headers.append(value)
|
||||
|
||||
def title(self, value):
|
||||
self.titles.append(value)
|
||||
|
||||
def caption(self, value):
|
||||
pass
|
||||
|
||||
def markdown(self, value, **kwargs):
|
||||
self.markdowns.append(value)
|
||||
|
||||
def write(self, value):
|
||||
self.markdowns.append(str(value))
|
||||
|
||||
def selectbox(self, label, options, **kwargs):
|
||||
return options[0]
|
||||
|
||||
def info(self, value):
|
||||
pass
|
||||
|
||||
def error(self, value):
|
||||
self.errors.append(value)
|
||||
|
||||
def dataframe(self, value, **kwargs):
|
||||
self.dataframes.append((value, kwargs))
|
||||
|
||||
class _Column:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def button(self, label, **kwargs):
|
||||
return False
|
||||
|
||||
def popover(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def columns(self, spec):
|
||||
return [self._Column() for _ in spec]
|
||||
|
||||
def container(self, **kwargs):
|
||||
return self._Column()
|
||||
|
||||
def button(self, label, **kwargs):
|
||||
self.buttons.append((label, kwargs))
|
||||
return False
|
||||
|
||||
def popover(self, *args, **kwargs):
|
||||
return self._Column()
|
||||
|
||||
def rerun(self):
|
||||
return None
|
||||
|
||||
|
||||
def _fixture_db(tmp_path):
|
||||
path = tmp_path / "monthly-navigation.sqlite3"
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO monthly_settlement
|
||||
(id, month, basis_date, status, version, created_by, created_at, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
("settlement-2026-09", "2026-09", "2026-09-30", "draft", 1, "test", "2026-09-30T00:00:00Z", "test", "2026-09-30T00:00:00Z"),
|
||||
)
|
||||
conn.commit()
|
||||
return path
|
||||
|
||||
|
||||
def test_month_text_link_uses_exact_canonical_id_and_direct_detail_reads_month(tmp_path, monkeypatch):
|
||||
db_path = _fixture_db(tmp_path)
|
||||
fake = _FakeStreamlit()
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
|
||||
"success": True,
|
||||
"months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안", "income": 100}],
|
||||
})
|
||||
|
||||
view._render_monthly_list(str(db_path))
|
||||
month_buttons = [item for item in fake.buttons if item[0] == "2026-09"]
|
||||
assert len(month_buttons) == 1
|
||||
assert month_buttons[0][1]["type"] == "tertiary"
|
||||
assert month_buttons[0][1]["on_click"] is view._set_monthly_detail
|
||||
assert month_buttons[0][1]["args"] == ("settlement-2026-09", "전체", "전체")
|
||||
assert "?page=monthly_settlement" not in "\n".join(fake.markdowns)
|
||||
month_buttons[0][1]["on_click"](*month_buttons[0][1]["args"])
|
||||
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
|
||||
assert not fake.dataframes
|
||||
|
||||
detail_fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09"})
|
||||
monkeypatch.setattr(view, "st", detail_fake)
|
||||
calls = []
|
||||
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: calls.append((db, month)))
|
||||
view.page_monthly_settlement(str(db_path))
|
||||
assert calls == [(str(db_path), "2026-09")]
|
||||
assert detail_fake.titles == ["월정산 상세 · 2026-09"]
|
||||
assert [label for label, _ in detail_fake.buttons] == ["← 월정산 목록", "검토승인"]
|
||||
|
||||
|
||||
def test_unregistered_month_selection_does_not_create_none_link(monkeypatch):
|
||||
fake = _FakeStreamlit()
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
|
||||
"success": True,
|
||||
"months": [{"settlement_id": None, "month": "2026-08", "status_label": "정산 미생성·상세 선택 불가·현재집계(미확정)"}],
|
||||
})
|
||||
view._render_monthly_list("fixture.sqlite3")
|
||||
rendered = "\n".join(fake.markdowns)
|
||||
assert "2026-08" in rendered
|
||||
assert "[2026-08]" not in rendered
|
||||
assert "?page=monthly_settlement&id=None" not in rendered
|
||||
|
||||
|
||||
def test_direct_reload_unknown_id_does_not_fallback_to_month_or_list(tmp_path, monkeypatch):
|
||||
db_path = _fixture_db(tmp_path)
|
||||
fake = _FakeStreamlit({"page": "monthly_settlement", "id": "unknown-id", "month": "2026-09"})
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
calls = []
|
||||
monkeypatch.setattr(view, "_render_monthly_list", lambda db: calls.append(("list", db)))
|
||||
view.page_monthly_settlement(str(db_path))
|
||||
assert calls == []
|
||||
assert fake.errors == ["월정산을 찾을 수 없습니다."]
|
||||
|
||||
|
||||
def test_legacy_month_query_rewrites_to_canonical_id(tmp_path, monkeypatch):
|
||||
db_path = _fixture_db(tmp_path)
|
||||
fake = _FakeStreamlit({"page": "monthly_settlement", "month": "2026-09"})
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {
|
||||
"success": True,
|
||||
"months": [{"settlement_id": "settlement-2026-09", "month": "2026-09", "status_label": "초안"}],
|
||||
})
|
||||
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: None)
|
||||
view.page_monthly_settlement(str(db_path))
|
||||
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-09"}
|
||||
|
||||
|
||||
def test_back_to_list_uses_canonical_page_and_preserves_filters(monkeypatch):
|
||||
fake = _FakeStreamlit({"page": "monthly_settlement", "id": "settlement-2026-09", "year": "2026", "status": "미마감"})
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
view._clear_monthly_detail("2026", "미마감")
|
||||
assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
|
||||
assert fake.session_state["dashboard_page"] == "월정산"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_add_button_opens_dialog_and_navigates_to_created_canonical_id(monkeypatch):
|
||||
class DialogFake(_FakeStreamlit):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.reruns = 0
|
||||
|
||||
def dialog(self, title):
|
||||
assert title == "월정산 추가"
|
||||
return lambda fn: fn
|
||||
|
||||
def text_input(self, label, **kwargs):
|
||||
assert label == "대상 월"
|
||||
return "2026-10"
|
||||
|
||||
def button(self, label, **kwargs):
|
||||
self.buttons.append((label, kwargs))
|
||||
return label in {"+", "추가"}
|
||||
|
||||
def rerun(self):
|
||||
self.reruns += 1
|
||||
|
||||
fake = DialogFake()
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
monkeypatch.setattr(view.overview_gateway, "list_monthly_settlement_overview", lambda **kwargs: {"success": True, "months": []})
|
||||
monkeypatch.setattr(view.settlement_gateway, "create_monthly_settlement", lambda month, **kwargs: {"success": True, "settlement_id": "settlement-2026-10"})
|
||||
|
||||
view._render_monthly_list("fixture.sqlite3")
|
||||
|
||||
assert fake.query_params == {"page": "monthly_settlement", "id": "settlement-2026-10"}
|
||||
assert fake.reruns == 1
|
||||
|
||||
|
||||
def test_month_link_target_contains_no_write_operation():
|
||||
source = view._render_monthly_list.__code__.co_names
|
||||
assert "create_settlement_draft" not in source
|
||||
assert "finalize_settlement_draft" not in source
|
||||
|
||||
|
||||
def test_back_button_single_click_clears_detail_query_and_reruns(monkeypatch):
|
||||
class ClickFake(_FakeStreamlit):
|
||||
def __init__(self):
|
||||
super().__init__({"page": "monthly_settlement", "id": "settlement-2026-09", "month": "2026-09", "year": "2026", "status": "미마감"})
|
||||
self.session_state.update({"dashboard_page": "월정산", "monthly_settlement_detail_id": "settlement-2026-09", "monthly_settlement_detail_month": "2026-09"})
|
||||
self.reruns = 0
|
||||
|
||||
def rerun(self):
|
||||
self.reruns += 1
|
||||
|
||||
fake = ClickFake()
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
|
||||
view._clear_monthly_detail("2026", "미마감")
|
||||
|
||||
assert fake.reruns == 1
|
||||
assert fake.query_params == {"page": "monthly_settlement", "year": "2026", "status": "미마감"}
|
||||
assert "monthly_settlement_detail_id" not in fake.session_state
|
||||
assert "monthly_settlement_detail_month" not in fake.session_state
|
||||
|
||||
|
||||
def test_next_rerun_without_detail_query_does_not_restore_detail(monkeypatch):
|
||||
fake = _FakeStreamlit({"page": "monthly_settlement", "year": "2026", "status": "미마감"})
|
||||
fake.session_state.update({"dashboard_page": "월정산"})
|
||||
monkeypatch.setattr(view, "st", fake)
|
||||
rendered = []
|
||||
monkeypatch.setattr(view, "_render_monthly_list", lambda db: rendered.append(db))
|
||||
monkeypatch.setattr(view, "_render_monthly_detail", lambda db, month: (_ for _ in ()).throw(AssertionError("detail restored")))
|
||||
|
||||
view.page_monthly_settlement("fixture.sqlite3")
|
||||
|
||||
assert rendered == ["fixture.sqlite3"]
|
||||
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.application.monthly_settlement_service import list_monthly_settlement_overview
|
||||
|
||||
|
||||
def test_monthly_overview_excludes_transaction_only_months(tmp_path: Path):
|
||||
db = tmp_path / "fixture.sqlite3"
|
||||
init_db(str(db))
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
|
||||
("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
|
||||
)
|
||||
conn.commit()
|
||||
result = list_monthly_settlement_overview(db_path=str(db))
|
||||
assert result["success"]
|
||||
assert result["months"] == []
|
||||
|
||||
|
||||
def test_monthly_overview_keeps_existing_parent_and_joins_live_transaction_totals(tmp_path: Path):
|
||||
db = tmp_path / "fixture.sqlite3"
|
||||
init_db(str(db))
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO transactions(id,transaction_date,type,amount,created_at,updated_at) VALUES (?,?,?,?,?,?)",
|
||||
("tx-1", "2026-09-03", "income", 100, "2026-09-03T00:00:00Z", "2026-09-03T00:00:00Z"),
|
||||
)
|
||||
conn.commit()
|
||||
result = list_monthly_settlement_overview(db_path=str(db))
|
||||
assert result["success"]
|
||||
assert len(result["months"]) == 1
|
||||
row = result["months"][0]
|
||||
assert row["month"] == "2026-09"
|
||||
assert row["settlement_id"] == "settlement-1"
|
||||
assert row["income"] == 100
|
||||
assert row["net_cashflow_display"] is None
|
||||
assert row["net_worth_display"] is None
|
||||
|
||||
|
||||
def test_monthly_overview_keeps_parent_without_transactions(tmp_path: Path):
|
||||
db = tmp_path / "fixture.sqlite3"
|
||||
init_db(str(db))
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
("settlement-1", "2026-09", "2026-09-30", "draft", 1, "test", "now", "test", "now"),
|
||||
)
|
||||
conn.commit()
|
||||
row = list_monthly_settlement_overview(db_path=str(db))["months"][0]
|
||||
assert row["settlement_id"] == "settlement-1"
|
||||
assert row["income"] == 0
|
||||
assert row["expense"] == 0
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.adapters.mcp import server
|
||||
|
||||
|
||||
def test_create_real_estate_defaults_to_real_estate_bucket(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
result = service.create_ledger_account("아파트", type="real_estate", db_path=db_path)
|
||||
assert result["success"] is True
|
||||
assert result["account"]["type"] == "real_estate"
|
||||
assert result["account"]["liquidity_bucket"] == "real_estate"
|
||||
|
||||
|
||||
def test_update_other_to_real_estate_preserves_explicit_bucket(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account("자산", type="other", db_path=db_path)
|
||||
updated = service.update_ledger_account(
|
||||
created["account"]["id"], type="real_estate", db_path=db_path
|
||||
)
|
||||
assert updated["success"] is True
|
||||
assert updated["account"]["type"] == "real_estate"
|
||||
assert updated["account"]["liquidity_bucket"] == "other"
|
||||
|
||||
|
||||
def test_real_estate_appears_in_list_and_asset_preview(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account(
|
||||
"주택", type="real_estate", last_balance=500_000_000, db_path=db_path
|
||||
)
|
||||
listed = service.list_ledger_accounts(db_path=db_path)
|
||||
assert listed["success"] is True
|
||||
assert listed["accounts"][0]["liquidity_bucket"] == "real_estate"
|
||||
preview = service.preview_asset_snapshot_close("2026-10-16", db_path=db_path)
|
||||
assert preview["success"] is True
|
||||
assert any(
|
||||
item["source_id"] == created["account"]["id"]
|
||||
and item["liquidity_bucket"] == "real_estate"
|
||||
for item in preview["included_items"]
|
||||
)
|
||||
|
||||
|
||||
def test_mcp_exposes_real_estate_type_in_create_and_update_schema():
|
||||
create_annotation = inspect.signature(server.create_ledger_account).parameters["type"].annotation
|
||||
update_annotation = inspect.signature(server.update_ledger_account).parameters["type"].annotation
|
||||
assert "real_estate" in str(create_annotation)
|
||||
assert "real_estate" in str(update_annotation)
|
||||
|
||||
|
||||
def test_unknown_account_type_remains_rejected(tmp_path):
|
||||
result = service.create_ledger_account(
|
||||
"알 수 없음", type="property", db_path=str(tmp_path / "ledger.sqlite3")
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["error_code"] == "validation_error"
|
||||
@@ -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_asset_snapshots.py",
|
||||
"tests/test_account_snapshot.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"] == "14"
|
||||
assert service.health_check(db_path)["schema_version"] == "16"
|
||||
|
||||
|
||||
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp.infrastructure.sqlite import migrations
|
||||
from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "schema_v14.sql"
|
||||
|
||||
|
||||
def _v14_db(path: Path) -> None:
|
||||
with sqlite3.connect(path) as db:
|
||||
db.executescript(FIXTURE.read_text())
|
||||
db.execute("INSERT INTO accounts VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ("a1", "Main", "bank", "Bank", "KRW", 1, "memo", "1234", "User", 100000, "2026-01-31", "", "ok", "cash", "2026-01-01", "2026-01-31"))
|
||||
db.execute("INSERT INTO transactions (id,transaction_date,type,amount,account_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", ("t1", "2026-01-15", "expense", -1200, "a1", "2026-01-15", "2026-01-15"))
|
||||
db.execute("INSERT INTO asset_snapshots (id,basis_date,source_type,source_id,name,amount,closed_at,created_at) VALUES (?,?,?,?,?,?,?,?)", ("s1", "2026-01-31", "account", "a1", "Main", 100000, "2026-01-31T10:00:00", "2026-01-31T10:00:00"))
|
||||
db.execute("INSERT INTO asset_snapshot_runs (id,basis_date,close_status,created_at) VALUES (?,?,?,?)", ("run1", "2026-01-31", "closed", "2026-01-31T10:00:00"))
|
||||
db.execute("INSERT OR REPLACE INTO schema_meta VALUES ('schema_version','14')")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _rows(path: Path, table: str):
|
||||
with sqlite3.connect(path) as db:
|
||||
return db.execute(f"SELECT * FROM {table} ORDER BY 1").fetchall()
|
||||
|
||||
|
||||
def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path):
|
||||
path = tmp_path / "ledger.sqlite3"
|
||||
_v14_db(path)
|
||||
before_accounts, before_tx = _rows(path, "accounts"), _rows(path, "transactions")
|
||||
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("PRAGMA foreign_key_check").fetchall() == []
|
||||
assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
|
||||
assert _rows(path, "accounts") == before_accounts
|
||||
assert _rows(path, "transactions") == before_tx
|
||||
|
||||
|
||||
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")
|
||||
init_db(str(path))
|
||||
assert _rows(path, "account_snapshot") == first
|
||||
|
||||
|
||||
def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch):
|
||||
path = tmp_path / "ledger.sqlite3"
|
||||
_v14_db(path)
|
||||
original = migrations.migrate_db
|
||||
def fail(conn):
|
||||
raise RuntimeError("injected")
|
||||
monkeypatch.setattr(migrations, "migrate_db", fail)
|
||||
with pytest.raises(RuntimeError, match="injected"):
|
||||
init_db(str(path))
|
||||
with sqlite3.connect(path) as db:
|
||||
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14"
|
||||
assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshots'").fetchone()
|
||||
assert not db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='account_snapshot'").fetchone()
|
||||
monkeypatch.setattr(migrations, "migrate_db", original)
|
||||
|
||||
|
||||
def test_new_init_has_same_contract(tmp_path):
|
||||
path = tmp_path / "new.sqlite3"
|
||||
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"
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")}
|
||||
assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols
|
||||
@@ -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"] == "14"
|
||||
assert result["schema_version"] == "16"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@ from jarca_ledger_mcp.db import init_db
|
||||
def _schema(path):
|
||||
with sqlite3.connect(path) as db:
|
||||
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
assert {"settlement_cashflow_snapshots", "asset_snapshot_runs"} <= tables
|
||||
header = {r[1] for r in db.execute("PRAGMA table_info(asset_snapshot_runs)")}
|
||||
assert {"settlement_cashflow_snapshots", "monthly_settlement"} <= tables
|
||||
detail = {r[1] for r in db.execute("PRAGMA table_info(settlement_cashflow_snapshots)")}
|
||||
assert {"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"} <= header
|
||||
assert {"id", "settlement_run_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
|
||||
assert {"id", "monthly_settlement_id", "cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency"} <= detail
|
||||
|
||||
|
||||
def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
|
||||
@@ -21,69 +19,33 @@ def test_cashflow_detail_constraints_preserve_blank_categories(tmp_path):
|
||||
_schema(path)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('run-a','2026-09-30','complete_exact','now')")
|
||||
insert = "INSERT INTO settlement_cashflow_snapshots(id,settlement_run_id,cashflow_kind,category,subcategory,amount,transaction_count,currency) VALUES(?,?,?,?,?,?,?,?)"
|
||||
good = ("d1", "run-a", "expense", "", "", -7, 1, "KRW")
|
||||
db.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES('month-a','2026-09','2026-09-30','draft',1,'test','now','test','now')")
|
||||
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")]:
|
||||
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, ("d10", "run-a", "income", "", "", 2, 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]
|
||||
assert any(x == ("settlement_run_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
|
||||
assert any(x == ("monthly_settlement_id", "cashflow_kind", "category", "subcategory", "currency") for x in unique_fields)
|
||||
assert db.execute("SELECT amount,category,subcategory FROM settlement_cashflow_snapshots WHERE id='d1'").fetchone() == (-7, "", "")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
db.execute("DELETE FROM asset_snapshot_runs WHERE id='run-a'")
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE settlement_run_id='run-a'").fetchone()[0] == 2
|
||||
db.execute("DELETE FROM monthly_settlement WHERE id='month-a'")
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots WHERE monthly_settlement_id='month-a'").fetchone()[0] == 2
|
||||
|
||||
|
||||
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');
|
||||
""")
|
||||
def test_init_is_idempotent_and_retains_schema_16_contract(tmp_path):
|
||||
path = str(tmp_path / "fresh.sqlite3")
|
||||
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()
|
||||
assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '16'
|
||||
assert not 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 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
|
||||
assert db.execute("SELECT COUNT(*) FROM settlement_cashflow_snapshots").fetchone()[0] == first
|
||||
|
||||
@@ -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 asset_snapshots').fetchone()[0] == 0
|
||||
assert conn.execute('SELECT COUNT(*) FROM account_snapshot').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', 'asset_snapshots'))
|
||||
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'account_snapshot'))
|
||||
|
||||
|
||||
@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 asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
|
||||
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON account_snapshot 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 asset_snapshots').fetchone()[0] == 0
|
||||
assert c.execute('SELECT COUNT(*) FROM account_snapshot').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 asset_snapshots').fetchone()[0] == final['inserted_count']
|
||||
assert c.execute('SELECT COUNT(*) FROM account_snapshot').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', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
|
||||
for table in ('asset_snapshot_runs', 'account_snapshot', '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)['asset_snapshots'] == original_rows['asset_snapshots']
|
||||
assert counts(path)['account_snapshot'] == original_rows['account_snapshot']
|
||||
|
||||
run_id = original['snapshot_run_id']
|
||||
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
|
||||
|
||||
@@ -26,7 +26,9 @@ def _app_wrapper(tmp_path: Path) -> Path:
|
||||
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
|
||||
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
|
||||
from streamlit.testing.v1 import AppTest
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
|
||||
app.session_state["authenticated_actor"] = "fixture-operator"
|
||||
app = app.run()
|
||||
assert not app.exception
|
||||
|
||||
|
||||
@@ -44,7 +46,9 @@ def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkey
|
||||
db_path = tmp_path / "dashboard-missing.sqlite3"
|
||||
# Explicit synthetic DB path and no authenticated actor: render only.
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
|
||||
app.session_state["authenticated_actor"] = "fixture-operator"
|
||||
app = app.run()
|
||||
assert not app.exception
|
||||
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
|
||||
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
|
||||
@@ -91,13 +95,16 @@ def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch
|
||||
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
|
||||
assert seeded.get("success"), seeded
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
|
||||
monkeypatch.setattr("dashboard.authentication.get_authenticated_actor", lambda: "fixture-operator")
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path)))
|
||||
app.session_state["authenticated_actor"] = "fixture-operator"
|
||||
app = app.run()
|
||||
assert not app.exception
|
||||
app.button[0].click().run()
|
||||
assert not app.exception
|
||||
assert app.button[0].label == "초안 생성 / 정정 초안"
|
||||
assert len(app.selectbox[0].options) == 1
|
||||
assert "active" in " ".join(item.value for item in app.caption)
|
||||
next(button for button in app.button if button.label == "초안 만들기").click().run()
|
||||
assert not app.exception
|
||||
assert any(button.label == "검토 / 새로고침 대상" for button in app.button)
|
||||
# Exercise read-only review through the rendered explicit button.
|
||||
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
|
||||
review.click().run(timeout=10)
|
||||
|
||||
@@ -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", "asset_snapshots",
|
||||
"retained", "account_valuations", "asset_snapshot_runs", "account_snapshot",
|
||||
"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"):
|
||||
|
||||
@@ -44,7 +44,7 @@ def test_required_public_field_assertion_detects_omission():
|
||||
|
||||
|
||||
def rows(db, table):
|
||||
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
|
||||
assert table in {'asset_snapshot_runs', 'account_snapshot', '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 +138,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', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
|
||||
tables = ('asset_snapshot_runs', 'account_snapshot', '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})
|
||||
|
||||
@@ -9,7 +9,13 @@ from jarca_ledger_mcp.db import init_db
|
||||
|
||||
def panel(monkeypatch, path):
|
||||
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
|
||||
app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20)
|
||||
monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
|
||||
app = AppTest.from_string(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
|
||||
app.session_state["authenticated_actor"] = "fixture-operator"
|
||||
app = app.run(timeout=20)
|
||||
assert not app.exception
|
||||
control(app.button, '정산 시작').click().run(timeout=20)
|
||||
control(app.button, '초안 만들기').click().run(timeout=20)
|
||||
assert not app.exception
|
||||
return app
|
||||
|
||||
@@ -38,7 +44,7 @@ def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch
|
||||
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
|
||||
idempotency_key='synthetic-value', db_path=path)['success']
|
||||
app = panel(monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
|
||||
review_key = f"review_result_{draft['id']}"
|
||||
|
||||
@@ -16,9 +16,15 @@ def control(elements, label):
|
||||
|
||||
def app_for(tmp_path, monkeypatch, path):
|
||||
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
|
||||
monkeypatch.setattr('dashboard.authentication.get_authenticated_actor', lambda: 'fixture-operator')
|
||||
appfile = tmp_path / 'panel.py'
|
||||
appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n')
|
||||
app = AppTest.from_file(str(appfile)).run(timeout=20)
|
||||
appfile.write_text(f'from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts(db_path={str(path)!r})\n')
|
||||
app = AppTest.from_file(str(appfile))
|
||||
app.session_state["authenticated_actor"] = "fixture-operator"
|
||||
app = app.run(timeout=20)
|
||||
assert not app.exception
|
||||
control(app.button, '정산 시작').click().run(timeout=20)
|
||||
control(app.button, '초안 만들기').click().run(timeout=20)
|
||||
assert not app.exception
|
||||
return app
|
||||
|
||||
@@ -29,7 +35,7 @@ def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_databas
|
||||
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
|
||||
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
control(app.text_input, '대상 ID').set_value(account)
|
||||
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
|
||||
control(app.selectbox, '선택 방식').select('manual_substitute')
|
||||
@@ -69,7 +75,7 @@ def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monk
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
control(app.text_input, '대상 ID').set_value(account)
|
||||
control(app.button, '평가자료 조회').click().run(timeout=20)
|
||||
assert not app.exception
|
||||
@@ -81,7 +87,7 @@ def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path,
|
||||
init_db(path)
|
||||
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
|
||||
control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
|
||||
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
|
||||
@@ -103,7 +109,7 @@ def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypa
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
|
||||
key = f"review_result_{draft['id']}"
|
||||
@@ -135,7 +141,7 @@ def test_panel_correction_invalidates_review_without_registration_account_input(
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
|
||||
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
|
||||
|
||||
@@ -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 asset_snapshots").fetchone()[0] == 0
|
||||
assert db.execute("SELECT COUNT(*) FROM account_snapshot").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 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.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.commit()
|
||||
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
|
||||
assert report["success"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.application import settlement_draft_service as service
|
||||
|
||||
|
||||
def test_cancelled_draft_does_not_reserve_create_key_for_new_draft(tmp_path: Path):
|
||||
db = str(tmp_path / "ledger.sqlite3")
|
||||
init_db(db)
|
||||
first = service.create_settlement_draft("2026-09", "actor", "same-key", db_path=db)
|
||||
assert first["success"]
|
||||
cancelled = service.cancel_settlement_draft(first["draft"]["id"], 1, "actor", "restart", db_path=db)
|
||||
assert cancelled["success"]
|
||||
|
||||
second = service.create_settlement_draft("2026-09", "actor", "new-key-after-cancel", db_path=db)
|
||||
assert second["success"]
|
||||
assert second["draft"]["id"] != first["draft"]["id"]
|
||||
|
||||
|
||||
def test_dashboard_exposes_the_five_state_entry_labels():
|
||||
source = Path(__file__).parents[1].joinpath("dashboard/page_views/settlement_drafts.py").read_text()
|
||||
for label in ("정산시작", "이어서작업", "마감내역보기", "정정시작", "정정이어서작업"):
|
||||
assert label in source
|
||||
assert "검토·승인·마감·취소·평가" not in source
|
||||
Reference in New Issue
Block a user