Skip to main content
Glama
test_ynab_client.py49.4 kB
"""Tests for YNAB client.""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.ynab_mcp.exceptions import YNABValidationError from src.ynab_mcp.ynab_client import YNABClient @pytest.fixture def mock_ynab_sdk(): """Mock YNAB SDK.""" with patch("src.ynab_mcp.ynab_client.YNAB") as mock: yield mock @pytest.fixture def client(mock_ynab_sdk): """Create YNABClient instance with mocked SDK.""" return YNABClient("test_token") def test_client_initialization(): """Test client initializes with access token.""" client = YNABClient("test_token") assert client._access_token == "test_token" assert client.api_base_url == "https://api.ynab.com/v1" def test_client_initialization_fails_without_token(): """Test client raises error without access token.""" with pytest.raises( YNABValidationError, match="YNAB_ACCESS_TOKEN environment variable must be set" ): YNABClient(None) @pytest.mark.asyncio async def test_get_budgets(client, mock_ynab_sdk): """Test get_budgets returns formatted budget list.""" # Mock budget response mock_budget = MagicMock() mock_budget.id = "budget-123" mock_budget.name = "Test Budget" mock_budget.last_modified_on = "2025-10-05" mock_budget.currency_format.iso_code = "USD" mock_budget.currency_format.example_format = "$123.45" mock_budget.currency_format.currency_symbol = "$" mock_response = MagicMock() mock_response.data.budgets = [mock_budget] client.client.budgets.get_budgets.return_value = mock_response result = await client.get_budgets() assert len(result) == 1 assert result[0]["id"] == "budget-123" assert result[0]["name"] == "Test Budget" assert result[0]["currency_format"]["iso_code"] == "USD" @pytest.mark.asyncio async def test_get_accounts(client, mock_ynab_sdk): """Test get_accounts returns formatted account list.""" # Mock account response mock_account = MagicMock() mock_account.id = "account-123" mock_account.name = "Checking" mock_account.type = "checking" mock_account.on_budget = True mock_account.closed = False mock_account.balance = 10000000 # $10,000 in milliunits mock_account.deleted = False mock_response = MagicMock() mock_response.data.accounts = [mock_account] client.client.accounts.get_accounts.return_value = mock_response result = await client.get_accounts("budget-123") assert len(result) == 1 assert result[0]["id"] == "account-123" assert result[0]["name"] == "Checking" assert result[0]["balance"] == 10000.0 # Converted from milliunits @pytest.mark.asyncio async def test_get_accounts_skips_deleted(client, mock_ynab_sdk): """Test get_accounts skips deleted accounts.""" # Mock deleted account mock_account = MagicMock() mock_account.deleted = True mock_response = MagicMock() mock_response.data.accounts = [mock_account] client.client.accounts.get_accounts.return_value = mock_response result = await client.get_accounts("budget-123") assert len(result) == 0 @pytest.mark.asyncio async def test_get_categories(client, mock_ynab_sdk): """Test get_categories returns formatted category list.""" # Mock category response mock_category = MagicMock() mock_category.id = "cat-123" mock_category.name = "Groceries" mock_category.balance = 50000 # $50 in milliunits mock_category.hidden = False mock_category.deleted = False mock_group = MagicMock() mock_group.id = "group-123" mock_group.name = "Food" mock_group.hidden = False mock_group.categories = [mock_category] mock_response = MagicMock() mock_response.data.category_groups = [mock_group] client.client.categories.get_categories.return_value = mock_response result = await client.get_categories("budget-123") assert len(result) == 1 assert result[0]["name"] == "Food" assert len(result[0]["categories"]) == 1 assert result[0]["categories"][0]["name"] == "Groceries" assert result[0]["categories"][0]["balance"] == 50.0 @pytest.mark.asyncio async def test_get_categories_skips_hidden_by_default(client, mock_ynab_sdk): """Test get_categories skips hidden categories by default.""" # Mock hidden category mock_category = MagicMock() mock_category.hidden = True mock_category.deleted = False mock_group = MagicMock() mock_group.id = "group-123" mock_group.name = "Hidden Group" mock_group.hidden = False mock_group.categories = [mock_category] mock_response = MagicMock() mock_response.data.category_groups = [mock_group] client.client.categories.get_categories.return_value = mock_response result = await client.get_categories("budget-123", include_hidden=False) # Should skip the group since it has no visible categories assert len(result) == 0 @pytest.mark.asyncio async def test_get_categories_includes_hidden_when_requested(client, mock_ynab_sdk): """Test get_categories includes hidden categories when requested.""" # Mock hidden category mock_category = MagicMock() mock_category.id = "cat-123" mock_category.name = "Hidden Cat" mock_category.balance = 0 mock_category.hidden = True mock_category.deleted = False mock_group = MagicMock() mock_group.id = "group-123" mock_group.name = "Group" mock_group.hidden = False mock_group.categories = [mock_category] mock_response = MagicMock() mock_response.data.category_groups = [mock_group] client.client.categories.get_categories.return_value = mock_response result = await client.get_categories("budget-123", include_hidden=True) assert len(result) == 1 assert result[0]["categories"][0]["hidden"] @pytest.mark.asyncio async def test_milliunits_conversion(): """Test milliunits conversion for various amounts.""" YNABClient("test_token") # Test conversion from milliunits to dollars assert 10000000 / 1000 == 10000.0 assert 1234567 / 1000 == 1234.567 assert -50000 / 1000 == -50.0 # Test conversion from dollars to milliunits assert int(100.50 * 1000) == 100500 assert int(-25.75 * 1000) == -25750 @pytest.mark.asyncio async def test_search_transactions_handles_null_fields(client): """Test search_transactions handles null payee_name and memo.""" with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = { "data": { "transactions": [ { "id": "txn-1", "date": "2025-10-01", "amount": -5000, "payee_name": None, "memo": None, }, { "id": "txn-2", "date": "2025-10-02", "amount": -3000, "payee_name": "Store", "memo": "groceries", }, ] } } result = await client.search_transactions("budget-123", "groceries") # Should find the transaction with "groceries" in memo assert result["count"] == 1 assert result["transactions"][0]["id"] == "txn-2" @pytest.mark.asyncio async def test_pagination_calculations(client): """Test pagination metadata calculations.""" # Mock 250 transactions transactions = [ { "id": f"txn-{i}", "date": "2025-10-01", "amount": -1000, } for i in range(250) ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transactions": transactions}} # Get page 1 with limit 100 result = await client.get_transactions("budget-123", limit=100, page=1) assert result["pagination"]["page"] == 1 assert result["pagination"]["per_page"] == 100 assert result["pagination"]["total_count"] == 250 assert result["pagination"]["total_pages"] == 3 assert result["pagination"]["has_next_page"] assert not result["pagination"]["has_prev_page"] assert len(result["transactions"]) == 100 # Get page 3 (last page) result = await client.get_transactions("budget-123", limit=100, page=3) assert result["pagination"]["page"] == 3 assert not result["pagination"]["has_next_page"] assert result["pagination"]["has_prev_page"] assert len(result["transactions"]) == 50 # Remaining transactions @pytest.mark.asyncio async def test_get_category_spending_summary(client): """Test get_category_spending_summary aggregates correctly.""" # Mock transactions over 3 months transactions = [ # January 2025 {"id": "txn-1", "date": "2025-01-15", "amount": -10000, "category_id": "cat-123"}, {"id": "txn-2", "date": "2025-01-20", "amount": -15000, "category_id": "cat-123"}, # February 2025 {"id": "txn-3", "date": "2025-02-10", "amount": -12000, "category_id": "cat-123"}, {"id": "txn-4", "date": "2025-02-25", "amount": -13000, "category_id": "cat-123"}, # March 2025 {"id": "txn-5", "date": "2025-03-05", "amount": -11000, "category_id": "cat-123"}, # Different category (should be excluded) {"id": "txn-6", "date": "2025-01-10", "amount": -5000, "category_id": "cat-999"}, # Outside date range (should be excluded) {"id": "txn-7", "date": "2025-04-01", "amount": -20000, "category_id": "cat-123"}, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transactions": transactions}} result = await client.get_category_spending_summary( "budget-123", "cat-123", "2025-01-01", "2025-03-31", include_graph=False ) # Total: -10 + -15 + -12 + -13 + -11 = -61 assert result["total_spent"] == -61.0 assert result["transaction_count"] == 5 assert result["num_months"] == 3 assert result["average_per_month"] == pytest.approx(-20.333, rel=0.01) assert "graph" not in result # Graph should not be included # Check monthly breakdown assert len(result["monthly_breakdown"]) == 3 assert result["monthly_breakdown"][0]["month"] == "2025-01" assert result["monthly_breakdown"][0]["spent"] == -25.0 assert result["monthly_breakdown"][1]["month"] == "2025-02" assert result["monthly_breakdown"][1]["spent"] == -25.0 assert result["monthly_breakdown"][2]["month"] == "2025-03" assert result["monthly_breakdown"][2]["spent"] == -11.0 @pytest.mark.asyncio async def test_compare_spending_by_year(client): """Test compare_spending_by_year calculates year-over-year correctly.""" # Mock transactions over 3 years transactions = [ # 2023: $100 total {"id": "txn-1", "date": "2023-03-15", "amount": -50000, "category_id": "cat-123"}, {"id": "txn-2", "date": "2023-09-20", "amount": -50000, "category_id": "cat-123"}, # 2024: $150 total (50% increase) {"id": "txn-3", "date": "2024-02-10", "amount": -75000, "category_id": "cat-123"}, {"id": "txn-4", "date": "2024-08-25", "amount": -75000, "category_id": "cat-123"}, # 2025: $120 total (20% decrease) {"id": "txn-5", "date": "2025-01-05", "amount": -60000, "category_id": "cat-123"}, {"id": "txn-6", "date": "2025-06-15", "amount": -60000, "category_id": "cat-123"}, # Different category (should be excluded) {"id": "txn-7", "date": "2024-05-10", "amount": -30000, "category_id": "cat-999"}, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transactions": transactions}} result = await client.compare_spending_by_year( "budget-123", "cat-123", 2023, 3, include_graph=False ) assert result["years"] == "2023-2025" assert result["average_per_year"] == pytest.approx(-123.333, rel=0.01) assert "graph" not in result # Graph should not be included # Check yearly comparison assert len(result["yearly_comparison"]) == 3 # 2023 assert result["yearly_comparison"][0]["year"] == "2023" assert result["yearly_comparison"][0]["total_spent"] == -100.0 assert "change_from_previous" not in result["yearly_comparison"][0] # 2024 (50% increase in spending - more negative) assert result["yearly_comparison"][1]["year"] == "2024" assert result["yearly_comparison"][1]["total_spent"] == -150.0 assert result["yearly_comparison"][1]["change_from_previous"] == -50.0 # Change is -50 / |-100| * 100 = -50% (spending increased) assert result["yearly_comparison"][1]["percent_change"] == pytest.approx(-50.0, rel=0.01) # 2025 (20% decrease in spending - less negative) assert result["yearly_comparison"][2]["year"] == "2025" assert result["yearly_comparison"][2]["total_spent"] == -120.0 assert result["yearly_comparison"][2]["change_from_previous"] == 30.0 # Change is 30 / |-150| * 100 = 20% (spending decreased) assert result["yearly_comparison"][2]["percent_change"] == pytest.approx(20.0, rel=0.01) @pytest.mark.asyncio async def test_compare_spending_by_year_handles_zero_spending(client): """Test compare_spending_by_year handles years with zero spending.""" # Mock transactions with gap year transactions = [ {"id": "txn-1", "date": "2023-03-15", "amount": -10000, "category_id": "cat-123"}, # 2024 has no transactions {"id": "txn-2", "date": "2025-01-20", "amount": -10000, "category_id": "cat-123"}, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transactions": transactions}} result = await client.compare_spending_by_year( "budget-123", "cat-123", 2023, 3, include_graph=False ) # Check that 2024 has zero spending assert result["yearly_comparison"][1]["year"] == "2024" assert result["yearly_comparison"][1]["total_spent"] == 0.0 assert "graph" not in result # Check that percentage change handles zero correctly # Change is 10 / |-10| * 100 = 100% assert result["yearly_comparison"][1]["change_from_previous"] == 10.0 assert result["yearly_comparison"][1]["percent_change"] == pytest.approx(100.0, rel=0.01) @pytest.mark.asyncio async def test_get_category_spending_summary_with_graph(client): """Test get_category_spending_summary includes graph when requested.""" # Mock transactions over 2 months transactions = [ {"id": "txn-1", "date": "2025-01-15", "amount": -10000, "category_id": "cat-123"}, {"id": "txn-2", "date": "2025-02-10", "amount": -20000, "category_id": "cat-123"}, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transactions": transactions}} result = await client.get_category_spending_summary( "budget-123", "cat-123", "2025-01-01", "2025-02-28", include_graph=True ) # Verify graph is included assert "graph" in result assert isinstance(result["graph"], str) assert len(result["graph"]) > 0 # Check that graph contains the month labels assert "2025-01" in result["graph"] assert "2025-02" in result["graph"] @pytest.mark.asyncio async def test_compare_spending_by_year_with_graph(client): """Test compare_spending_by_year includes graph when requested.""" # Mock transactions over 2 years transactions = [ {"id": "txn-1", "date": "2023-03-15", "amount": -50000, "category_id": "cat-123"}, {"id": "txn-2", "date": "2024-02-10", "amount": -75000, "category_id": "cat-123"}, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transactions": transactions}} result = await client.compare_spending_by_year( "budget-123", "cat-123", 2023, 2, include_graph=True ) # Verify graph is included assert "graph" in result assert isinstance(result["graph"], str) assert len(result["graph"]) > 0 # Check that graph contains the year labels assert "2023" in result["graph"] assert "2024" in result["graph"] @pytest.mark.asyncio async def test_get_scheduled_transactions(client): """Test get_scheduled_transactions returns formatted scheduled transactions.""" # Mock scheduled transactions response scheduled_txns = [ { "id": "sched-1", "date_first": "2025-11-01", "date_next": "2025-11-01", "frequency": "monthly", "amount": -100000, "memo": "Rent", "flag_color": "red", "account_id": "account-123", "account_name": "Checking", "payee_id": "payee-1", "payee_name": "Landlord", "category_id": "cat-123", "category_name": "Housing", }, { "id": "sched-2", "date_first": "2025-10-15", "date_next": "2025-10-29", "frequency": "everyOtherWeek", "amount": 200000, "memo": "Paycheck", "flag_color": None, "account_id": "account-123", "account_name": "Checking", "payee_id": "payee-2", "payee_name": "Employer", "category_id": None, "category_name": None, }, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"scheduled_transactions": scheduled_txns}} result = await client.get_scheduled_transactions("budget-123") assert len(result) == 2 # Check first scheduled transaction assert result[0]["id"] == "sched-1" assert result[0]["frequency"] == "monthly" assert result[0]["amount"] == -100.0 assert result[0]["payee_name"] == "Landlord" assert result[0]["memo"] == "Rent" # Check second scheduled transaction assert result[1]["id"] == "sched-2" assert result[1]["frequency"] == "everyOtherWeek" assert result[1]["amount"] == 200.0 assert result[1]["payee_name"] == "Employer" @pytest.mark.asyncio async def test_create_scheduled_transaction(client): """Test create_scheduled_transaction sends correct data.""" # Mock successful creation response created_txn = { "id": "sched-new", "date_first": "2025-12-01", "date_next": "2025-12-01", "frequency": "monthly", "amount": -50000, "memo": "Internet Bill", "flag_color": "blue", "account_id": "account-123", "payee_name": "ISP Provider", "category_id": "cat-456", } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"scheduled_transaction": created_txn}} result = await client.create_scheduled_transaction( budget_id="budget-123", account_id="account-123", date_first="2025-12-01", frequency="monthly", amount=-50.0, payee_name="ISP Provider", category_id="cat-456", memo="Internet Bill", flag_color="blue", ) assert result["id"] == "sched-new" assert result["frequency"] == "monthly" assert result["amount"] == -50.0 assert result["payee_name"] == "ISP Provider" assert result["memo"] == "Internet Bill" assert result["flag_color"] == "blue" # Verify the API call was made with correct data mock_retry.assert_called_once() call_args = mock_retry.call_args assert "scheduled_transaction" in call_args.kwargs["json"] txn_data = call_args.kwargs["json"]["scheduled_transaction"] assert txn_data["amount"] == -50000 # Converted to milliunits assert txn_data["frequency"] == "monthly" @pytest.mark.asyncio async def test_delete_scheduled_transaction(client): """Test delete_scheduled_transaction sends correct request.""" # Mock successful deletion response deleted_txn = { "id": "sched-123", "deleted": True, } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"scheduled_transaction": deleted_txn}} result = await client.delete_scheduled_transaction("budget-123", "sched-123") assert result["deleted"] assert result["scheduled_transaction"]["id"] == "sched-123" # Verify DELETE request was made to correct URL mock_retry.assert_called_once() call_args = mock_retry.call_args assert "sched-123" in call_args.args[1] @pytest.mark.asyncio async def test_get_transaction(client): """Test get_transaction returns formatted transaction with subtransactions.""" # Mock transaction response with subtransactions txn_data = { "id": "txn-123", "date": "2025-10-06", "amount": -80000, # -$80 in milliunits "memo": "Shopping", "cleared": "cleared", "approved": True, "account_id": "account-123", "account_name": "Checking", "payee_id": "payee-1", "payee_name": "Target", "category_id": None, # Null for split transactions "category_name": None, "transfer_account_id": None, "subtransactions": [ { "id": "sub-1", "amount": -50000, "memo": "Groceries", "payee_id": None, "payee_name": "Target", "category_id": "cat-123", "category_name": "Food", }, { "id": "sub-2", "amount": -30000, "memo": "Household", "payee_id": None, "payee_name": "Target", "category_id": "cat-456", "category_name": "Household Items", }, ], } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transaction": txn_data}} result = await client.get_transaction("budget-123", "txn-123") # Check main transaction details assert result["id"] == "txn-123" assert result["date"] == "2025-10-06" assert result["amount"] == -80.0 # Converted from milliunits assert result["memo"] == "Shopping" assert result["payee_name"] == "Target" assert result["category_id"] is None # Split transactions have null category_id # Check subtransactions assert result["subtransactions"] is not None assert len(result["subtransactions"]) == 2 # Check first subtransaction assert result["subtransactions"][0]["id"] == "sub-1" assert result["subtransactions"][0]["amount"] == -50.0 assert result["subtransactions"][0]["memo"] == "Groceries" assert result["subtransactions"][0]["category_name"] == "Food" # Check second subtransaction assert result["subtransactions"][1]["id"] == "sub-2" assert result["subtransactions"][1]["amount"] == -30.0 assert result["subtransactions"][1]["memo"] == "Household" assert result["subtransactions"][1]["category_name"] == "Household Items" # Verify the API call was made to correct URL mock_retry.assert_called_once() call_args = mock_retry.call_args assert "txn-123" in call_args.args[1] @pytest.mark.asyncio async def test_get_transaction_without_subtransactions(client): """Test get_transaction for regular transactions without splits.""" # Mock regular transaction response (no subtransactions) txn_data = { "id": "txn-456", "date": "2025-10-05", "amount": -25000, "memo": "Coffee", "cleared": "uncleared", "approved": False, "account_id": "account-123", "account_name": "Checking", "payee_id": "payee-2", "payee_name": "Cafe", "category_id": "cat-789", "category_name": "Dining Out", "transfer_account_id": None, } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transaction": txn_data}} result = await client.get_transaction("budget-123", "txn-456") # Check transaction details assert result["id"] == "txn-456" assert result["amount"] == -25.0 assert result["category_name"] == "Dining Out" assert result["subtransactions"] is None # No subtransactions for regular transactions @pytest.mark.asyncio async def test_create_split_transaction(client): """Test create_split_transaction sends correct data and handles response.""" # Mock successful creation response created_txn = { "id": "txn-new", "date": "2025-10-06", "amount": -80000, "memo": "Shopping", "cleared": "uncleared", "approved": False, "account_id": "account-123", "account_name": "Checking", "payee_id": None, "payee_name": "Target", "category_id": None, # Null for split transactions "category_name": None, "subtransactions": [ { "id": "sub-1", "amount": -50000, "memo": "Food", "payee_id": None, "payee_name": "Target", "category_id": "cat-123", "category_name": "Groceries", }, { "id": "sub-2", "amount": -30000, "memo": "Supplies", "payee_id": None, "payee_name": "Target", "category_id": "cat-456", "category_name": "Household", }, ], } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"transaction": created_txn}} subtransactions = [ {"amount": -50.0, "category_id": "cat-123", "memo": "Food"}, {"amount": -30.0, "category_id": "cat-456", "memo": "Supplies"}, ] result = await client.create_split_transaction( budget_id="budget-123", account_id="account-123", date="2025-10-06", amount=-80.0, subtransactions=subtransactions, payee_name="Target", memo="Shopping", cleared="uncleared", approved=False, ) # Check main transaction assert result["id"] == "txn-new" assert result["amount"] == -80.0 assert result["payee_name"] == "Target" assert result["category_id"] is None # Check subtransactions assert len(result["subtransactions"]) == 2 assert result["subtransactions"][0]["amount"] == -50.0 assert result["subtransactions"][0]["category_name"] == "Groceries" assert result["subtransactions"][1]["amount"] == -30.0 assert result["subtransactions"][1]["category_name"] == "Household" # Verify the API call was made with correct data mock_retry.assert_called_once() call_args = mock_retry.call_args assert "transaction" in call_args.kwargs["json"] txn_data = call_args.kwargs["json"]["transaction"] assert txn_data["amount"] == -80000 # Converted to milliunits assert txn_data["category_id"] is None # Must be null for split transactions assert len(txn_data["subtransactions"]) == 2 assert txn_data["subtransactions"][0]["amount"] == -50000 assert txn_data["subtransactions"][1]["amount"] == -30000 @pytest.mark.asyncio async def test_prepare_split_for_matching(client): """Test prepare_split_for_matching fetches original and creates split.""" # Mock original transaction original_txn = { "id": "txn-original", "date": "2025-10-06", "amount": -80000, "memo": "Shopping", "cleared": "cleared", "approved": True, "account_id": "account-123", "account_name": "Checking", "payee_id": "payee-1", "payee_name": "Target", "category_id": "cat-999", "category_name": "Uncategorized", "transfer_account_id": None, "subtransactions": None, } # Mock created split transaction created_split = { "id": "txn-split", "date": "2025-10-06", "amount": -80000, "memo": "Shopping", "cleared": "cleared", "approved": False, # Created as unapproved "account_id": "account-123", "account_name": "Checking", "payee_id": None, "payee_name": "Target", "category_id": None, "category_name": None, "subtransactions": [ { "id": "sub-1", "amount": -50000, "memo": "Food", "payee_id": None, "payee_name": "Target", "category_id": "cat-123", "category_name": "Groceries", }, { "id": "sub-2", "amount": -30000, "memo": "Supplies", "payee_id": None, "payee_name": "Target", "category_id": "cat-456", "category_name": "Household", }, ], } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API responses: first call for get_transaction, second for create_split_transaction mock_retry.side_effect = [ {"data": {"transaction": original_txn}}, {"data": {"transaction": created_split}}, ] subtransactions = [ {"amount": -50.0, "category_id": "cat-123", "memo": "Food"}, {"amount": -30.0, "category_id": "cat-456", "memo": "Supplies"}, ] result = await client.prepare_split_for_matching( budget_id="budget-123", transaction_id="txn-original", subtransactions=subtransactions ) # Check original transaction info assert result["original_transaction"]["id"] == "txn-original" assert result["original_transaction"]["amount"] == -80.0 assert result["original_transaction"]["payee_name"] == "Target" # Check new split transaction assert result["new_split_transaction"]["id"] == "txn-split" assert result["new_split_transaction"]["approved"] is False # Unapproved for matching assert result["new_split_transaction"]["amount"] == -80.0 assert len(result["new_split_transaction"]["subtransactions"]) == 2 # Check instructions are provided assert "instructions" in result assert "match" in result["instructions"].lower() # Verify two API calls were made (get + create) assert mock_retry.call_count == 2 @pytest.mark.asyncio async def test_start_reconciliation(client): """Test start_reconciliation returns account data for reconciliation.""" # Mock account response account_data = { "id": "account-123", "name": "Checking Account", "balance": 1500000, # $1,500.00 in milliunits "cleared_balance": 1245670, # $1,245.67 in milliunits "uncleared_balance": 254330, # $254.33 in milliunits } # Mock transactions response transactions = [ # Cleared transactions { "id": "txn-1", "date": "2025-10-01", "amount": -10000, "cleared": "cleared", "deleted": False, }, { "id": "txn-2", "date": "2025-10-02", "amount": -15000, "cleared": "cleared", "deleted": False, }, { "id": "txn-3", "date": "2025-10-03", "amount": -20000, "cleared": "cleared", "deleted": False, }, # Uncleared transactions { "id": "txn-4", "date": "2025-10-04", "amount": -5000, "cleared": "uncleared", "deleted": False, }, { "id": "txn-5", "date": "2025-10-05", "amount": -3000, "cleared": "uncleared", "deleted": False, }, # Already reconciled (should be skipped) { "id": "txn-6", "date": "2025-09-30", "amount": -50000, "cleared": "reconciled", "deleted": False, }, # Deleted transaction (should be skipped) { "id": "txn-7", "date": "2025-10-06", "amount": -10000, "cleared": "cleared", "deleted": True, }, ] with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API responses: first call for account, second for transactions mock_retry.side_effect = [ {"data": {"account": account_data}}, {"data": {"transactions": transactions}}, ] result = await client.start_reconciliation("budget-123", "account-123") # Check account info assert result["account_id"] == "account-123" assert result["account_name"] == "Checking Account" assert result["cleared_balance"] == 1245.67 assert result["uncleared_balance"] == 254.33 assert result["total_balance"] == 1500.0 # Check transaction counts assert result["cleared_transaction_count"] == 3 assert result["uncleared_transaction_count"] == 2 # Check cleared transaction IDs assert len(result["cleared_transaction_ids"]) == 3 assert "txn-1" in result["cleared_transaction_ids"] assert "txn-2" in result["cleared_transaction_ids"] assert "txn-3" in result["cleared_transaction_ids"] # Reconciled and deleted should not be in the list assert "txn-6" not in result["cleared_transaction_ids"] assert "txn-7" not in result["cleared_transaction_ids"] # Verify two API calls were made assert mock_retry.call_count == 2 @pytest.mark.asyncio async def test_complete_reconciliation_matches(client): """Test complete_reconciliation when balances match.""" # Mock successful reconciliation reconciled_txn = { "id": "txn-1", "date": "2025-10-01", "amount": -10000, "cleared": "reconciled", } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API responses for each transaction update mock_retry.return_value = {"data": {"transaction": reconciled_txn}} cleared_ids = ["txn-1", "txn-2", "txn-3"] result = await client.complete_reconciliation( budget_id="budget-123", account_id="account-123", cleared_transaction_ids=cleared_ids, matches=True, ) # Check result assert result["status"] == "completed" assert result["reconciled_count"] == 3 assert "Successfully reconciled" in result["message"] # Verify three API calls were made (one for each transaction) assert mock_retry.call_count == 3 @pytest.mark.asyncio async def test_complete_reconciliation_discrepancy_no_adjustment(client): """Test complete_reconciliation when balances don't match, no adjustment.""" # Mock account response with cleared balance account_data = { "id": "account-123", "name": "Checking Account", "cleared_balance": 1245670, # $1,245.67 in milliunits } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"account": account_data}} result = await client.complete_reconciliation( budget_id="budget-123", account_id="account-123", cleared_transaction_ids=["txn-1", "txn-2"], matches=False, bank_balance=1250.00, create_adjustment=False, ) # Check result assert result["status"] == "discrepancy_found" assert result["ynab_cleared_balance"] == 1245.67 assert result["bank_balance"] == 1250.00 assert result["difference"] == pytest.approx(4.33, rel=0.01) assert result["adjustment_created"] is False # Verify only one API call was made (to get account balance) assert mock_retry.call_count == 1 @pytest.mark.asyncio async def test_complete_reconciliation_discrepancy_with_adjustment(client): """Test complete_reconciliation when balances don't match, with adjustment.""" # Mock account response account_data = { "id": "account-123", "name": "Checking Account", "cleared_balance": 1245670, # $1,245.67 in milliunits } # Mock created adjustment transaction adjustment_txn = { "id": "txn-adjustment", "date": "2025-10-15", "amount": 4330, # $4.33 in milliunits "payee_name": "Reconciliation Adjustment", "memo": "Adjustment to match bank balance of 1250.0", "cleared": "cleared", "approved": True, } with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API responses: first for account, second for creating adjustment mock_retry.side_effect = [ {"data": {"account": account_data}}, {"data": {"transaction": adjustment_txn}}, ] result = await client.complete_reconciliation( budget_id="budget-123", account_id="account-123", cleared_transaction_ids=["txn-1", "txn-2"], matches=False, bank_balance=1250.00, create_adjustment=True, ) # Check result assert result["status"] == "completed_with_adjustment" assert result["ynab_cleared_balance"] == 1245.67 assert result["bank_balance"] == 1250.00 assert result["difference"] == pytest.approx(4.33, rel=0.01) assert result["adjustment_created"] is True assert result["adjustment_transaction"]["id"] == "txn-adjustment" assert result["adjustment_transaction"]["amount"] == 4.33 assert "Created adjustment transaction" in result["message"] # Verify two API calls were made (get account + create transaction) assert mock_retry.call_count == 2 @pytest.mark.asyncio async def test_complete_reconciliation_requires_bank_balance_when_no_match(client): """Test complete_reconciliation raises error if bank_balance not provided when matches=False.""" with pytest.raises(Exception, match="bank_balance is required"): await client.complete_reconciliation( budget_id="budget-123", account_id="account-123", cleared_transaction_ids=["txn-1"], matches=False, bank_balance=None, # Missing required parameter ) @pytest.mark.asyncio async def test_complete_reconciliation_handles_failed_transactions(client): """Test complete_reconciliation handles some transactions failing to reconcile.""" with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock: first transaction succeeds, second fails, third succeeds mock_retry.side_effect = [ {"data": {"transaction": {"id": "txn-1", "cleared": "reconciled"}}}, Exception("API Error"), {"data": {"transaction": {"id": "txn-3", "cleared": "reconciled"}}}, ] cleared_ids = ["txn-1", "txn-2", "txn-3"] result = await client.complete_reconciliation( budget_id="budget-123", account_id="account-123", cleared_transaction_ids=cleared_ids, matches=True, ) # Check result - should have reconciled 2 out of 3 assert result["status"] == "completed" assert result["reconciled_count"] == 2 # Verify three API calls were attempted assert mock_retry.call_count == 3 @pytest.mark.asyncio async def test_get_underfunded_goals(client): """Test get_underfunded_goals returns summary of underfunded categories.""" # Mock month data with underfunded categories month_data = { "month": "2025-10-01", "categories": [ # Underfunded category 1 { "id": "cat-1", "name": "Emergency Fund", "budgeted": 500000, # $500 "goal_type": "TB", "goal_target": 1000000, # $1000 "goal_under_funded": 500000, # $500 needed }, # Underfunded category 2 { "id": "cat-2", "name": "Car Maintenance", "budgeted": 100000, # $100 "goal_type": "TBD", "goal_target": 200000, # $200 "goal_under_funded": 100000, # $100 needed }, # Fully funded category (should be excluded) { "id": "cat-3", "name": "Groceries", "budgeted": 500000, # $500 "goal_type": "NEED", "goal_target": 500000, # $500 "goal_under_funded": 0, # Fully funded }, # Category with no goal (should be excluded) { "id": "cat-4", "name": "Dining Out", "budgeted": 200000, # $200 "goal_type": None, "goal_target": None, "goal_under_funded": None, }, ], } # Mock category groups for mapping mock_cat_1 = MagicMock() mock_cat_1.id = "cat-1" mock_cat_2 = MagicMock() mock_cat_2.id = "cat-2" mock_cat_3 = MagicMock() mock_cat_3.id = "cat-3" mock_cat_4 = MagicMock() mock_cat_4.id = "cat-4" mock_group_1 = MagicMock() mock_group_1.name = "Savings" mock_group_1.categories = [mock_cat_1] mock_group_2 = MagicMock() mock_group_2.name = "Monthly Bills" mock_group_2.categories = [mock_cat_2, mock_cat_3, mock_cat_4] mock_response = MagicMock() mock_response.data.category_groups = [mock_group_1, mock_group_2] client.client.categories.get_categories.return_value = mock_response with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"month": month_data}} result = await client.get_underfunded_goals("budget-123", "2025-10-01") # Check summary assert result["month"] == "2025-10-01" assert result["total_underfunded"] == 600.0 # $500 + $100 assert result["underfunded_count"] == 2 # Check underfunded categories assert len(result["underfunded_categories"]) == 2 # Check first underfunded category assert result["underfunded_categories"][0]["category_name"] == "Emergency Fund" assert result["underfunded_categories"][0]["category_group"] == "Savings" assert result["underfunded_categories"][0]["budgeted"] == 500.0 assert result["underfunded_categories"][0]["goal_target"] == 1000.0 assert result["underfunded_categories"][0]["goal_under_funded"] == 500.0 assert result["underfunded_categories"][0]["goal_type"] == "TB" # Check second underfunded category assert result["underfunded_categories"][1]["category_name"] == "Car Maintenance" assert result["underfunded_categories"][1]["category_group"] == "Monthly Bills" assert result["underfunded_categories"][1]["budgeted"] == 100.0 assert result["underfunded_categories"][1]["goal_target"] == 200.0 assert result["underfunded_categories"][1]["goal_under_funded"] == 100.0 assert result["underfunded_categories"][1]["goal_type"] == "TBD" @pytest.mark.asyncio async def test_get_underfunded_goals_excludes_hidden_categories(client): """Test get_underfunded_goals excludes hidden categories.""" # Mock month data with hidden and visible underfunded categories month_data = { "month": "2025-10-01", "categories": [ # Visible underfunded category { "id": "cat-1", "name": "Emergency Fund", "budgeted": 500000, # $500 "goal_type": "TB", "goal_target": 1000000, # $1000 "goal_under_funded": 500000, # $500 needed "hidden": False, }, # Hidden underfunded category (should be excluded) { "id": "cat-2", "name": "Old Goal", "budgeted": 0, "goal_type": "TB", "goal_target": 500000, # $500 "goal_under_funded": 500000, # $500 needed "hidden": True, }, # Deleted underfunded category (should be excluded) { "id": "cat-3", "name": "Deleted Goal", "budgeted": 0, "goal_type": "TB", "goal_target": 300000, # $300 "goal_under_funded": 300000, # $300 needed "deleted": True, }, ], } # Mock category groups for mapping mock_cat_1 = MagicMock() mock_cat_1.id = "cat-1" mock_group_1 = MagicMock() mock_group_1.name = "Savings" mock_group_1.categories = [mock_cat_1] mock_response = MagicMock() mock_response.data.category_groups = [mock_group_1] client.client.categories.get_categories.return_value = mock_response with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"month": month_data}} result = await client.get_underfunded_goals("budget-123", "2025-10-01") # Should only include the visible category, not hidden or deleted assert result["total_underfunded"] == 500.0 # Only cat-1 assert result["underfunded_count"] == 1 assert len(result["underfunded_categories"]) == 1 assert result["underfunded_categories"][0]["category_name"] == "Emergency Fund" @pytest.mark.asyncio async def test_get_underfunded_goals_no_underfunded_categories(client): """Test get_underfunded_goals when all categories are fully funded.""" # Mock month data with no underfunded categories month_data = { "month": "2025-10-01", "categories": [ { "id": "cat-1", "name": "Emergency Fund", "budgeted": 1000000, "goal_type": "TB", "goal_target": 1000000, "goal_under_funded": 0, # Fully funded }, { "id": "cat-2", "name": "Groceries", "budgeted": 500000, "goal_type": "NEED", "goal_target": 500000, "goal_under_funded": 0, # Fully funded }, ], } mock_response = MagicMock() mock_response.data.category_groups = [] client.client.categories.get_categories.return_value = mock_response with patch.object(client, "_make_request_with_retry", new_callable=AsyncMock) as mock_retry: # Mock API response mock_retry.return_value = {"data": {"month": month_data}} result = await client.get_underfunded_goals("budget-123", "2025-10-01") # Check that no underfunded categories were found assert result["month"] == "2025-10-01" assert result["total_underfunded"] == 0.0 assert result["underfunded_count"] == 0 assert len(result["underfunded_categories"]) == 0

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/dgalarza/ynab-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server