Files
simbarag/tests/unit/test_ynab_mirror.py
T
ryan 9249f92db8 Fix YNAB mirror review findings
- Full resyncs mark mirror rows missing from the download as deleted;
  YNAB omits deleted entities from non-delta responses, so deletions
  made just before a full sync never reached the mirror.
- YNAB tools return errors as text instead of raising, since ToolNode
  re-raises and would fail the whole chat turn. ynab_sql catches all
  sqlglot errors (e.g. an unbalanced quote) and runs the SQL regenerated
  from the validated tree, which also drops comments that broke the
  LIMIT wrapper. Results keep duplicate column names.
- Sync fetches from YNAB before opening the DB transaction, discards the
  payload if the other container synced meanwhile, re-checks freshness
  under the lock so parallel tool calls share one sync, and backs off
  for 60s after a failure.
- The mirror is pinned to one budget; a changed YNAB_BUDGET_ID errors
  instead of mixing budgets, and an unset id stays on the budget the
  first sync resolved from "last-used".
- Change log: no entries for empty month rollover rows, and id fields
  are logged as names (category, payee, account). ynab_recent_changes
  accepts the names it prints ("assignment") and plurals.
- Outputs sized to the 4000-char tool result cap; ynab_sql reuses the
  shared admin message.
- YNAB API client moves back to utils/ynab_service.py, and the frontend
  tool status labels list the new tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:58:14 -04:00

348 lines
12 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 _check, 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, full=False):
async with in_transaction() as conn:
return await apply_budget(
budget, knowledge, conn, log_changes=log_changes, full=full
)
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_keeps_duplicate_column_names():
rows = [("Groceries", "Bills", Decimal("-54.150")), ("Rent", None, Decimal("10"))]
assert format_rows(["name", "name", "total"], rows) == (
"name\tname\ttotal\nGroceries\tBills\t-54.150\nRent\t\t10"
)
assert format_rows([], []) == "Query returned no rows."
def test_unbalanced_quote_is_reported_not_raised():
error = validate_sql("SELECT * FROM ynab_payees WHERE name = 'Trader Joe's'")
assert error and error.startswith("Could not parse SQL")
def test_executed_sql_drops_comments_and_semicolon():
error, sql = _check(
"SELECT payee_name, sum(amount) FROM ynab_transactions GROUP BY 1; -- top"
)
assert error is None
assert "--" not in sql and not sql.endswith(";")
assert f"SELECT * FROM ({sql}) AS q" # wraps cleanly
async def test_full_sync_marks_missing_rows_deleted(db):
await _apply(_budget([_txn(), _txn(id="txn-2")]), 1, log_changes=False, full=True)
# A full download omits deleted entities entirely
counts = await _apply(_budget([_txn(id="txn-2")]), 2, full=True)
assert counts["transactions"] == 1
assert (await YnabTransaction.get(id="txn-1")).deleted is True
assert (await YnabTransaction.get(id="txn-2")).deleted is False
change = await YnabChange.get(entity_id="txn-1")
assert change.action == "deleted" and "Costco" in change.label
async def test_delta_sync_does_not_treat_missing_rows_as_deleted(db):
await _apply(_budget([_txn(), _txn(id="txn-2")]), 1, log_changes=False)
await _apply(_budget([_txn(id="txn-2")]), 2)
assert (await YnabTransaction.get(id="txn-1")).deleted is False
async def test_full_sync_skips_entity_types_absent_from_payload(db):
await _apply(_budget([_txn()]), 1, log_changes=False, full=True)
budget = _budget([])
del budget["transactions"]
await _apply(budget, 2, full=True)
assert (await YnabTransaction.get(id="txn-1")).deleted is False
async def test_new_month_with_nothing_assigned_is_not_logged(db):
await _apply(_budget([]), 1, log_changes=False)
next_month = {**_month(budgeted=0, activity=0), "month": "2026-11-01"}
await _apply(_budget([], next_month), 2)
assert await YnabChange.filter(entity_type="month_category").count() == 0
async def test_recategorization_logs_names_not_ids(db):
household = {**CATEGORY, "id": "cat-2", "name": "Household"}
budget = _budget([_txn()])
budget["categories"].append(household)
await _apply(budget, 1, log_changes=False)
budget = _budget([_txn(category_id="cat-2")])
budget["categories"].append(household)
await _apply(budget, 2)
change = await YnabChange.get(entity_type="transaction")
assert change.changes == {"category": ["Groceries", "Household"]}
assert (await YnabTransaction.get(id="txn-1")).category_name == "Household"
async def test_tool_errors_are_returned_as_text(monkeypatch):
from blueprints.ynab import tools
async def fresh():
return object()
async def broken_query(*args):
raise ConnectionError("db went away")
monkeypatch.setattr(tools, "ensure_fresh", fresh)
monkeypatch.setattr(tools, "_query", broken_query)
result = await tools.ynab_account_balances.ainvoke({})
assert "db went away" in result and result.startswith("YNAB error")
async def test_recent_changes_accepts_display_names(monkeypatch):
from blueprints.ynab import tools
seen = []
async def fresh():
return object()
async def fake_query(sql, *params):
seen.append(params)
return []
monkeypatch.setattr(tools, "ensure_fresh", fresh)
monkeypatch.setattr(tools, "_query", fake_query)
await tools.ynab_recent_changes.ainvoke({"entity_type": "Assignments"})
assert seen[-1][1] == "month_category"
result = await tools.ynab_recent_changes.ainvoke({"entity_type": "bogus"})
assert result.startswith("Unknown entity_type")
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."
)