553 lines
17 KiB
Python
553 lines
17 KiB
Python
"""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 == []
|