test_scheduled_transactions.py•25.5 kB
"""
Tests for scheduled transaction functionality in YNAB MCP Server.
Tests the list_scheduled_transactions tool with various filters and scenarios.
"""
from datetime import date
from unittest.mock import MagicMock
import ynab
from assertions import extract_response_data
from fastmcp.client import Client, FastMCPTransport
async def test_list_scheduled_transactions_basic(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test basic scheduled transaction listing without filters."""
st1 = ynab.ScheduledTransactionDetail(
id="st-1",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-120_000, # -$120.00 outflow
memo="Netflix subscription",
flag_color=ynab.TransactionFlagColor.RED,
flag_name="Entertainment",
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Netflix",
category_id="cat-1",
category_name="Entertainment",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st2 = ynab.ScheduledTransactionDetail(
id="st-2",
date_first=date(2024, 1, 15),
date_next=date(2024, 1, 29),
frequency="weekly",
amount=-5_000, # -$5.00 outflow
memo="Weekly coffee",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-2",
payee_name="Coffee Shop",
category_id="cat-2",
category_name="Dining Out",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Add a deleted scheduled transaction that should be filtered out
st_deleted = ynab.ScheduledTransactionDetail(
id="st-deleted",
date_first=date(2024, 1, 1),
date_next=date(2024, 3, 1),
frequency="monthly",
amount=-50_000,
memo="Deleted subscription",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-3",
payee_name="Old Service",
category_id="cat-1",
category_name="Entertainment",
transfer_account_id=None,
deleted=True, # Should be excluded
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [
st2,
st1,
st_deleted,
]
result = await mcp_client.call_tool("list_scheduled_transactions", {})
response_data = extract_response_data(result)
# Should have 2 scheduled transactions (deleted one excluded)
assert len(response_data["scheduled_transactions"]) == 2
# Should be sorted by next date ascending (earliest scheduled first)
assert response_data["scheduled_transactions"][0]["id"] == "st-2"
assert response_data["scheduled_transactions"][0]["date_next"] == "2024-01-29"
assert response_data["scheduled_transactions"][0]["frequency"] == "weekly"
assert response_data["scheduled_transactions"][0]["amount"] == "-5"
assert response_data["scheduled_transactions"][0]["payee_name"] == "Coffee Shop"
assert response_data["scheduled_transactions"][1]["id"] == "st-1"
assert response_data["scheduled_transactions"][1]["date_next"] == "2024-02-01"
assert response_data["scheduled_transactions"][1]["frequency"] == "monthly"
assert response_data["scheduled_transactions"][1]["amount"] == "-120"
assert response_data["scheduled_transactions"][1]["flag"] == "Entertainment (Red)"
# Check pagination
assert response_data["pagination"]["total_count"] == 2
assert response_data["pagination"]["has_more"] is False
async def test_list_scheduled_transactions_with_frequency_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by frequency."""
st_monthly = ynab.ScheduledTransactionDetail(
id="st-monthly",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-100_000,
memo="Monthly bill",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Electric Company",
category_id="cat-1",
category_name="Utilities",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st_weekly = ynab.ScheduledTransactionDetail(
id="st-weekly",
date_first=date(2024, 1, 8),
date_next=date(2024, 1, 15),
frequency="weekly",
amount=-2_500,
memo="Weekly groceries",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-2",
payee_name="Grocery Store",
category_id="cat-2",
category_name="Groceries",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_monthly, st_weekly]
# Test filtering by monthly frequency
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"frequency": "monthly"}
)
response_data = extract_response_data(result)
# Should only have the monthly scheduled transaction
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-monthly"
assert response_data["scheduled_transactions"][0]["frequency"] == "monthly"
assert (
response_data["scheduled_transactions"][0]["payee_name"] == "Electric Company"
)
# Check pagination
assert response_data["pagination"]["total_count"] == 1
assert response_data["pagination"]["has_more"] is False
async def test_list_scheduled_transactions_with_upcoming_days_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by upcoming days."""
# Scheduled for 5 days from now
st_soon = ynab.ScheduledTransactionDetail(
id="st-soon",
date_first=date(2024, 1, 1),
date_next=date(2024, 1, 20), # 5 days from "today" (2024-01-15)
frequency="monthly",
amount=-50_000,
memo="Due soon",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Due Soon Co",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Scheduled for 15 days from now
st_later = ynab.ScheduledTransactionDetail(
id="st-later",
date_first=date(2024, 1, 1),
date_next=date(2024, 1, 30), # 15 days from "today" (2024-01-15)
frequency="monthly",
amount=-75_000,
memo="Due later",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-2",
payee_name="Due Later Co",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_soon, st_later]
# Mock datetime.now() to return a fixed date for testing
from unittest.mock import patch
import server
with patch.object(server, "datetime") as mock_datetime:
mock_datetime.now.return_value.date.return_value = date(2024, 1, 15)
# Test filtering by upcoming 7 days
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"upcoming_days": 7}
)
response_data = extract_response_data(result)
# Should only have the transaction due within 7 days
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-soon"
assert response_data["scheduled_transactions"][0]["payee_name"] == "Due Soon Co"
async def test_list_scheduled_transactions_with_amount_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by amount range."""
st_small = ynab.ScheduledTransactionDetail(
id="st-small",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-1_000, # -$1.00
memo="Small expense",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Small Store",
category_id="cat-1",
category_name="Misc",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st_large = ynab.ScheduledTransactionDetail(
id="st-large",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-500_000, # -$500.00
memo="Large expense",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-2",
payee_name="Large Store",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_small, st_large]
# Test filtering by minimum amount (expenses <= -$10, i.e., larger expenses)
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"max_amount": -10}
)
response_data = extract_response_data(result)
# Should only have the large transaction (<= -$10)
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-large"
assert response_data["scheduled_transactions"][0]["amount"] == "-500"
async def test_list_scheduled_transactions_with_account_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by account."""
st_checking = ynab.ScheduledTransactionDetail(
id="st-checking",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-100_000,
memo="Checking account expense",
flag_color=None,
flag_name=None,
account_id="acc-checking",
account_name="Checking",
payee_id="payee-1",
payee_name="Merchant A",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st_savings = ynab.ScheduledTransactionDetail(
id="st-savings",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-50_000,
memo="Savings account expense",
flag_color=None,
flag_name=None,
account_id="acc-savings",
account_name="Savings",
payee_id="payee-2",
payee_name="Merchant B",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_checking, st_savings]
# Test filtering by checking account
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"account_id": "acc-checking"}
)
response_data = extract_response_data(result)
# Should only have the checking account scheduled transaction
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-checking"
assert response_data["scheduled_transactions"][0]["account_name"] == "Checking"
async def test_list_scheduled_transactions_with_category_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by category."""
st_bills = ynab.ScheduledTransactionDetail(
id="st-bills",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-100_000,
memo="Monthly bill",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Utility Co",
category_id="cat-bills",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st_entertainment = ynab.ScheduledTransactionDetail(
id="st-entertainment",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-1_500,
memo="Entertainment subscription",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-2",
payee_name="Streaming Service",
category_id="cat-entertainment",
category_name="Entertainment",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [
st_bills,
st_entertainment,
]
# Test filtering by bills category
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"category_id": "cat-bills"}
)
response_data = extract_response_data(result)
# Should only have the bills category scheduled transaction
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-bills"
assert response_data["scheduled_transactions"][0]["category_name"] == "Bills"
async def test_list_scheduled_transactions_with_min_amount_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by minimum amount."""
st_small = ynab.ScheduledTransactionDetail(
id="st-small",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-1_000, # -$1.00
memo="Small expense",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Small Store",
category_id="cat-1",
category_name="Misc",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st_large = ynab.ScheduledTransactionDetail(
id="st-large",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-500_000, # -$500.00
memo="Large expense",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-2",
payee_name="Large Store",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_small, st_large]
# Test filtering by minimum amount (only expenses >= -$5, excludes -$500)
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"min_amount": -5}
)
response_data = extract_response_data(result)
# Should only have the small transaction (>= -$5)
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-small"
assert response_data["scheduled_transactions"][0]["amount"] == "-1"
async def test_list_scheduled_transactions_with_payee_filter(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing filtered by payee."""
st_netflix = ynab.ScheduledTransactionDetail(
id="st-netflix",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-1_500,
memo="Netflix subscription",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-netflix",
payee_name="Netflix",
category_id="cat-1",
category_name="Entertainment",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
st_spotify = ynab.ScheduledTransactionDetail(
id="st-spotify",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-1_000,
memo="Spotify subscription",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-spotify",
payee_name="Spotify",
category_id="cat-1",
category_name="Entertainment",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_netflix, st_spotify]
# Test filtering by Netflix payee
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"payee_id": "payee-netflix"}
)
response_data = extract_response_data(result)
# Should only have the Netflix scheduled transaction
assert len(response_data["scheduled_transactions"]) == 1
assert response_data["scheduled_transactions"][0]["id"] == "st-netflix"
assert response_data["scheduled_transactions"][0]["payee_name"] == "Netflix"
async def test_list_scheduled_transactions_pagination(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing with pagination."""
# Create multiple scheduled transactions
scheduled_transactions = []
for i in range(15):
st = ynab.ScheduledTransactionDetail(
id=f"st-{i}",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, i + 1), # Different next dates for sorting
frequency="monthly",
amount=-10_000 * (i + 1),
memo=f"Transaction {i}",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id=f"payee-{i}",
payee_name=f"Payee {i}",
category_id="cat-1",
category_name="Bills",
transfer_account_id=None,
deleted=False,
subtransactions=[],
)
scheduled_transactions.append(st)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = scheduled_transactions
# Test first page with limit
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"limit": 5, "offset": 0}
)
response_data = extract_response_data(result)
# Should have 5 scheduled transactions
assert len(response_data["scheduled_transactions"]) == 5
assert response_data["pagination"]["total_count"] == 15
assert response_data["pagination"]["has_more"] is True
# Test second page
result = await mcp_client.call_tool(
"list_scheduled_transactions", {"limit": 5, "offset": 5}
)
response_data = extract_response_data(result)
# Should have next 5 scheduled transactions
assert len(response_data["scheduled_transactions"]) == 5
assert response_data["pagination"]["total_count"] == 15
assert response_data["pagination"]["has_more"] is True
async def test_list_scheduled_transactions_with_subtransactions(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing with split transactions (subtransactions)."""
# Create scheduled subtransactions
sub1 = ynab.ScheduledSubTransaction(
id="sub-1",
scheduled_transaction_id="st-split",
amount=-30_000, # -$30.00 for groceries
memo="Groceries portion",
payee_id="payee-1",
payee_name="Grocery Store",
category_id="cat-groceries",
category_name="Groceries",
transfer_account_id=None,
deleted=False,
)
sub2 = ynab.ScheduledSubTransaction(
id="sub-2",
scheduled_transaction_id="st-split",
amount=-20_000, # -$20.00 for household
memo="Household portion",
payee_id="payee-1",
payee_name="Grocery Store",
category_id="cat-household",
category_name="Household",
transfer_account_id=None,
deleted=False,
)
st_split = ynab.ScheduledTransactionDetail(
id="st-split",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-50_000, # -$50.00 total (should equal sum of subtransactions)
memo="Split transaction",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Grocery Store",
category_id=None, # Split transactions don't have a main category
category_name=None,
transfer_account_id=None,
deleted=False,
subtransactions=[sub1, sub2],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_split]
result = await mcp_client.call_tool("list_scheduled_transactions", {})
response_data = extract_response_data(result)
# Should have 1 scheduled transaction with subtransactions
assert len(response_data["scheduled_transactions"]) == 1
st = response_data["scheduled_transactions"][0]
assert st["id"] == "st-split"
assert st["amount"] == "-50"
assert st["memo"] == "Split transaction"
# Check subtransactions
assert len(st["subtransactions"]) == 2
assert st["subtransactions"][0]["id"] == "sub-1"
assert st["subtransactions"][0]["amount"] == "-30"
assert st["subtransactions"][0]["category_name"] == "Groceries"
assert st["subtransactions"][1]["id"] == "sub-2"
assert st["subtransactions"][1]["amount"] == "-20"
assert st["subtransactions"][1]["category_name"] == "Household"
async def test_list_scheduled_transactions_with_deleted_subtransactions(
mock_repository: MagicMock,
mcp_client: Client[FastMCPTransport],
) -> None:
"""Test scheduled transaction listing excludes deleted subtransactions."""
# Create active and deleted scheduled subtransactions
sub_active = ynab.ScheduledSubTransaction(
id="sub-active",
scheduled_transaction_id="st-mixed",
amount=-30_000, # -$30.00
memo="Active subtransaction",
payee_id="payee-1",
payee_name="Store",
category_id="cat-1",
category_name="Active Category",
transfer_account_id=None,
deleted=False,
)
sub_deleted = ynab.ScheduledSubTransaction(
id="sub-deleted",
scheduled_transaction_id="st-mixed",
amount=-20_000, # -$20.00
memo="Deleted subtransaction",
payee_id="payee-1",
payee_name="Store",
category_id="cat-2",
category_name="Deleted Category",
transfer_account_id=None,
deleted=True, # Should be excluded
)
st_mixed = ynab.ScheduledTransactionDetail(
id="st-mixed",
date_first=date(2024, 1, 1),
date_next=date(2024, 2, 1),
frequency="monthly",
amount=-50_000, # -$50.00 total
memo="Mixed subtransactions",
flag_color=None,
flag_name=None,
account_id="acc-1",
account_name="Checking",
payee_id="payee-1",
payee_name="Store",
category_id=None,
category_name=None,
transfer_account_id=None,
deleted=False,
subtransactions=[sub_active, sub_deleted],
)
# Mock repository to return scheduled transactions
mock_repository.get_scheduled_transactions.return_value = [st_mixed]
result = await mcp_client.call_tool("list_scheduled_transactions", {})
response_data = extract_response_data(result)
# Should have 1 scheduled transaction with only active subtransactions
assert len(response_data["scheduled_transactions"]) == 1
st = response_data["scheduled_transactions"][0]
assert st["id"] == "st-mixed"
assert st["amount"] == "-50"
# Should only have the active subtransaction (deleted one excluded)
assert len(st["subtransactions"]) == 1
assert st["subtransactions"][0]["id"] == "sub-active"
assert st["subtransactions"][0]["category_name"] == "Active Category"