8f02318542
YNAB has no event feed, so keep a local copy in ynab_* tables synced via server_knowledge delta requests: full download on first run, deltas every YNAB_SYNC_INTERVAL seconds and on demand before each tool call, full resync daily. Each sync diffs against the mirror and appends real edits (transactions, assignments, renames) to ynab_changes, the edit history YNAB doesn't keep. Agent YNAB tools now answer from the mirror: budget summary (spending computed from transactions, since YNAB's month activity nets in credit card payment categories), account balances, transaction search with splits expanded, spending reports by category/payee/account/month, category budget, and recent changes. Adds an admin-only read-only ynab_sql tool guarded by a sqlglot allowlist (scope-resolved tables, no dangerous functions), a READ ONLY transaction, a timeout, and a row cap. Replaces utils/ynab_service.py and drops the unused ynab SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
8.1 KiB
Python
250 lines
8.1 KiB
Python
"""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."
|
|
)
|