chore(jarca-ledger): finalize approved batch docs, dashboard, and CI smoke updates
This commit is contained in:
@@ -49,3 +49,35 @@ def test_apply_categorization_rules_applies_all_matches_not_just_preview_sample(
|
||||
assert applied["applied_count"] == 150
|
||||
uncategorized = service.list_uncategorized_transactions(limit=200, db_path=db_path)
|
||||
assert uncategorized["count"] == 0
|
||||
|
||||
def test_single_category_rule_preview_and_apply_only_uncategorized(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
seeded = service.seed_default_category_rules(db_path=db_path)
|
||||
assert seeded["success"] is True
|
||||
rules = service.list_category_rules(db_path=db_path)["rules"]
|
||||
mart_rule = next(r for r in rules if r["pattern"] == "이마트")
|
||||
|
||||
uncategorized = service.create_ledger_transaction(
|
||||
"2026-06-01", "expense", 10_000, merchant="이마트에브리데이", raw_summary="체크카드", db_path=db_path
|
||||
)["transaction"]
|
||||
categorized = service.create_ledger_transaction(
|
||||
"2026-06-02", "expense", 20_000, category="생활", subcategory="생필품", merchant="이마트24", raw_summary="체크카드", db_path=db_path
|
||||
)["transaction"]
|
||||
service.create_ledger_transaction(
|
||||
"2026-06-03", "expense", 30_000, merchant="알수없음", raw_summary="체크카드", db_path=db_path
|
||||
)
|
||||
|
||||
preview = service.preview_category_rule_matches(mart_rule["id"], db_path=db_path)
|
||||
assert preview["success"] is True
|
||||
assert preview["matched_count"] == 2
|
||||
assert preview["uncategorized_matched_count"] == 1
|
||||
assert len(preview["sample"]) == 2
|
||||
|
||||
applied = service.apply_category_rule(mart_rule["id"], db_path=db_path)
|
||||
assert applied["success"] is True
|
||||
assert applied["applied_count"] == 1
|
||||
|
||||
all_tx = service.list_ledger_transactions(limit=10, db_path=db_path)["transactions"]
|
||||
by_id = {tx["id"]: tx for tx in all_tx}
|
||||
assert by_id[uncategorized["id"]]["category"] == "식비"
|
||||
assert by_id[categorized["id"]]["category"] == "생활"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from dashboard.app import get_transfer_review_items
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
|
||||
def test_get_transfer_review_items_accepts_transactions_key():
|
||||
payload = {
|
||||
"success": True,
|
||||
"transactions": [
|
||||
{"id": "tx_1", "amount": 10000},
|
||||
],
|
||||
}
|
||||
|
||||
assert get_transfer_review_items(payload) == payload["transactions"]
|
||||
|
||||
|
||||
def test_get_transfer_review_items_prefers_review_items_when_present():
|
||||
payload = {
|
||||
"success": True,
|
||||
"review_items": [
|
||||
{"id": "tx_legacy", "amount": 20000},
|
||||
],
|
||||
"transactions": [
|
||||
{"id": "tx_new", "amount": 30000},
|
||||
],
|
||||
}
|
||||
|
||||
assert get_transfer_review_items(payload) == payload["review_items"]
|
||||
|
||||
|
||||
def test_get_transfer_review_items_returns_empty_for_unsuccessful_payload():
|
||||
assert get_transfer_review_items({"success": False, "transactions": [{"id": "tx_1"}]}) == []
|
||||
assert get_transfer_review_items(None) == []
|
||||
|
||||
|
||||
def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeypatch):
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
|
||||
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||
page_expectations = {
|
||||
"요약": "가계부 운영 대시보드",
|
||||
"분류 규칙": "분류 규칙",
|
||||
"확인필요 큐": "KB↔토스뱅크 확인필요 큐",
|
||||
}
|
||||
for page, expected_text in page_expectations.items():
|
||||
at = AppTest.from_file(str(app_path), default_timeout=10)
|
||||
at.query_params["page"] = page
|
||||
at.run()
|
||||
assert not at.exception
|
||||
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
|
||||
assert expected_text in body_text
|
||||
@@ -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"] == "6"
|
||||
assert health["schema_version"] == "8"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
|
||||
+155
-1
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
@@ -7,10 +10,35 @@ 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"] == "6"
|
||||
assert result["schema_version"] == "8"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
def test_service_default_dates_and_monthly_reports_use_kst_boundary(tmp_path, monkeypatch):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
boundary_instant = datetime(2026, 7, 1, 0, 30, tzinfo=ZoneInfo("Asia/Seoul"))
|
||||
monkeypatch.setattr(service, "_current_kst_datetime", lambda: boundary_instant)
|
||||
|
||||
assert service.now_iso() == "2026-07-01T00:30:00+09:00"
|
||||
assert service.normalize_date(None, default_today=True) == "2026-07-01"
|
||||
assert service.normalize_month("") == "2026-07"
|
||||
|
||||
service.create_ledger_transaction("2026-06-30", "expense", 10_000, category="식비", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-07-01", "income", 30_000, category="수입", db_path=db_path)
|
||||
service.create_ledger_transaction("2026-07-02", "expense", 5_000, category="식비", db_path=db_path)
|
||||
|
||||
cashflow = service.get_monthly_cashflow_report("", db_path)
|
||||
assert cashflow["success"] is True
|
||||
assert cashflow["month"] == "2026-07"
|
||||
assert cashflow["income"] == 30_000
|
||||
assert cashflow["expenses"] == 5_000
|
||||
|
||||
status = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)
|
||||
assert status["aggregate_totals"]["month"] == "2026-07"
|
||||
assert status["aggregate_totals"]["by_cashflow_kind"]["income"] == {"amount": 30_000, "count": 1}
|
||||
assert status["aggregate_totals"]["by_cashflow_kind"]["expense"] == {"amount": 5_000, "count": 1}
|
||||
|
||||
|
||||
def test_account_transaction_cashflow_and_category_reports(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
acct = service.create_ledger_account("신한카드", type="card", institution="신한", db_path=db_path)
|
||||
@@ -135,3 +163,129 @@ def test_duplicate_detection_and_soft_delete(tmp_path):
|
||||
listed_all = service.list_ledger_transactions(include_deleted=True, db_path=db_path)
|
||||
assert listed_all["count"] == 1
|
||||
assert listed_all["transactions"][0]["status"] == "deleted"
|
||||
|
||||
|
||||
def test_statement_status_account_update_and_balance_reconciliation(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
acct = service.create_ledger_account(
|
||||
"수동 계좌",
|
||||
statement_status="manual_only",
|
||||
last_balance=901_500,
|
||||
last_synced_at="2026-06-30T23:59:00+09:00",
|
||||
db_path=db_path,
|
||||
)
|
||||
assert acct["success"] is True
|
||||
assert acct["account"]["statement_status"] == "manual_only"
|
||||
account_id = acct["account"]["id"]
|
||||
|
||||
service.create_ledger_transaction("2026-06-01", "income", 1_000_000, account_id=account_id, db_path=db_path)
|
||||
service.create_ledger_transaction("2026-06-10", "expense", 98_500, account_id=account_id, db_path=db_path)
|
||||
|
||||
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
|
||||
assert report["success"] is True
|
||||
assert report["items"][0]["calculated_balance"] == 901_500
|
||||
assert report["items"][0]["difference"] == 0
|
||||
assert report["items"][0]["status"] == "OK"
|
||||
|
||||
updated = service.update_ledger_account(account_id, statement_status="partial_import", last_balance=905_000, db_path=db_path)
|
||||
assert updated["success"] is True
|
||||
assert updated["account"]["statement_status"] == "partial_import"
|
||||
|
||||
review = service.get_balance_reconciliation_report(account_id=account_id, baseline_balance=905_000, baseline_at="2026-06", db_path=db_path)
|
||||
assert review["success"] is True
|
||||
assert review["baseline_at"] == "2026-06-30T23:59:00+09:00"
|
||||
assert review["items"][0]["difference"] == 3_500
|
||||
assert review["items"][0]["status"] == "REVIEW_NEEDED"
|
||||
assert "확인필요" in review["items"][0]["recommended_decisions"]
|
||||
|
||||
|
||||
def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omitted(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
acct = service.create_ledger_account(
|
||||
"수동 계좌 기준일",
|
||||
statement_status="manual_only",
|
||||
last_balance=1_000,
|
||||
last_synced_at="2026-01-31T23:59:00+09:00",
|
||||
db_path=db_path,
|
||||
)
|
||||
assert acct["success"] is True
|
||||
account_id = acct["account"]["id"]
|
||||
|
||||
service.create_ledger_transaction("2026-01-15", "income", 1_000, account_id=account_id, db_path=db_path)
|
||||
service.create_ledger_transaction("2026-02-01", "expense", 200, account_id=account_id, db_path=db_path)
|
||||
|
||||
report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path)
|
||||
assert report["success"] is True
|
||||
assert report["baseline_at"] is None
|
||||
assert report["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00"
|
||||
assert report["items"][0]["calculated_balance"] == 1_000
|
||||
assert report["items"][0]["difference"] == 0
|
||||
assert report["items"][0]["status"] == "OK"
|
||||
|
||||
explicit = service.get_balance_reconciliation_report(
|
||||
account_id=account_id,
|
||||
baseline_balance=800,
|
||||
baseline_at="2026-02",
|
||||
db_path=db_path,
|
||||
)
|
||||
assert explicit["success"] is True
|
||||
assert explicit["baseline_at"] == "2026-02-28T23:59:00+09:00"
|
||||
assert explicit["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00"
|
||||
assert explicit["items"][0]["calculated_balance"] == 800
|
||||
assert explicit["items"][0]["difference"] == 0
|
||||
assert explicit["items"][0]["status"] == "OK"
|
||||
|
||||
|
||||
def test_invalid_statement_status_rejected(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account("오류 계좌", statement_status="unsupported", db_path=db_path)
|
||||
assert created["success"] is False
|
||||
assert created["error_code"] == "validation_error"
|
||||
|
||||
acct = service.create_ledger_account("정상 계좌", db_path=db_path)
|
||||
updated = service.update_ledger_account(acct["account"]["id"], statement_status="unsupported", db_path=db_path)
|
||||
assert updated["success"] is False
|
||||
assert updated["error_code"] == "validation_error"
|
||||
|
||||
|
||||
def test_liquidity_bucket_account_flow_and_asset_report(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
cash = service.create_ledger_account("현금성 계좌", last_balance=1_000_000, liquidity_bucket="cash", db_path=db_path)
|
||||
investment = service.create_ledger_account("투자 계좌", type="investment", last_balance=3_000_000, liquidity_bucket="investment", db_path=db_path)
|
||||
loan = service.create_ledger_account("대출", type="loan", last_balance=500_000, liquidity_bucket="loan", db_path=db_path)
|
||||
missing = service.create_ledger_account("평가 대기 부동산", liquidity_bucket="real_estate", db_path=db_path)
|
||||
default_bucket = service.create_ledger_account("기본 계좌", db_path=db_path)
|
||||
|
||||
assert cash["success"] is True
|
||||
assert investment["success"] is True
|
||||
assert loan["success"] is True
|
||||
assert missing["success"] is True
|
||||
assert default_bucket["account"]["liquidity_bucket"] == "other"
|
||||
|
||||
updated = service.update_ledger_account(default_bucket["account"]["id"], liquidity_bucket="deposit", last_balance=2_000_000, db_path=db_path)
|
||||
assert updated["success"] is True
|
||||
assert updated["account"]["liquidity_bucket"] == "deposit"
|
||||
|
||||
accounts = service.list_ledger_accounts(db_path=db_path)
|
||||
assert {account["liquidity_bucket"] for account in accounts["accounts"]} >= {"cash", "investment", "deposit", "real_estate", "loan"}
|
||||
|
||||
report = service.get_asset_liquidity_report(db_path=db_path)
|
||||
assert report["success"] is True
|
||||
assert report["total_assets"] == 6_000_000
|
||||
assert report["total_liabilities"] == 500_000
|
||||
assert report["net_worth"] == 5_500_000
|
||||
assert report["missing_balance_accounts"] == [{"id": missing["account"]["id"], "name": "평가 대기 부동산", "liquidity_bucket": "real_estate"}]
|
||||
loan_bucket = next(item for item in report["buckets"] if item["liquidity_bucket"] == "loan")
|
||||
assert loan_bucket["asset_share"] is None
|
||||
|
||||
|
||||
def test_invalid_liquidity_bucket_rejected(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
created = service.create_ledger_account("오류 계좌", liquidity_bucket="liquid", db_path=db_path)
|
||||
assert created["success"] is False
|
||||
assert created["error_code"] == "validation_error"
|
||||
|
||||
acct = service.create_ledger_account("정상 계좌", db_path=db_path)
|
||||
updated = service.update_ledger_account(acct["account"]["id"], liquidity_bucket="liquid", db_path=db_path)
|
||||
assert updated["success"] is False
|
||||
assert updated["error_code"] == "validation_error"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
|
||||
|
||||
def test_uncategorized_workbench_recommends_from_rules_and_applies_single(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.seed_default_category_rules(db_path=db_path)
|
||||
tx = service.create_ledger_transaction(
|
||||
"2026-06-01", "expense", 10_000, merchant="스타벅스강남", raw_summary="체크카드", db_path=db_path
|
||||
)["transaction"]
|
||||
|
||||
workbench = service.get_uncategorized_workbench(db_path=db_path)
|
||||
|
||||
assert workbench["success"] is True
|
||||
assert workbench["summary"]["uncategorized_count"] == 1
|
||||
assert workbench["summary"]["recommendable_count"] == 1
|
||||
row = workbench["transactions"][0]
|
||||
assert row["id"] == tx["id"]
|
||||
assert row["recommended_category"] == "식비"
|
||||
assert row["recommended_subcategory"] == "카페"
|
||||
assert row["recommendation"]["source"] == "rule"
|
||||
|
||||
applied = service.apply_uncategorized_recommendation(tx["id"], db_path=db_path)
|
||||
|
||||
assert applied["success"] is True
|
||||
assert applied["applied_count"] == 1
|
||||
saved = service.list_ledger_transactions(limit=10, db_path=db_path)["transactions"][0]
|
||||
assert saved["category"] == "식비"
|
||||
assert saved["subcategory"] == "카페"
|
||||
|
||||
|
||||
def test_uncategorized_workbench_recommends_from_history_and_applies_group(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_ledger_transaction(
|
||||
"2026-06-01", "expense", 7_000, category="식비", subcategory="카페", merchant="동네커피", raw_summary="체크카드", db_path=db_path
|
||||
)
|
||||
tx1 = service.create_ledger_transaction(
|
||||
"2026-06-02", "expense", 8_000, merchant="동네커피", raw_summary="체크카드", db_path=db_path
|
||||
)["transaction"]
|
||||
tx2 = service.create_ledger_transaction(
|
||||
"2026-06-03", "expense", 9_000, merchant="동네커피", raw_summary="체크카드", db_path=db_path
|
||||
)["transaction"]
|
||||
|
||||
workbench = service.get_uncategorized_workbench(db_path=db_path)
|
||||
|
||||
assert workbench["summary"]["uncategorized_count"] == 2
|
||||
assert workbench["summary"]["recommendable_count"] == 2
|
||||
assert workbench["summary"]["repeated_group_count"] == 1
|
||||
group = workbench["groups"][0]
|
||||
assert group["count"] == 2
|
||||
assert group["recommendation"]["category"] == "식비"
|
||||
assert group["recommendation"]["source"] == "history"
|
||||
|
||||
applied = service.apply_uncategorized_group_recommendation("동네커피", "체크카드", "expense", db_path=db_path)
|
||||
|
||||
assert applied["success"] is True
|
||||
assert applied["applied_count"] == 2
|
||||
transactions = service.list_ledger_transactions(limit=10, db_path=db_path)["transactions"]
|
||||
by_id = {tx["id"]: tx for tx in transactions}
|
||||
assert by_id[tx1["id"]]["category"] == "식비"
|
||||
assert by_id[tx2["id"]]["subcategory"] == "카페"
|
||||
|
||||
|
||||
def test_uncategorized_workbench_supports_amount_and_date_sort(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
service.create_ledger_transaction(
|
||||
"2026-06-01", "expense", 30_000, merchant="오래된고액", raw_summary="체크카드", db_path=db_path
|
||||
)
|
||||
service.create_ledger_transaction(
|
||||
"2026-06-03", "expense", 5_000, merchant="최신소액", raw_summary="체크카드", db_path=db_path
|
||||
)
|
||||
service.create_ledger_transaction(
|
||||
"2026-06-02", "expense", 10_000, merchant="중간", raw_summary="체크카드", db_path=db_path
|
||||
)
|
||||
|
||||
by_amount = service.get_uncategorized_workbench(sort_by="amount_desc", db_path=db_path)["transactions"]
|
||||
by_date = service.get_uncategorized_workbench(sort_by="date_desc", db_path=db_path)["transactions"]
|
||||
|
||||
assert [row["merchant"] for row in by_amount] == ["오래된고액", "중간", "최신소액"]
|
||||
assert [row["merchant"] for row in by_date] == ["최신소액", "중간", "오래된고액"]
|
||||
Reference in New Issue
Block a user