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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user