d1c3b7693e
- 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>
435 lines
15 KiB
Python
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
|