Files
simbarag/tests/unit/test_ynab_mirror.py
T
ryan 8f02318542 Mirror YNAB budget into Postgres with delta sync and change log
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>
2026-10-12 17:32:38 -04:00

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."
)