Files
simbarag/tests/unit/test_ynab_mirror.py
T
ryan d1c3b7693e Fix second-round YNAB review findings
- Failure backoff is re-checked under the sync lock, so parallel tool
  calls share one failed YNAB request instead of retrying in turn.
- Budget id: YNAB_BUDGET_ID aliases ("last-used", "default") count as
  unset; state is always keyed by the real budget id, a mismatched
  response errors instead of silently skipping every sync, and more
  than one stored budget is an error rather than an arbitrary pick.
- Change log renames only id fields that resolve to names (account,
  payee, category, group); subtransaction transaction_id stays as is.
- ynab_recent_changes: correct singular for "categories", multi-word
  types, and the "month" type.
- The SQL wrap test now parses the wrapped query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:03:48 -04:00

435 lines
15 KiB
Python

"""Tests for the YNAB Postgres mirror: delta application, change log, SQL guard."""
import asyncio
from decimal import Decimal
import pytest
import sqlglot
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(";")
# The LIMIT wrapper must still parse: a leftover line comment would
# swallow its closing parenthesis
wrapped = sqlglot.parse_one(
f"SELECT * FROM ({sql}) AS q LIMIT 100", read="postgres"
)
assert wrapped.args["limit"] is not None
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)
for given, stored in [
("Assignments", "month_category"),
("categories", "category"),
("category groups", "category_group"),
("month", "month"),
]:
await tools.ynab_recent_changes.ainvoke({"entity_type": given})
assert seen[-1][1] == stored
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."
)
async def test_subtransaction_parent_id_is_not_renamed(db):
split = {
"id": "sub-1",
"transaction_id": "txn-1",
"amount": -10_000,
"memo": None,
"payee_id": None,
"category_id": "cat-1",
"transfer_account_id": None,
"deleted": False,
}
budget = _budget([_txn()])
budget["subtransactions"] = [split]
await _apply(budget, 1, log_changes=False)
budget["subtransactions"] = [{**split, "amount": -12_000}]
await _apply(budget, 2)
change = await YnabChange.get(entity_type="subtransaction")
assert change.changes == {"amount": [-10.0, -12.0]}
class TestBudgetPinning:
@pytest.fixture(autouse=True)
async def _db(self, db):
yield
async def _state(self, budget_id):
from blueprints.ynab.models import YnabSyncState
await YnabSyncState.create(budget_id=budget_id, server_knowledge=1)
@pytest.mark.parametrize("configured", ["", "last-used", "default"])
async def test_alias_or_unset_uses_stored_budget(self, monkeypatch, configured):
from blueprints.ynab.sync import _current_state
monkeypatch.setenv("YNAB_BUDGET_ID", configured)
await self._state("uuid-a")
assert (await _current_state()).budget_id == "uuid-a"
async def test_different_configured_budget_is_an_error(self, monkeypatch):
from blueprints.ynab.sync import _current_state
monkeypatch.setenv("YNAB_BUDGET_ID", "uuid-b")
await self._state("uuid-a")
with pytest.raises(ValueError, match="clear the ynab_"):
await _current_state()
async def test_multiple_stored_budgets_is_an_error(self, monkeypatch):
from blueprints.ynab.sync import _current_state
monkeypatch.delenv("YNAB_BUDGET_ID", raising=False)
await self._state("uuid-a")
await self._state("uuid-b")
with pytest.raises(ValueError):
await _current_state()
async def test_parallel_syncs_share_one_failure(db, monkeypatch):
from blueprints.ynab import sync
calls = []
class FailingYNAB:
async def fetch_budget(self, budget_id, since):
calls.append(budget_id)
raise TimeoutError("ynab down")
monkeypatch.setattr(sync, "YNABService", FailingYNAB)
monkeypatch.setattr(sync, "_last_failure", float("-inf"))
await asyncio.gather(
*(sync.ensure_fresh() for _ in range(3)), return_exceptions=True
)
assert len(calls) == 1