"""Tests for the YNAB Postgres mirror: delta application, change log, SQL guard.""" from decimal import Decimal import pytest from tortoise import Tortoise from tortoise.transactions import in_transaction from blueprints.ynab.models import YnabChange, YnabMonthCategory, YnabTransaction from blueprints.ynab.sql_tool import format_rows, validate_sql from blueprints.ynab.sync import _money, apply_budget ACCOUNT = { "id": "acct-1", "name": "Checking", "type": "checking", "on_budget": True, "closed": False, "note": None, "balance": 1_000_000, "cleared_balance": 1_000_000, "uncleared_balance": 0, "last_reconciled_at": None, "deleted": False, } PAYEE = { "id": "payee-1", "name": "Costco", "transfer_account_id": None, "deleted": False, } GROUP = {"id": "grp-1", "name": "Bills", "hidden": False, "deleted": False} CATEGORY = { "id": "cat-1", "category_group_id": "grp-1", "name": "Groceries", "hidden": False, "note": None, "goal_type": None, "goal_target": 0, "goal_target_month": None, "deleted": False, } def _txn(**overrides): txn = { "id": "txn-1", "date": "2026-10-03", "amount": -54_150, "memo": None, "cleared": "cleared", "approved": True, "flag_color": None, "account_id": "acct-1", "payee_id": "payee-1", "category_id": "cat-1", "transfer_account_id": None, "import_payee_name": None, "deleted": False, } return {**txn, **overrides} def _month(budgeted=400_000, activity=-54_150): return { "month": "2026-10-01", "note": "", "income": 0, "budgeted": budgeted, "activity": activity, "to_be_budgeted": 0, "age_of_money": 30, "deleted": False, "categories": [ { **CATEGORY, "budgeted": budgeted, "activity": activity, "balance": budgeted + activity, } ], } def _budget(transactions, month=None): return { "accounts": [ACCOUNT], "payees": [PAYEE], "category_groups": [GROUP], "categories": [CATEGORY], "months": [month or _month()], "transactions": transactions, "subtransactions": [], } @pytest.fixture async def db(): await Tortoise.init( db_url="sqlite://:memory:", modules={"models": ["blueprints.ynab.models"]} ) await Tortoise.generate_schemas() yield await Tortoise.close_connections() async def _apply(budget, knowledge, log_changes=True): async with in_transaction() as conn: return await apply_budget(budget, knowledge, conn, log_changes=log_changes) def test_money_is_exact(): assert _money(-54_150) == Decimal("-54.15") assert _money(1) == Decimal("0.001") assert _money(None) is None async def test_initial_sync_writes_rows_without_logging(db): counts = await _apply(_budget([_txn()]), 1, log_changes=False) assert counts["transactions"] == 1 txn = await YnabTransaction.get(id="txn-1") assert txn.amount == Decimal("-54.15") # Names are denormalized onto the transaction assert (txn.payee_name, txn.category_name, txn.account_name) == ( "Costco", "Groceries", "Checking", ) assert await YnabChange.all().count() == 0 async def test_unchanged_delta_is_a_noop(db): await _apply(_budget([_txn()]), 1, log_changes=False) counts = await _apply(_budget([_txn()]), 2) assert not any(counts.values()) assert await YnabChange.all().count() == 0 async def test_edit_logs_field_diff(db): await _apply(_budget([_txn()]), 1, log_changes=False) await _apply(_budget([_txn(amount=-60_000, memo="bulk")]), 2) change = await YnabChange.get(entity_type="transaction") assert change.action == "updated" assert change.server_knowledge == 2 assert change.changes == {"amount": [-54.15, -60.0], "memo": [None, "bulk"]} assert "Costco" in change.label async def test_assignment_change_is_logged_but_activity_is_not(db): await _apply(_budget([]), 1, log_changes=False) # Only activity moves: derived, so not logged await _apply(_budget([], _month(activity=-80_000)), 2) assert await YnabChange.filter(entity_type="month_category").count() == 0 await _apply(_budget([], _month(budgeted=500_000, activity=-80_000)), 3) change = await YnabChange.get(entity_type="month_category") assert change.changes == {"budgeted": [400.0, 500.0]} assert change.label == "Groceries 2026-10" mc = await YnabMonthCategory.get(id="2026-10-01:cat-1") assert mc.balance == Decimal("420") async def test_new_and_deleted_transactions(db): await _apply(_budget([_txn()]), 1, log_changes=False) await _apply(_budget([_txn(deleted=True), _txn(id="txn-2")]), 2) actions = { c.entity_id: c.action for c in await YnabChange.filter(entity_type="transaction") } assert actions == {"txn-1": "deleted", "txn-2": "created"} assert (await YnabTransaction.get(id="txn-1")).deleted is True async def test_created_then_deleted_between_syncs_is_not_logged(db): await _apply(_budget([]), 1, log_changes=False) await _apply(_budget([_txn(id="txn-9", deleted=True)]), 2) assert await YnabChange.filter(entity_type="transaction").count() == 0 class TestValidateSql: @pytest.mark.parametrize( "query", [ "SELECT payee_name, sum(amount) FROM ynab_transactions WHERE NOT deleted GROUP BY 1", "WITH x AS (SELECT * FROM ynab_payees) SELECT * FROM x", "SELECT date_trunc('month', date) m, count(*) FROM ynab_transactions GROUP BY 1;", "SELECT * FROM generate_series(1, 3) g", "WITH RECURSIVE r AS (SELECT 1 n UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT * FROM r", "WITH ynab_payees AS (SELECT * FROM ynab_payees WHERE NOT deleted) SELECT * FROM ynab_payees", "SELECT * FROM ynab_transactions UNION ALL SELECT * FROM ynab_transactions", ], ) def test_allows_read_queries(self, query): assert validate_sql(query) is None @pytest.mark.parametrize( "query, error", [ ("DELETE FROM ynab_payees", "Only SELECT"), ("SELECT 1; SELECT 2", "Exactly one"), ("SELECT * FROM users", "not allowed"), ("SELECT * FROM public.conversations", "not allowed"), ("SELECT * FROM pg_catalog.pg_user", "not allowed"), ("SELECT * INTO evil FROM ynab_payees", "read-only"), ("SELECT * FROM ynab_payees FOR UPDATE", "read-only"), ( "WITH x AS (DELETE FROM ynab_payees RETURNING *) SELECT * FROM x", "read-only", ), ("SELECT pg_read_file('/etc/passwd')", "pg_read_file"), ("SELECT (SELECT lo_import('/etc/passwd'))", "lo_import"), ("SELECT current_setting('is_superuser')", "current_setting"), # CTE named like a real table: its body still reads the real table ("WITH users AS (SELECT * FROM users) SELECT * FROM users", "users"), # CTE defined in a subquery doesn't cover a reference outside it ("SELECT * FROM (WITH users AS (SELECT 1) SELECT 1) x, users", "users"), ("SELECT * FROM ynab_payees WHERE id IN (SELECT id FROM users)", "users"), ], ) def test_rejects(self, query, error): result = validate_sql(query) assert result is not None and error in result def test_format_rows(): rows = [ {"payee": "Costco", "total": Decimal("-54.150")}, {"payee": None, "total": Decimal("10.000")}, ] assert format_rows(rows) == "payee\ttotal\nCostco\t-54.150\n\t10" assert format_rows([]) == "Query returned no rows." async def test_budget_summary_rejects_bad_month(monkeypatch): from blueprints.ynab import tools async def fresh(): return object() monkeypatch.setattr(tools, "ensure_fresh", fresh) for month in ("October", "2026-13"): assert await tools.ynab_budget_summary.ainvoke({"month": month}) == ( "Month must be YYYY-MM." )