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>
This commit is contained in:
2026-10-12 18:03:48 -04:00
parent 9249f92db8
commit d1c3b7693e
3 changed files with 151 additions and 29 deletions
+90 -3
View File
@@ -1,8 +1,10 @@
"""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
@@ -248,7 +250,12 @@ def test_executed_sql_drops_comments_and_semicolon():
)
assert error is None
assert "--" not in sql and not sql.endswith(";")
assert f"SELECT * FROM ({sql}) AS q" # wraps cleanly
# 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):
@@ -328,8 +335,14 @@ async def test_recent_changes_accepts_display_names(monkeypatch):
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"
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")
@@ -345,3 +358,77 @@ async def test_budget_summary_rejects_bad_month(monkeypatch):
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