Files
simbarag/tests/unit/test_ynab_tool_artifacts.py
T
ryan 20ec7ca8b6 Return artifacts from YNAB budget, transaction and spending tools
Convert ynab_budget_summary, ynab_search_transactions, ynab_spending_report
and ynab_category_budget to content_and_artifact (ynab.summary,
ynab.transactions, ynab.spending_report, ynab.category_budget). The text
the LLM sees is unchanged. Not-synced, bad-input, empty and error paths
return no artifact; Decimals become floats and dates ISO strings.

ynab_search_transactions now fetches up to 500 rows in one query (with an
id tie-break so ordering is stable) and lists the first `limit` in the
text; the artifact carries all of them. ynab_account_balances,
ynab_recent_changes and ynab_sql stay text-only.

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

428 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"},
"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("400.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})
msg = await call(tools.ynab_spending_report, {"top": 2})
assert msg.content == (
"Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n"
"- Groceries: $400.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": 400.0, "count": 10},
{"key": "Dining Out", "amount": 150.5, "count": 5},
{"key": "Gas", "amount": 60.0, "count": 2},
],
"total": 610.5,
}
async def test_spending_report_by_month(monkeypatch):
rows = [
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
]
fake_db(monkeypatch, {"GROUP BY 1": rows})
msg = await call(
tools.ynab_spending_report,
{"group_by": "month", "start_date": "2026-09-01"},
)
assert msg.content.endswith("Monthly average: $150.00")
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": []})
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"