"""Tests for the YNAB tools' content_and_artifact output. `ensure_fresh` and the `_query` DB helper are mocked; the fake answers each query by matching a fragment of its SQL. """ import json import re from datetime import date from decimal import Decimal from types import SimpleNamespace import pytest from langchain_core.messages import ToolMessage from blueprints.ynab import tools KIND_DATA_KEYS = { "ynab.summary": { "month", "ready_to_assign", "income", "assigned", "spent", "net_worth", "overspent", }, "ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"}, "ynab.spending_report": { "start_date", "end_date", "group_by", "rows", "total", "total_basis", "inflows", "net_outflows", "filters", }, "ynab.category_budget": {"month", "groups"}, } async def call(tool, args=None) -> ToolMessage: """Invoke a tool the way the agent does: with a ToolCall dict.""" msg = await tool.ainvoke( {"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}} ) assert isinstance(msg, ToolMessage) return msg def assert_artifact(artifact, kind, optional=()): assert artifact is not None assert artifact["kind"] == kind assert isinstance(artifact["title"], str) and artifact["title"] assert KIND_DATA_KEYS[kind] <= set(artifact["data"]) assert set(artifact["data"]) <= KIND_DATA_KEYS[kind] | set(optional) json.dumps(artifact) def fake_db(monkeypatch, responses: dict, synced=True): """Route `_query` calls to `responses` by SQL fragment; honour LIMIT.""" calls = [] async def fake_query(sql, *params): calls.append((sql, params)) for fragment, rows in responses.items(): if fragment in sql: limit = re.search(r"LIMIT (\d+)", sql) return rows[: int(limit.group(1))] if limit else rows raise AssertionError(f"unexpected query: {sql}") async def fake_ensure_fresh(): return SimpleNamespace(budget_name="Home") if synced else None monkeypatch.setattr(tools, "_query", fake_query) monkeypatch.setattr(tools, "ensure_fresh", fake_ensure_fresh) monkeypatch.setattr(tools, "_today", lambda: date(2026, 10, 12)) return calls # --- ynab_budget_summary ---------------------------------------------------- SUMMARY_DB = { "FROM ynab_months": [ { "to_be_budgeted": Decimal("120.50"), "income": Decimal("5000.00"), "budgeted": Decimal("4800.00"), "age_of_money": 42, } ], "mc.balance < 0": [{"name": "Dining Out", "balance": Decimal("-23.10")}], "AS spent": [{"spent": Decimal("3100.25")}], "GROUP BY on_budget": [ {"on_budget": True, "total": Decimal("10000.00")}, {"on_budget": False, "total": Decimal("-2500.00")}, ], } async def test_budget_summary(monkeypatch): fake_db(monkeypatch, SUMMARY_DB) msg = await call(tools.ynab_budget_summary, {"month": "2026-10"}) assert msg.content == ( "YNAB budget 'Home' for 2026-10:\n" "Ready to Assign: $120.50\n" "Income: $5,000.00\n" "Assigned: $4,800.00\n" "Spent: $3,100.25\n" "Age of money: 42 days\n" "Overspent: Dining Out (-$23.10)\n" "Net worth: $7,500.00 (budget accounts $10,000.00, " "tracking accounts -$2,500.00)" ) art = msg.artifact assert_artifact(art, "ynab.summary", optional={"age_of_money"}) assert art["title"] == "Budget · October 2026" assert art["data"] == { "month": "2026-10", "ready_to_assign": 120.5, "income": 5000.0, "assigned": 4800.0, "spent": 3100.25, "age_of_money": 42, "net_worth": 7500.0, "overspent": [{"name": "Dining Out", "available": -23.1}], } async def test_budget_summary_without_age_of_money(monkeypatch): db = dict(SUMMARY_DB) db["FROM ynab_months"] = [ {**SUMMARY_DB["FROM ynab_months"][0], "age_of_money": None} ] fake_db(monkeypatch, db) msg = await call(tools.ynab_budget_summary, {"month": "2026-10"}) assert "Age of money" not in msg.content assert "age_of_money" not in msg.artifact["data"] async def test_budget_summary_no_month_data(monkeypatch): fake_db(monkeypatch, {"FROM ynab_months": []}) msg = await call(tools.ynab_budget_summary, {"month": "2026-10"}) assert msg.content == "No YNAB budget data for 2026-10." assert msg.artifact is None # --- ynab_search_transactions ----------------------------------------------- def _txn(i, amount, **extra): return { "id": f"t{i}", "date": date(2026, 10, 12 - i % 10), "amount": Decimal(amount), "memo": None, "approved": True, "account_name": "Checking", "payee_name": "Costco", "category_name": "Groceries", **extra, } TXNS = [ _txn(0, "-54.20", memo="milk"), _txn(1, "-12.00", approved=False, payee_name=None, category_name=None), _txn(2, "100.00"), ] TXN_TOTALS = { "count(*) AS n": [ {"n": 3, "outflow": Decimal("-66.20"), "inflow": Decimal("100.00")} ], } async def test_search_transactions(monkeypatch): calls = fake_db(monkeypatch, {**TXN_TOTALS, "SELECT * FROM lines": TXNS}) msg = await call(tools.ynab_search_transactions, {"limit": 2}) assert msg.content == ( "Found 3 transactions from 2026-09-12 to 2026-10-12. " "Outflows -$66.20, inflows $100.00.\n" "- 2026-10-12: Costco -$54.20 (Groceries; Checking) — milk\n" "- 2026-10-11: Unknown payee -$12.00 (Uncategorized; Checking) [unapproved]\n" "(Showing 2 most recent of 3)" ) assert "LIMIT 500" in calls[-1][0] art = msg.artifact assert_artifact(art, "ynab.transactions") assert art["title"] == "Transactions · Sep 12–Oct 12" data = art["data"] assert data["start_date"] == "2026-09-12" assert data["end_date"] == "2026-10-12" assert data["count"] == 3 assert data["total"] == pytest.approx(33.8) # the artifact keeps every row, not just the `limit` shown to the model assert len(data["transactions"]) == 3 assert data["transactions"][0] == { "date": "2026-10-12", "payee": "Costco", "category": "Groceries", "account": "Checking", "memo": "milk", "amount": -54.2, } assert "memo" not in data["transactions"][1] async def test_search_transactions_caps_artifact_rows(monkeypatch): many = [_txn(i, "-1.00") for i in range(600)] totals = {"count(*) AS n": [{"n": 600, "outflow": Decimal("-600"), "inflow": 0}]} fake_db(monkeypatch, {**totals, "SELECT * FROM lines": many}) msg = await call(tools.ynab_search_transactions, {"payee": "costco"}) assert msg.content.endswith("(Showing 20 most recent of 600)") assert len(msg.artifact["data"]["transactions"]) == tools.ARTIFACT_MAX_TRANSACTIONS assert msg.artifact["data"]["start_date"] is None assert msg.artifact["title"] == "Transactions · through 2026-10-12" async def test_search_transactions_empty(monkeypatch): fake_db(monkeypatch, {"count(*) AS n": [{"n": 0, "outflow": 0, "inflow": 0}]}) msg = await call(tools.ynab_search_transactions, {}) assert msg.content == "No transactions found from 2026-09-12 to 2026-10-12." assert msg.artifact is None async def test_search_transactions_bad_date(monkeypatch): fake_db(monkeypatch, {}) msg = await call(tools.ynab_search_transactions, {"start_date": "nope"}) assert msg.content == "Dates must be YYYY-MM-DD." assert msg.artifact is None # --- ynab_spending_report --------------------------------------------------- SPENDING_ROWS = [ {"key": "Groceries", "spent": Decimal("500.00"), "n": 10}, {"key": "Dining Out", "spent": Decimal("150.50"), "n": 5}, {"key": "Gas", "spent": Decimal("60.00"), "n": 2}, ] async def test_spending_report(monkeypatch): fake_db( monkeypatch, { "GROUP BY 1": SPENDING_ROWS, "AS outflows": [ {"outflows": Decimal("710.50"), "inflows": Decimal("100.00")} ], }, ) msg = await call(tools.ynab_spending_report, {"top": 2}) assert msg.content == ( "Spending by category from 2026-10-01 to 2026-10-12:\n" "- Total spending (outflows): $710.50\n" "- Inflows (deposits into budget accounts, not spending): $100.00\n" "- Group amounts are gross outflows; refunds and other inflows are reported separately.\n" "- Groceries: $500.00 (10 txns)\n" "- Dining Out: $150.50 (5 txns)\n" "- 1 others: $60.00" ) art = msg.artifact assert_artifact(art, "ynab.spending_report") assert art["title"] == "Spending by category · Oct 1–12" assert art["data"] == { "start_date": "2026-10-01", "end_date": "2026-10-12", "group_by": "category", "rows": [ {"key": "Groceries", "amount": 500.0, "count": 10}, {"key": "Dining Out", "amount": 150.5, "count": 5}, {"key": "Gas", "amount": 60.0, "count": 2}, ], "total": 710.5, "total_basis": "outflows", "inflows": 100.0, "net_outflows": 610.5, "filters": {"category": "", "payee": ""}, } async def test_spending_report_by_month(monkeypatch): rows = [ {"key": "2026-09", "spent": Decimal("100"), "n": 1}, {"key": "2026-10", "spent": Decimal("300"), "n": 2}, ] fake_db( monkeypatch, { "GROUP BY 1": rows, "AS outflows": [ {"outflows": Decimal("400.00"), "inflows": Decimal("100.00")} ], }, ) msg = await call( tools.ynab_spending_report, {"group_by": "month", "start_date": "2026-09-01"}, ) assert msg.content.endswith( "Monthly average (outflows over 2 calendar months): $200.00" ) assert msg.artifact["data"]["total"] == 400.0 assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"] async def test_spending_report_empty_and_invalid(monkeypatch): fake_db( monkeypatch, { "GROUP BY 1": [], "AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(0)}], }, ) msg = await call(tools.ynab_spending_report, {}) assert msg.content == "No spending found from 2026-10-01 to 2026-10-12." assert msg.artifact is None msg = await call(tools.ynab_spending_report, {"group_by": "vibes"}) assert msg.content == ( "group_by must be one of: category, category_group, payee, account, month." ) assert msg.artifact is None # --- ynab_category_budget --------------------------------------------------- def _cat(group, name, budgeted, activity, balance, goal=None): return { "group_name": group, "name": name, "goal_target": Decimal(goal) if goal else None, "budgeted": Decimal(budgeted), "activity": Decimal(activity), "balance": Decimal(balance), } async def test_category_budget(monkeypatch): rows = [ _cat("Bills", "Rent", "2000", "-2000", "0"), _cat("Bills", "Unused", "0", "0", "0"), _cat("Food", "Dining Out", "100", "-123.10", "-23.10", goal="150"), ] fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows}) msg = await call(tools.ynab_category_budget, {"month": "2026-10"}) assert msg.content == ( "Categories for 2026-10:\n" "\nBills\n" " Rent: assigned $2,000.00, activity -$2,000.00, available $0.00\n" "\nFood\n" " Dining Out: assigned $100.00, activity -$123.10, available -$23.10, " "goal $150.00 [OVERSPENT]" ) art = msg.artifact assert_artifact(art, "ynab.category_budget") assert art["title"] == "Category budget · October 2026" assert art["data"] == { "month": "2026-10", "groups": [ { "name": "Bills", "categories": [ { "name": "Rent", "assigned": 2000.0, "activity": -2000.0, "available": 0.0, } ], }, { "name": "Food", "categories": [ { "name": "Dining Out", "assigned": 100.0, "activity": -123.1, "available": -23.1, "goal_target": 150.0, } ], }, ], } async def test_category_budget_all_zero_rows(monkeypatch): rows = [_cat("Bills", "Unused", "0", "0", "0")] fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows}) msg = await call(tools.ynab_category_budget, {"month": "2026-10"}) assert msg.content == "Categories for 2026-10:" assert msg.artifact is None async def test_category_budget_empty(monkeypatch): fake_db(monkeypatch, {"FROM ynab_month_categories mc": []}) msg = await call(tools.ynab_category_budget, {"month": "2026-10"}) assert msg.content == "No YNAB category data for 2026-10." assert msg.artifact is None # --- shared paths ----------------------------------------------------------- ARTIFACT_TOOLS = [ tools.ynab_budget_summary, tools.ynab_search_transactions, tools.ynab_spending_report, tools.ynab_category_budget, ] @pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name) async def test_not_synced(monkeypatch, tool): fake_db(monkeypatch, {}, synced=False) msg = await call(tool) assert msg.content == tools.NOT_SYNCED assert msg.artifact is None @pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name) async def test_db_error(monkeypatch, tool): fake_db(monkeypatch, {}) async def boom(sql, *params): raise RuntimeError("db down") monkeypatch.setattr(tools, "_query", boom) msg = await call(tool) assert msg.content == f"YNAB error in {tool.name}: RuntimeError: db down" assert msg.artifact is None def test_text_only_tools_unchanged(): assert tools.ynab_account_balances.response_format == "content" assert tools.ynab_recent_changes.response_format == "content" async def test_spending_report_omits_nonpositive_groups(monkeypatch): rows = SPENDING_ROWS + [ {"key": "Uncategorized", "spent": Decimal("-5000"), "n": 1}, {"key": "Fully refunded", "spent": Decimal("0"), "n": 2}, ] fake_db( monkeypatch, { "GROUP BY 1": rows, "AS outflows": [ {"outflows": Decimal("710.50"), "inflows": Decimal("5100.00")} ], }, ) msg = await call(tools.ynab_spending_report, {}) assert "Total spending (outflows): $710.50" in msg.content assert "not spending): $5,100.00" in msg.content assert "Uncategorized" not in msg.content assert "Fully refunded" not in msg.content assert [r["key"] for r in msg.artifact["data"]["rows"]] == [ r["key"] for r in SPENDING_ROWS ] assert msg.artifact["data"]["total"] == 710.5 async def test_spending_report_fully_refunded_purchase_is_not_empty(monkeypatch): queries = fake_db( monkeypatch, { "GROUP BY 1": [{"key": "Shopping", "spent": Decimal(100), "n": 2}], "AS outflows": [{"outflows": Decimal(100), "inflows": Decimal(100)}], }, ) msg = await call(tools.ynab_spending_report, {"category": "Shopping"}) assert msg.artifact["data"]["total"] == 100 assert msg.artifact["data"]["net_outflows"] == 0 assert msg.artifact["data"]["rows"][0]["amount"] == 100 assert "-SUM(LEAST(l.amount, 0)) AS spent" in queries[0][0] assert all("l.category_name ILIKE $3" in sql for sql, _ in queries) assert msg.artifact["data"]["filters"] == {"category": "Shopping", "payee": ""} async def test_spending_report_inflow_only_period_retains_totals(monkeypatch): fake_db( monkeypatch, { "GROUP BY 1": [{"key": "Refund", "spent": Decimal(0), "n": 1}], "AS outflows": [{"outflows": Decimal(0), "inflows": Decimal(75)}], }, ) msg = await call(tools.ynab_spending_report, {}) assert msg.artifact["data"]["rows"] == [] assert msg.artifact["data"]["total"] == 0 assert msg.artifact["data"]["inflows"] == 75 assert "not spending): $75.00" in msg.content async def test_monthly_average_includes_zero_spending_months(monkeypatch): fake_db( monkeypatch, { "GROUP BY 1": [{"key": "2026-10", "spent": Decimal(300), "n": 1}], "AS outflows": [{"outflows": Decimal(300), "inflows": Decimal(0)}], }, ) msg = await call( tools.ynab_spending_report, {"group_by": "month", "start_date": "2026-08-01"} ) assert msg.content.endswith( "Monthly average (outflows over 3 calendar months): $100.00" ) async def test_spending_report_reversed_dates_are_rejected(monkeypatch): queries = fake_db(monkeypatch, {}) msg = await call( tools.ynab_spending_report, {"start_date": "2026-11-01", "end_date": "2026-10-01"}, ) assert msg.content == "start_date must be on or before end_date." assert queries == []