This is page 2 of 2. Use http://codebase.md/chrisguidry/you-need-an-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .envrc ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── CLAUDE.md ├── DESIGN.md ├── models.py ├── plans │ └── caching.md ├── pyproject.toml ├── README.md ├── repository.py ├── server.py ├── tests │ ├── assertions.py │ ├── conftest.py │ ├── test_accounts.py │ ├── test_assertions.py │ ├── test_budget_months.py │ ├── test_categories.py │ ├── test_payees.py │ ├── test_repository.py │ ├── test_scheduled_transactions.py │ ├── test_transactions.py │ ├── test_updates.py │ └── test_utilities.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /tests/test_transactions.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for transaction-related functionality in YNAB MCP Server. 3 | """ 4 | 5 | from datetime import date 6 | from unittest.mock import MagicMock 7 | 8 | import pytest 9 | import ynab 10 | from assertions import extract_response_data 11 | from conftest import create_ynab_transaction 12 | from fastmcp.client import Client, FastMCPTransport 13 | from fastmcp.exceptions import ToolError 14 | 15 | 16 | async def test_list_transactions_basic( 17 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 18 | ) -> None: 19 | """Test basic transaction listing without filters.""" 20 | 21 | txn1 = create_ynab_transaction( 22 | id="txn-1", 23 | transaction_date=date(2024, 1, 15), 24 | amount=-50_000, # -$50.00 outflow 25 | memo="Grocery shopping", 26 | flag_color=ynab.TransactionFlagColor.RED, 27 | account_name="Checking", 28 | payee_id="payee-1", 29 | payee_name="Whole Foods", 30 | category_id="cat-1", 31 | category_name="Groceries", 32 | ) 33 | 34 | txn2 = create_ynab_transaction( 35 | id="txn-2", 36 | transaction_date=date(2024, 1, 20), 37 | amount=-75_000, # -$75.00 outflow 38 | memo="Dinner", 39 | cleared=ynab.TransactionClearedStatus.UNCLEARED, 40 | account_name="Checking", 41 | payee_id="payee-2", 42 | payee_name="Restaurant XYZ", 43 | category_id="cat-2", 44 | category_name="Dining Out", 45 | ) 46 | 47 | # Add a deleted transaction that should be filtered out 48 | txn_deleted = create_ynab_transaction( 49 | id="txn-deleted", 50 | transaction_date=date(2024, 1, 10), 51 | amount=-25_000, 52 | memo="Deleted transaction", 53 | account_name="Checking", 54 | payee_id="payee-3", 55 | payee_name="Store ABC", 56 | category_id="cat-1", 57 | category_name="Groceries", 58 | deleted=True, # Should be excluded 59 | ) 60 | 61 | # Mock repository to return transactions 62 | mock_repository.get_transactions.return_value = [txn2, txn1, txn_deleted] 63 | 64 | result = await mcp_client.call_tool("list_transactions", {}) 65 | 66 | response_data = extract_response_data(result) 67 | 68 | # Should have 2 transactions (deleted one excluded) 69 | assert len(response_data["transactions"]) == 2 70 | 71 | # Should be sorted by date descending 72 | assert response_data["transactions"][0]["id"] == "txn-2" 73 | assert response_data["transactions"][0]["date"] == "2024-01-20" 74 | assert response_data["transactions"][0]["amount"] == "-75" 75 | assert response_data["transactions"][0]["payee_name"] == "Restaurant XYZ" 76 | assert response_data["transactions"][0]["category_name"] == "Dining Out" 77 | 78 | assert response_data["transactions"][1]["id"] == "txn-1" 79 | assert response_data["transactions"][1]["date"] == "2024-01-15" 80 | assert response_data["transactions"][1]["amount"] == "-50" 81 | assert response_data["transactions"][1]["flag"] == "Red" 82 | 83 | # Check pagination 84 | assert response_data["pagination"]["total_count"] == 2 85 | assert response_data["pagination"]["has_more"] is False 86 | 87 | 88 | async def test_list_transactions_with_account_filter( 89 | mock_repository: MagicMock, 90 | mcp_client: Client[FastMCPTransport], 91 | ) -> None: 92 | """Test transaction listing filtered by account.""" 93 | # Create transaction 94 | txn = create_ynab_transaction( 95 | id="txn-acc-1", 96 | transaction_date=date(2024, 2, 1), 97 | amount=-30_000, 98 | memo="Account filtered", 99 | account_id="acc-checking", 100 | account_name="Main Checking", 101 | payee_id="payee-1", 102 | payee_name="Store", 103 | category_id="cat-1", 104 | category_name="Shopping", 105 | ) 106 | 107 | # Mock repository to return filtered transactions 108 | mock_repository.get_transactions_by_filters.return_value = [txn] 109 | 110 | result = await mcp_client.call_tool( 111 | "list_transactions", {"account_id": "acc-checking"} 112 | ) 113 | 114 | response_data = extract_response_data(result) 115 | assert len(response_data["transactions"]) == 1 116 | assert response_data["transactions"][0]["account_id"] == "acc-checking" 117 | 118 | # Verify correct repository method was called 119 | mock_repository.get_transactions_by_filters.assert_called_once_with( 120 | account_id="acc-checking", 121 | category_id=None, 122 | payee_id=None, 123 | since_date=None, 124 | ) 125 | 126 | 127 | async def test_list_transactions_with_amount_filters( 128 | mock_repository: MagicMock, 129 | mcp_client: Client[FastMCPTransport], 130 | ) -> None: 131 | """Test transaction listing with amount range filters.""" 132 | # Create transactions with different amounts 133 | txn_small = create_ynab_transaction( 134 | id="txn-small", 135 | transaction_date=date(2024, 3, 1), 136 | amount=-25_000, # -$25 137 | memo="Small purchase", 138 | payee_id="payee-1", 139 | payee_name="Coffee Shop", 140 | category_id="cat-1", 141 | category_name="Dining Out", 142 | ) 143 | 144 | txn_medium = create_ynab_transaction( 145 | id="txn-medium", 146 | transaction_date=date(2024, 3, 2), 147 | amount=-60_000, # -$60 148 | memo="Medium purchase", 149 | payee_id="payee-2", 150 | payee_name="Restaurant", 151 | category_id="cat-1", 152 | category_name="Dining Out", 153 | ) 154 | 155 | txn_large = create_ynab_transaction( 156 | id="txn-large", 157 | transaction_date=date(2024, 3, 3), 158 | amount=-120_000, # -$120 159 | memo="Large purchase", 160 | payee_id="payee-3", 161 | payee_name="Electronics Store", 162 | category_id="cat-2", 163 | category_name="Shopping", 164 | ) 165 | 166 | # Mock repository to return all transactions for filtering 167 | mock_repository.get_transactions.return_value = [txn_small, txn_medium, txn_large] 168 | 169 | # Test with min_amount filter (transactions >= -$50) 170 | result = await mcp_client.call_tool( 171 | "list_transactions", 172 | { 173 | "min_amount": -50.0 # -$50 174 | }, 175 | ) 176 | 177 | response_data = extract_response_data(result) 178 | assert response_data is not None 179 | # Should only include small transaction (-$25 > -$50) 180 | assert len(response_data["transactions"]) == 1 181 | assert response_data["transactions"][0]["id"] == "txn-small" 182 | 183 | # Test with max_amount filter (transactions <= -$100) 184 | result = await mcp_client.call_tool( 185 | "list_transactions", 186 | { 187 | "max_amount": -100.0 # -$100 188 | }, 189 | ) 190 | 191 | response_data = extract_response_data(result) 192 | assert response_data is not None 193 | # Should only include large transaction (-$120 < -$100) 194 | assert len(response_data["transactions"]) == 1 195 | assert response_data["transactions"][0]["id"] == "txn-large" 196 | 197 | # Test with both min and max filters 198 | result = await mcp_client.call_tool( 199 | "list_transactions", 200 | { 201 | "min_amount": -80.0, # >= -$80 202 | "max_amount": -40.0, # <= -$40 203 | }, 204 | ) 205 | 206 | response_data = extract_response_data(result) 207 | assert response_data is not None 208 | # Should only include medium transaction (-$60) 209 | assert len(response_data["transactions"]) == 1 210 | assert response_data["transactions"][0]["id"] == "txn-medium" 211 | 212 | 213 | async def test_list_transactions_with_subtransactions( 214 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 215 | ) -> None: 216 | """Test transaction listing with split transactions (subtransactions).""" 217 | sub1 = ynab.SubTransaction( 218 | id="sub-1", 219 | transaction_id="txn-split", 220 | amount=-30_000, # -$30 221 | memo="Groceries portion", 222 | payee_id=None, 223 | payee_name=None, 224 | category_id="cat-groceries", 225 | category_name="Groceries", 226 | transfer_account_id=None, 227 | transfer_transaction_id=None, 228 | deleted=False, 229 | ) 230 | 231 | sub2 = ynab.SubTransaction( 232 | id="sub-2", 233 | transaction_id="txn-split", 234 | amount=-20_000, # -$20 235 | memo="Household items", 236 | payee_id=None, 237 | payee_name=None, 238 | category_id="cat-household", 239 | category_name="Household", 240 | transfer_account_id=None, 241 | transfer_transaction_id=None, 242 | deleted=False, 243 | ) 244 | 245 | # Deleted subtransaction should be filtered out 246 | sub_deleted = ynab.SubTransaction( 247 | id="sub-deleted", 248 | transaction_id="txn-split", 249 | amount=-10_000, 250 | memo="Deleted sub", 251 | payee_id=None, 252 | payee_name=None, 253 | category_id="cat-other", 254 | category_name="Other", 255 | transfer_account_id=None, 256 | transfer_transaction_id=None, 257 | deleted=True, 258 | ) 259 | 260 | # Create split transaction 261 | txn_split = create_ynab_transaction( 262 | id="txn-split", 263 | transaction_date=date(2024, 4, 1), 264 | amount=-50_000, # -$50 total 265 | memo="Split transaction at Target", 266 | payee_id="payee-target", 267 | payee_name="Target", 268 | category_id=None, # Split transactions don't have a single category 269 | category_name=None, 270 | subtransactions=[sub1, sub2, sub_deleted], 271 | ) 272 | 273 | # Mock repository to return split transaction 274 | mock_repository.get_transactions.return_value = [txn_split] 275 | 276 | result = await mcp_client.call_tool("list_transactions", {}) 277 | 278 | response_data = extract_response_data(result) 279 | assert response_data is not None 280 | assert len(response_data["transactions"]) == 1 281 | 282 | txn = response_data["transactions"][0] 283 | assert txn["id"] == "txn-split" 284 | assert txn["amount"] == "-50" 285 | 286 | # Should have 2 subtransactions (deleted one excluded) 287 | assert len(txn["subtransactions"]) == 2 288 | assert txn["subtransactions"][0]["id"] == "sub-1" 289 | assert txn["subtransactions"][0]["amount"] == "-30" 290 | assert txn["subtransactions"][0]["category_name"] == "Groceries" 291 | assert txn["subtransactions"][1]["id"] == "sub-2" 292 | assert txn["subtransactions"][1]["amount"] == "-20" 293 | assert txn["subtransactions"][1]["category_name"] == "Household" 294 | 295 | 296 | async def test_list_transactions_pagination( 297 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 298 | ) -> None: 299 | """Test transaction listing with pagination.""" 300 | # Create many transactions to test pagination 301 | transactions = [] 302 | for i in range(5): 303 | txn = create_ynab_transaction( 304 | id=f"txn-{i}", 305 | transaction_date=date(2024, 1, i + 1), 306 | amount=-10_000 * (i + 1), 307 | memo=f"Transaction {i}", 308 | payee_id=f"payee-{i}", 309 | payee_name=f"Store {i}", 310 | category_id="cat-1", 311 | category_name="Shopping", 312 | ) 313 | transactions.append(txn) 314 | 315 | # Mock repository to return all transactions 316 | mock_repository.get_transactions.return_value = transactions 317 | 318 | # Test first page 319 | result = await mcp_client.call_tool("list_transactions", {"limit": 2, "offset": 0}) 320 | 321 | response_data = extract_response_data(result) 322 | assert response_data is not None 323 | assert len(response_data["transactions"]) == 2 324 | assert response_data["pagination"]["total_count"] == 5 325 | assert response_data["pagination"]["has_more"] is True 326 | 327 | # Transactions should be sorted by date descending 328 | assert response_data["transactions"][0]["id"] == "txn-4" 329 | assert response_data["transactions"][1]["id"] == "txn-3" 330 | 331 | # Test second page 332 | result = await mcp_client.call_tool("list_transactions", {"limit": 2, "offset": 2}) 333 | 334 | response_data = extract_response_data(result) 335 | assert response_data is not None 336 | assert len(response_data["transactions"]) == 2 337 | assert response_data["transactions"][0]["id"] == "txn-2" 338 | assert response_data["transactions"][1]["id"] == "txn-1" 339 | 340 | 341 | async def test_list_transactions_with_category_filter( 342 | mock_repository: MagicMock, 343 | mcp_client: Client[FastMCPTransport], 344 | ) -> None: 345 | """Test transaction listing filtered by category.""" 346 | 347 | # Create transaction 348 | txn = create_ynab_transaction( 349 | id="txn-cat-1", 350 | transaction_date=date(2024, 2, 1), 351 | amount=-40_000, 352 | memo="Category filtered", 353 | payee_id="payee-1", 354 | payee_name="Store", 355 | category_id="cat-dining", 356 | category_name="Dining Out", 357 | ) 358 | 359 | # Mock repository to return filtered transactions 360 | mock_repository.get_transactions_by_filters.return_value = [txn] 361 | 362 | result = await mcp_client.call_tool( 363 | "list_transactions", {"category_id": "cat-dining"} 364 | ) 365 | 366 | response_data = extract_response_data(result) 367 | assert len(response_data["transactions"]) == 1 368 | assert response_data["transactions"][0]["category_id"] == "cat-dining" 369 | 370 | # Verify correct repository method was called 371 | mock_repository.get_transactions_by_filters.assert_called_once_with( 372 | account_id=None, 373 | category_id="cat-dining", 374 | payee_id=None, 375 | since_date=None, 376 | ) 377 | 378 | 379 | async def test_list_transactions_with_payee_filter( 380 | mock_repository: MagicMock, 381 | mcp_client: Client[FastMCPTransport], 382 | ) -> None: 383 | """Test transaction listing filtered by payee.""" 384 | # Create transaction 385 | txn = create_ynab_transaction( 386 | id="txn-payee-1", 387 | transaction_date=date(2024, 3, 1), 388 | amount=-80_000, 389 | memo="Payee filtered", 390 | payee_id="payee-amazon", 391 | payee_name="Amazon", 392 | category_id="cat-shopping", 393 | category_name="Shopping", 394 | ) 395 | 396 | # Mock repository to return filtered transactions 397 | mock_repository.get_transactions_by_filters.return_value = [txn] 398 | 399 | result = await mcp_client.call_tool( 400 | "list_transactions", {"payee_id": "payee-amazon"} 401 | ) 402 | 403 | response_data = extract_response_data(result) 404 | assert len(response_data["transactions"]) == 1 405 | assert response_data["transactions"][0]["payee_id"] == "payee-amazon" 406 | 407 | # Verify correct repository method was called 408 | mock_repository.get_transactions_by_filters.assert_called_once_with( 409 | account_id=None, 410 | category_id=None, 411 | payee_id="payee-amazon", 412 | since_date=None, 413 | ) 414 | 415 | 416 | async def test_split_transaction_payee_inheritance( 417 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 418 | ) -> None: 419 | """Test that subtransactions inherit parent payee when their payee is null.""" 420 | # Create subtransactions where payee is null (simulating API response issue) 421 | sub1 = ynab.SubTransaction( 422 | id="sub-1", 423 | transaction_id="txn-split", 424 | amount=-30_000, 425 | memo="Groceries portion", 426 | payee_id=None, # Null payee_id 427 | payee_name=None, # Null payee_name (the bug we're fixing) 428 | category_id="cat-groceries", 429 | category_name="Groceries", 430 | transfer_account_id=None, 431 | transfer_transaction_id=None, 432 | deleted=False, 433 | ) 434 | 435 | sub2 = ynab.SubTransaction( 436 | id="sub-2", 437 | transaction_id="txn-split", 438 | amount=-20_000, 439 | memo="Household items", 440 | payee_id=None, # Null payee_id 441 | payee_name=None, # Null payee_name (the bug we're fixing) 442 | category_id="cat-household", 443 | category_name="Household", 444 | transfer_account_id=None, 445 | transfer_transaction_id=None, 446 | deleted=False, 447 | ) 448 | 449 | # Create parent transaction with valid payee (what user sees in YNAB interface) 450 | txn_split = create_ynab_transaction( 451 | id="txn-split", 452 | transaction_date=date(2024, 8, 11), 453 | amount=-50_000, 454 | memo="Split transaction at Walmart", 455 | payee_id="payee-walmart", 456 | payee_name="Walmart", # Parent has payee name 457 | category_id=None, # Split transactions don't have single category 458 | category_name=None, 459 | subtransactions=[sub1, sub2], 460 | ) 461 | 462 | # Mock repository to return split transaction 463 | mock_repository.get_transactions.return_value = [txn_split] 464 | 465 | result = await mcp_client.call_tool("list_transactions", {}) 466 | 467 | response_data = extract_response_data(result) 468 | assert response_data is not None 469 | assert len(response_data["transactions"]) == 1 470 | 471 | txn = response_data["transactions"][0] 472 | assert txn["id"] == "txn-split" 473 | assert txn["payee_name"] == "Walmart" # Parent should have payee name 474 | assert txn["payee_id"] == "payee-walmart" 475 | 476 | # Both subtransactions should inherit parent payee info 477 | assert len(txn["subtransactions"]) == 2 478 | 479 | assert txn["subtransactions"][0]["id"] == "sub-1" 480 | assert txn["subtransactions"][0]["payee_name"] == "Walmart" # Inherited! 481 | assert txn["subtransactions"][0]["payee_id"] == "payee-walmart" # Inherited! 482 | 483 | assert txn["subtransactions"][1]["id"] == "sub-2" 484 | assert txn["subtransactions"][1]["payee_name"] == "Walmart" # Inherited! 485 | assert txn["subtransactions"][1]["payee_id"] == "payee-walmart" # Inherited! 486 | 487 | 488 | async def test_hybrid_transaction_subtransaction_payee_resolution( 489 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 490 | ) -> None: 491 | """Test HybridTransaction subtransactions that need parent payee resolution.""" 492 | # Create a HybridTransaction subtransaction (like from filtered API) 493 | from datetime import date 494 | 495 | from ynab.models.hybrid_transaction import HybridTransaction 496 | 497 | # This simulates what we get from get_transactions_by_filters() 498 | hybrid_subtxn = HybridTransaction( 499 | id="28a0ce46-a33b-4c3b-bcfc-633a05d9f9ec", 500 | date=date(2025, 8, 11), 501 | amount=-239660, # $239.66 in milliunits 502 | memo=None, 503 | cleared=ynab.TransactionClearedStatus.RECONCILED, 504 | approved=True, 505 | flag_color=None, 506 | flag_name=None, 507 | account_id="914dcb14-13da-49d2-86de-ba241c48f047", 508 | account_name="American Express", 509 | payee_id=None, # Missing payee info (the bug) 510 | payee_name=None, # Missing payee info (the bug) 511 | category_id="cd7c0b0e-7895-4f9f-aa1e-b6e0a22020cd", 512 | category_name="Groceries", 513 | transfer_account_id=None, 514 | transfer_transaction_id=None, 515 | matched_transaction_id=None, 516 | import_id="YNAB:-339660:2025-08-11:1", 517 | import_payee_name=None, 518 | import_payee_name_original=None, 519 | debt_transaction_type=None, 520 | deleted=False, 521 | type="subtransaction", # This is key! 522 | parent_transaction_id="5db47639-1867-41df-a807-23cc23b0ffe9", 523 | ) 524 | 525 | # Create the parent transaction that has the payee info 526 | parent_txn = create_ynab_transaction( 527 | id="5db47639-1867-41df-a807-23cc23b0ffe9", 528 | transaction_date=date(2025, 8, 11), 529 | amount=-339660, # Total amount 530 | memo=None, 531 | payee_id="payee-walmart", 532 | payee_name="Walmart", # Parent has the payee name 533 | category_id=None, 534 | category_name="Split", 535 | ) 536 | 537 | # Mock repository to return both transactions 538 | mock_repository.get_transactions_by_filters.return_value = [hybrid_subtxn] 539 | mock_repository.get_transaction_by_id.return_value = parent_txn # For parent lookup 540 | 541 | result = await mcp_client.call_tool( 542 | "list_transactions", {"category_id": "cd7c0b0e-7895-4f9f-aa1e-b6e0a22020cd"} 543 | ) 544 | 545 | response_data = extract_response_data(result) 546 | assert response_data is not None 547 | assert len(response_data["transactions"]) == 1 548 | 549 | txn = response_data["transactions"][0] 550 | assert txn["id"] == "28a0ce46-a33b-4c3b-bcfc-633a05d9f9ec" 551 | assert txn["amount"] == "-239.66" 552 | 553 | # The key test: should have resolved parent payee info 554 | assert txn["payee_name"] == "Walmart" # Resolved from parent! 555 | assert txn["payee_id"] == "payee-walmart" # Resolved from parent! 556 | assert ( 557 | txn["parent_transaction_id"] == "5db47639-1867-41df-a807-23cc23b0ffe9" 558 | ) # Should surface parent ID 559 | 560 | 561 | async def test_hybrid_transaction_with_missing_parent( 562 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 563 | ) -> None: 564 | """Test HybridTransaction subtransaction when parent is not found.""" 565 | from datetime import date 566 | 567 | from ynab.models.hybrid_transaction import HybridTransaction 568 | 569 | # Create a HybridTransaction subtransaction with non-existent parent 570 | hybrid_subtxn = HybridTransaction( 571 | id="orphan-subtxn", 572 | date=date(2025, 8, 11), 573 | amount=-50000, 574 | memo=None, 575 | cleared=ynab.TransactionClearedStatus.CLEARED, 576 | approved=True, 577 | flag_color=None, 578 | flag_name=None, 579 | account_id="acc-1", 580 | account_name="Test Account", 581 | payee_id=None, 582 | payee_name=None, 583 | category_id="cat-1", 584 | category_name="Test Category", 585 | transfer_account_id=None, 586 | transfer_transaction_id=None, 587 | matched_transaction_id=None, 588 | import_id=None, 589 | import_payee_name=None, 590 | import_payee_name_original=None, 591 | debt_transaction_type=None, 592 | deleted=False, 593 | type="subtransaction", 594 | parent_transaction_id="nonexistent-parent-id", # Parent doesn't exist 595 | ) 596 | 597 | # Mock repository - parent transaction not found 598 | mock_repository.get_transactions_by_filters.return_value = [hybrid_subtxn] 599 | # Mock get_transaction_by_id to raise an exception (transaction not found) 600 | mock_repository.get_transaction_by_id.side_effect = Exception( 601 | "Transaction not found" 602 | ) 603 | 604 | # Should raise an exception when parent lookup fails 605 | with pytest.raises(ToolError, match="Transaction not found"): 606 | await mcp_client.call_tool("list_transactions", {"category_id": "cat-1"}) 607 | 608 | 609 | async def test_hybrid_transaction_parent_resolver_exception( 610 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 611 | ) -> None: 612 | """Test HybridTransaction when parent resolver throws exception.""" 613 | from datetime import date 614 | 615 | from ynab.models.hybrid_transaction import HybridTransaction 616 | 617 | hybrid_subtxn = HybridTransaction( 618 | id="exception-subtxn", 619 | date=date(2025, 8, 11), 620 | amount=-75000, 621 | memo=None, 622 | cleared=ynab.TransactionClearedStatus.CLEARED, 623 | approved=True, 624 | flag_color=None, 625 | flag_name=None, 626 | account_id="acc-1", 627 | account_name="Test Account", 628 | payee_id=None, 629 | payee_name=None, 630 | category_id="cat-1", 631 | category_name="Test Category", 632 | transfer_account_id=None, 633 | transfer_transaction_id=None, 634 | matched_transaction_id=None, 635 | import_id=None, 636 | import_payee_name=None, 637 | import_payee_name_original=None, 638 | debt_transaction_type=None, 639 | deleted=False, 640 | type="subtransaction", 641 | parent_transaction_id="exception-parent-id", 642 | ) 643 | 644 | # Mock repository to return the subtransaction 645 | mock_repository.get_transactions_by_filters.return_value = [hybrid_subtxn] 646 | # Make get_transaction_by_id raise an exception to test exception handling 647 | mock_repository.get_transaction_by_id.side_effect = Exception("Database error") 648 | 649 | # Should raise an exception when parent lookup fails 650 | with pytest.raises(ToolError, match="Database error"): 651 | await mcp_client.call_tool("list_transactions", {"category_id": "cat-1"}) 652 | 653 | 654 | async def test_hybrid_transaction_parent_with_null_payee( 655 | mock_repository: MagicMock, mcp_client: Client[FastMCPTransport] 656 | ) -> None: 657 | """Test HybridTransaction when parent transaction also has null payee.""" 658 | from datetime import date 659 | 660 | from ynab.models.hybrid_transaction import HybridTransaction 661 | 662 | hybrid_subtxn = HybridTransaction( 663 | id="null-payee-subtxn", 664 | date=date(2025, 8, 11), 665 | amount=-80000, 666 | memo=None, 667 | cleared=ynab.TransactionClearedStatus.CLEARED, 668 | approved=True, 669 | flag_color=None, 670 | flag_name=None, 671 | account_id="acc-1", 672 | account_name="Test Account", 673 | payee_id=None, 674 | payee_name=None, 675 | category_id="cat-1", 676 | category_name="Test Category", 677 | transfer_account_id=None, 678 | transfer_transaction_id=None, 679 | matched_transaction_id=None, 680 | import_id=None, 681 | import_payee_name=None, 682 | import_payee_name_original=None, 683 | debt_transaction_type=None, 684 | deleted=False, 685 | type="subtransaction", 686 | parent_transaction_id="null-payee-parent-id", 687 | ) 688 | 689 | # Create parent transaction that also has null payee info 690 | parent_txn = create_ynab_transaction( 691 | id="null-payee-parent-id", 692 | transaction_date=date(2025, 8, 11), 693 | amount=-80000, 694 | memo=None, 695 | payee_id=None, # Parent also has null payee 696 | payee_name=None, # Parent also has null payee 697 | category_id=None, 698 | category_name="Split", 699 | ) 700 | 701 | # Mock repository to return both 702 | mock_repository.get_transactions_by_filters.return_value = [hybrid_subtxn] 703 | mock_repository.get_transaction_by_id.return_value = ( 704 | parent_txn # Parent found but has null payee 705 | ) 706 | 707 | result = await mcp_client.call_tool("list_transactions", {"category_id": "cat-1"}) 708 | 709 | response_data = extract_response_data(result) 710 | assert response_data is not None 711 | assert len(response_data["transactions"]) == 1 712 | 713 | txn = response_data["transactions"][0] 714 | assert txn["id"] == "null-payee-subtxn" 715 | 716 | # Should remain null when parent also has null payee 717 | assert txn["payee_name"] is None 718 | assert txn["payee_id"] is None 719 | assert txn["parent_transaction_id"] == "null-payee-parent-id" 720 | ``` -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | import os 3 | from collections.abc import Sequence 4 | from datetime import date, datetime 5 | from decimal import Decimal 6 | from typing import Literal 7 | 8 | import ynab 9 | from fastmcp import FastMCP 10 | 11 | from models import ( 12 | Account, 13 | AccountsResponse, 14 | BudgetMonth, 15 | CategoriesResponse, 16 | Category, 17 | CategoryGroup, 18 | PaginationInfo, 19 | Payee, 20 | PayeesResponse, 21 | ScheduledTransaction, 22 | ScheduledTransactionsResponse, 23 | Transaction, 24 | TransactionsResponse, 25 | milliunits_to_currency, 26 | ) 27 | from repository import YNABRepository 28 | 29 | # Configure logging 30 | logging.basicConfig( 31 | level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 32 | ) 33 | logger = logging.getLogger(__name__) 34 | 35 | mcp = FastMCP[None]( 36 | name="YNAB", 37 | instructions=""" 38 | Gives you access to a user's YNAB budget, including accounts, categories, and 39 | transactions. If a user is ever asking about budgeting, their personal finances, 40 | banking, saving, or investing, their YNAB budget is very relevant to them. 41 | When the user asks about budget categories and "how much is left", they are 42 | talking about the current month. 43 | 44 | Budget categories are grouped into category groups, which are important groupings 45 | to the user and should be displayed in a hierarchical manner. Categories will have 46 | the category_group_name and category_group_id available. 47 | 48 | The server operates on a single budget configured via the YNAB_BUDGET environment 49 | variable. All tools work with this budget automatically. 50 | """, 51 | ) 52 | 53 | 54 | # Load configuration at module import - fail fast if not configured 55 | BUDGET_ID = os.environ["YNAB_BUDGET"] 56 | ACCESS_TOKEN = os.environ["YNAB_ACCESS_TOKEN"] 57 | ynab_api_configuration = ynab.Configuration(access_token=ACCESS_TOKEN) 58 | 59 | # Initialize repository at module level 60 | _repository = YNABRepository(budget_id=BUDGET_ID, access_token=ACCESS_TOKEN) 61 | 62 | 63 | def _paginate_items[T]( 64 | items: list[T], limit: int, offset: int 65 | ) -> tuple[list[T], PaginationInfo]: 66 | """Apply pagination to a list of items and return the page with pagination info.""" 67 | total_count = len(items) 68 | start_index = offset 69 | end_index = min(offset + limit, total_count) 70 | items_page = items[start_index:end_index] 71 | 72 | has_more = end_index < total_count 73 | 74 | pagination = PaginationInfo( 75 | total_count=total_count, 76 | limit=limit, 77 | offset=offset, 78 | has_more=has_more, 79 | ) 80 | 81 | return items_page, pagination 82 | 83 | 84 | def _filter_active_items[T]( 85 | items: list[T], 86 | *, 87 | exclude_deleted: bool = True, 88 | exclude_hidden: bool = False, 89 | exclude_closed: bool = False, 90 | ) -> list[T]: 91 | """Filter items to exclude deleted/hidden/closed based on flags.""" 92 | filtered = [] 93 | for item in items: 94 | if exclude_deleted and getattr(item, "deleted", False): 95 | continue 96 | if exclude_hidden and getattr(item, "hidden", False): 97 | continue 98 | if exclude_closed and getattr(item, "closed", False): 99 | continue 100 | filtered.append(item) 101 | return filtered 102 | 103 | 104 | def _build_category_group_map( 105 | category_groups: list[ynab.CategoryGroupWithCategories], 106 | ) -> dict[str, str]: 107 | """Build a mapping of category_id to category_group_name.""" 108 | mapping = {} 109 | for category_group in category_groups: 110 | for category in category_group.categories: 111 | mapping[category.id] = category_group.name 112 | return mapping 113 | 114 | 115 | def convert_month_to_date( 116 | month: date | Literal["current", "last", "next"], 117 | ) -> date: 118 | """Convert month parameter to appropriate date object for YNAB API. 119 | 120 | Args: 121 | month: Month in ISO format (date object), or "current", "last", "next" literals 122 | 123 | Returns: 124 | date object representing the first day of the specified month: 125 | - "current": first day of current month 126 | - "last": first day of previous month 127 | - "next": first day of next month 128 | - date object unchanged if already a date 129 | """ 130 | if isinstance(month, date): 131 | return month 132 | 133 | today = datetime.now().date() 134 | year, month_num = today.year, today.month 135 | 136 | match month: 137 | case "current": 138 | return date(year, month_num, 1) 139 | case "last": 140 | return ( 141 | date(year - 1, 12, 1) 142 | if month_num == 1 143 | else date(year, month_num - 1, 1) 144 | ) 145 | case "next": 146 | return ( 147 | date(year + 1, 1, 1) 148 | if month_num == 12 149 | else date(year, month_num + 1, 1) 150 | ) 151 | case _: 152 | raise ValueError(f"Invalid month value: {month}") 153 | 154 | 155 | @mcp.tool() 156 | def list_accounts( 157 | limit: int = 100, 158 | offset: int = 0, 159 | ) -> AccountsResponse: 160 | """List accounts with pagination. 161 | 162 | Only returns open/active accounts. Closed accounts are excluded automatically. 163 | 164 | Args: 165 | limit: Maximum number of accounts to return per page (default: 100) 166 | offset: Number of accounts to skip for pagination (default: 0) 167 | 168 | Returns: 169 | AccountsResponse with accounts list and pagination information 170 | """ 171 | # Get accounts from repository (handles sync automatically if needed) 172 | accounts = _repository.get_accounts() 173 | 174 | # Apply existing filtering and pagination logic 175 | active_accounts = _filter_active_items(accounts, exclude_closed=True) 176 | all_accounts = [Account.from_ynab(account) for account in active_accounts] 177 | 178 | accounts_page, pagination = _paginate_items(all_accounts, limit, offset) 179 | 180 | return AccountsResponse(accounts=accounts_page, pagination=pagination) 181 | 182 | 183 | @mcp.tool() 184 | def list_categories( 185 | limit: int = 50, 186 | offset: int = 0, 187 | ) -> CategoriesResponse: 188 | """List categories with pagination. 189 | 190 | Only returns active/visible categories. Hidden and deleted categories are excluded 191 | automatically. 192 | 193 | Args: 194 | limit: Maximum number of categories to return per page (default: 50) 195 | offset: Number of categories to skip for pagination (default: 0) 196 | 197 | Returns: 198 | CategoriesResponse with categories list and pagination information 199 | """ 200 | category_groups = _repository.get_category_groups() 201 | 202 | all_categories = [] 203 | for category_group in category_groups: 204 | active_categories = _filter_active_items( 205 | category_group.categories, exclude_hidden=True 206 | ) 207 | for category in active_categories: 208 | all_categories.append( 209 | Category.from_ynab(category, category_group.name).model_dump() 210 | ) 211 | 212 | categories_page, pagination = _paginate_items(all_categories, limit, offset) 213 | 214 | # Convert dict categories back to Category objects 215 | category_objects = [Category(**cat_dict) for cat_dict in categories_page] 216 | 217 | return CategoriesResponse(categories=category_objects, pagination=pagination) 218 | 219 | 220 | @mcp.tool() 221 | def list_category_groups() -> list[CategoryGroup]: 222 | """List category groups (lighter weight than full categories). 223 | 224 | Returns: 225 | List of category groups 226 | """ 227 | category_groups = _repository.get_category_groups() 228 | active_groups = _filter_active_items(category_groups) 229 | groups = [ 230 | CategoryGroup.from_ynab(category_group) for category_group in active_groups 231 | ] 232 | 233 | return groups 234 | 235 | 236 | @mcp.tool() 237 | def get_budget_month( 238 | month: date | Literal["current", "last", "next"] = "current", 239 | limit: int = 50, 240 | offset: int = 0, 241 | ) -> BudgetMonth: 242 | """Get budget data for a specific month including category budgets, activity, and 243 | balances with pagination. 244 | 245 | Only returns active/visible categories. Hidden and deleted categories are excluded 246 | automatically. 247 | 248 | Args: 249 | month: Specifies which budget month to retrieve: 250 | • "current": Current calendar month 251 | • "last": Previous calendar month 252 | • "next": Next calendar month 253 | • date object: Specific month (uses first day of month) 254 | Examples: "current", date(2024, 3, 1) for March 2024 (default: "current") 255 | limit: Maximum number of categories to return per page (default: 50) 256 | offset: Number of categories to skip for pagination (default: 0) 257 | 258 | Returns: 259 | BudgetMonth with month info, categories, and pagination 260 | """ 261 | converted_month = convert_month_to_date(month) 262 | month_data = _repository.get_budget_month(converted_month) 263 | 264 | # Map category IDs to group names 265 | category_groups = _repository.get_category_groups() 266 | category_group_map = _build_category_group_map(category_groups) 267 | all_categories = [] 268 | 269 | active_categories = _filter_active_items(month_data.categories, exclude_hidden=True) 270 | for category in active_categories: 271 | group_name = category_group_map.get(category.id) 272 | all_categories.append(Category.from_ynab(category, group_name)) 273 | 274 | categories_page, pagination = _paginate_items(all_categories, limit, offset) 275 | 276 | return BudgetMonth( 277 | month=month_data.month, 278 | note=month_data.note, 279 | income=milliunits_to_currency(month_data.income), 280 | budgeted=milliunits_to_currency(month_data.budgeted), 281 | activity=milliunits_to_currency(month_data.activity), 282 | to_be_budgeted=milliunits_to_currency(month_data.to_be_budgeted), 283 | age_of_money=month_data.age_of_money, 284 | categories=categories_page, 285 | pagination=pagination, 286 | ) 287 | 288 | 289 | @mcp.tool() 290 | def get_month_category_by_id( 291 | category_id: str, 292 | month: date | Literal["current", "last", "next"] = "current", 293 | ) -> Category: 294 | """Get a specific category's data for a specific month. 295 | 296 | Args: 297 | category_id: Unique identifier for the category (required) 298 | month: Specifies which budget month to retrieve: 299 | • "current": Current calendar month 300 | • "last": Previous calendar month 301 | • "next": Next calendar month 302 | • date object: Specific month (uses first day of month) 303 | Examples: "current", date(2024, 3, 1) for March 2024 (default: "current") 304 | 305 | Returns: 306 | Category with budget data for the specified month 307 | """ 308 | converted_month = convert_month_to_date(month) 309 | category = _repository.get_month_category_by_id(converted_month, category_id) 310 | 311 | # Fetch category groups to get group name 312 | category_groups = _repository.get_category_groups() 313 | category_group_map = _build_category_group_map(category_groups) 314 | group_name = category_group_map.get(category_id) 315 | 316 | return Category.from_ynab(category, group_name) 317 | 318 | 319 | @mcp.tool() 320 | def list_transactions( 321 | account_id: str | None = None, 322 | category_id: str | None = None, 323 | payee_id: str | None = None, 324 | since_date: date | None = None, 325 | min_amount: Decimal | None = None, 326 | max_amount: Decimal | None = None, 327 | limit: int = 25, 328 | offset: int = 0, 329 | ) -> TransactionsResponse: 330 | """List transactions with powerful filtering options for financial analysis. 331 | 332 | This tool supports various filters that can be combined: 333 | - Filter by account to see transactions for a specific account 334 | - Filter by category to analyze spending in a category (e.g., "Dining Out") 335 | - Filter by payee to see all transactions with a specific merchant (e.g., "Amazon") 336 | - Filter by date range using since_date 337 | - Filter by amount range using min_amount and/or max_amount 338 | 339 | Example queries this tool can answer: 340 | - "Show me all transactions over $50 in Dining Out this year" 341 | → Use: category_id="cat_dining_out_id", min_amount=50.00, 342 | since_date=date(2024, 1, 1) 343 | - "How much have I spent at Amazon this month" 344 | → Use: payee_id="payee_amazon_id", since_date=date(2024, 12, 1) 345 | - "List recent transactions in my checking account" 346 | → Use: account_id="acc_checking_id" 347 | 348 | Args: 349 | account_id: Filter by specific account (optional) 350 | category_id: Filter by specific category (optional) 351 | payee_id: Filter by specific payee (optional) 352 | since_date: Only show transactions on or after this date. Accepts date objects 353 | in YYYY-MM-DD format (e.g., date(2024, 1, 1)) (optional) 354 | min_amount: Only show transactions with amount >= this value in currency units. 355 | Use negative values for outflows/expenses 356 | (e.g., -50.00 for $50+ expenses) (optional) 357 | max_amount: Only show transactions with amount <= this value in currency units. 358 | Use negative values for outflows/expenses 359 | (e.g., -10.00 for under $10 expenses) (optional) 360 | limit: Maximum number of transactions to return per page (default: 25) 361 | offset: Number of transactions to skip for pagination (default: 0) 362 | 363 | Returns: 364 | TransactionsResponse with filtered transactions and pagination info 365 | """ 366 | # Use repository to get transactions with appropriate filtering 367 | transactions_data: Sequence[ynab.TransactionDetail | ynab.HybridTransaction] 368 | if account_id or category_id or payee_id or since_date: 369 | # Use filtered endpoint for specific filters 370 | transactions_data = _repository.get_transactions_by_filters( 371 | account_id=account_id, 372 | category_id=category_id, 373 | payee_id=payee_id, 374 | since_date=since_date, 375 | ) 376 | else: 377 | # Use cached transactions for general queries 378 | transactions_data = _repository.get_transactions() 379 | 380 | active_transactions = _filter_active_items(list(transactions_data)) 381 | all_transactions = [] 382 | for txn in active_transactions: 383 | # Apply amount filters (check milliunits directly for efficiency) 384 | if ( 385 | min_amount is not None 386 | and txn.amount is not None 387 | and txn.amount < (min_amount * 1000) 388 | ): 389 | continue 390 | if ( 391 | max_amount is not None 392 | and txn.amount is not None 393 | and txn.amount > (max_amount * 1000) 394 | ): 395 | continue 396 | 397 | all_transactions.append(Transaction.from_ynab(txn, _repository)) 398 | 399 | # Sort by date descending (most recent first) 400 | all_transactions.sort(key=lambda t: t.date, reverse=True) 401 | 402 | transactions_page, pagination = _paginate_items(all_transactions, limit, offset) 403 | 404 | return TransactionsResponse(transactions=transactions_page, pagination=pagination) 405 | 406 | 407 | @mcp.tool() 408 | def list_payees( 409 | limit: int = 50, 410 | offset: int = 0, 411 | ) -> PayeesResponse: 412 | """List payees for a specific budget with pagination. 413 | 414 | Payees are the entities you pay money to (merchants, people, companies, etc.). 415 | This tool helps you find payee IDs for filtering transactions or analyzing spending 416 | patterns. Only returns active payees. Deleted payees are excluded automatically. 417 | 418 | Example queries this tool can answer: 419 | - "List all my payees" 420 | - "Find the payee ID for Amazon" 421 | - "Show me all merchants I've paid" 422 | 423 | Args: 424 | limit: Maximum number of payees to return per page (default: 50) 425 | offset: Number of payees to skip for pagination (default: 0) 426 | 427 | Returns: 428 | PayeesResponse with payees list and pagination information 429 | """ 430 | # Get payees from repository (syncs automatically if needed) 431 | payees = _repository.get_payees() 432 | 433 | active_payees = _filter_active_items(payees) 434 | all_payees = [Payee.from_ynab(payee) for payee in active_payees] 435 | 436 | # Sort by name for easier browsing 437 | all_payees.sort(key=lambda p: p.name.lower()) 438 | 439 | payees_page, pagination = _paginate_items(all_payees, limit, offset) 440 | 441 | return PayeesResponse(payees=payees_page, pagination=pagination) 442 | 443 | 444 | @mcp.tool() 445 | def find_payee( 446 | name_search: str, 447 | limit: int = 10, 448 | ) -> PayeesResponse: 449 | """Find payees by searching for name substrings (case-insensitive). 450 | 451 | This tool is perfect for finding specific payees when you know part of their name. 452 | Much more efficient than paginating through all payees with list_payees. 453 | Only returns active payees. Deleted payees are excluded automatically. 454 | 455 | Example queries this tool can answer: 456 | - "Find Amazon payee ID" (use name_search="amazon") 457 | - "Show me all Starbucks locations" (use name_search="starbucks") 458 | - "Find payees with 'grocery' in the name" (use name_search="grocery") 459 | 460 | Args: 461 | name_search: Search term to match against payee names (case-insensitive 462 | substring match). Examples: "amazon", "starbucks", "grocery" 463 | limit: Maximum number of matching payees to return (default: 10) 464 | 465 | Returns: 466 | PayeesResponse with matching payees and pagination information 467 | """ 468 | # Get payees from repository (syncs automatically if needed) 469 | payees = _repository.get_payees() 470 | 471 | active_payees = _filter_active_items(payees) 472 | search_term = name_search.lower().strip() 473 | matching_payees = [ 474 | Payee.from_ynab(payee) 475 | for payee in active_payees 476 | if search_term in payee.name.lower() 477 | ] 478 | 479 | # Sort by name for easier browsing 480 | matching_payees.sort(key=lambda p: p.name.lower()) 481 | 482 | # Apply limit (no offset since this is a search, not pagination) 483 | limited_payees = matching_payees[:limit] 484 | 485 | # Create pagination info showing search results 486 | total_count = len(matching_payees) 487 | has_more = len(matching_payees) > limit 488 | 489 | pagination = PaginationInfo( 490 | total_count=total_count, 491 | limit=limit, 492 | offset=0, 493 | has_more=has_more, 494 | ) 495 | 496 | return PayeesResponse(payees=limited_payees, pagination=pagination) 497 | 498 | 499 | @mcp.tool() 500 | def list_scheduled_transactions( 501 | account_id: str | None = None, 502 | category_id: str | None = None, 503 | payee_id: str | None = None, 504 | frequency: str | None = None, 505 | upcoming_days: int | None = None, 506 | min_amount: Decimal | None = None, 507 | max_amount: Decimal | None = None, 508 | limit: int = 25, 509 | offset: int = 0, 510 | ) -> ScheduledTransactionsResponse: 511 | """List scheduled transactions with powerful filtering options for analysis. 512 | 513 | This tool supports various filters that can be combined: 514 | - Filter by account to see scheduled transactions for a specific account 515 | - Filter by category to analyze recurring spending (e.g., "Monthly Bills") 516 | - Filter by payee to see scheduled transactions (e.g., "Netflix") 517 | - Filter by frequency to find daily, weekly, monthly, etc. recurring transactions 518 | - Filter by upcoming_days to see what's scheduled in the next N days 519 | - Filter by amount range using min_amount and/or max_amount 520 | 521 | Example queries this tool can answer: 522 | - "Show me all monthly recurring expenses" (use frequency="monthly") 523 | - "What bills are due in the next 7 days?" (use upcoming_days=7) 524 | - "List all Netflix subscriptions" (use payee search first, then filter by payee_id) 525 | - "Show scheduled transactions over $100" (use min_amount=100) 526 | 527 | Args: 528 | account_id: Filter by specific account (optional) 529 | category_id: Filter by specific category (optional) 530 | payee_id: Filter by specific payee (optional) 531 | frequency: Filter by recurrence frequency. Valid values: 532 | • never, daily, weekly 533 | • everyOtherWeek, twiceAMonth, every4Weeks 534 | • monthly, everyOtherMonth, every3Months, every4Months 535 | • twiceAYear, yearly, everyOtherYear 536 | (optional) 537 | upcoming_days: Only show scheduled transactions with next occurrence 538 | within this many days (optional) 539 | min_amount: Only show scheduled transactions with amount >= this value 540 | in currency units. Use negative values for outflows/expenses 541 | (optional) 542 | max_amount: Only show scheduled transactions with amount <= this value 543 | in currency units. Use negative values for outflows/expenses 544 | (optional) 545 | limit: Maximum number of scheduled transactions to return per page (default: 25) 546 | offset: Number of scheduled transactions to skip for pagination (default: 0) 547 | 548 | Returns: 549 | ScheduledTransactionsResponse with filtered scheduled transactions and 550 | pagination info 551 | """ 552 | scheduled_transactions_data = _repository.get_scheduled_transactions() 553 | active_scheduled_transactions = _filter_active_items(scheduled_transactions_data) 554 | all_scheduled_transactions = [] 555 | for st in active_scheduled_transactions: 556 | # Apply filters 557 | if account_id and st.account_id != account_id: 558 | continue 559 | if category_id and st.category_id != category_id: 560 | continue 561 | if payee_id and st.payee_id != payee_id: 562 | continue 563 | if frequency and st.frequency != frequency: 564 | continue 565 | 566 | # Apply upcoming_days filter 567 | if upcoming_days is not None: 568 | days_until_next = (st.date_next - datetime.now().date()).days 569 | if days_until_next > upcoming_days: 570 | continue 571 | 572 | # Apply amount filters (check milliunits directly for efficiency) 573 | if min_amount is not None and st.amount < (min_amount * 1000): 574 | continue 575 | if max_amount is not None and st.amount > (max_amount * 1000): 576 | continue 577 | 578 | all_scheduled_transactions.append(ScheduledTransaction.from_ynab(st)) 579 | 580 | # Sort by next date ascending (earliest scheduled first) 581 | all_scheduled_transactions.sort(key=lambda st: st.date_next) 582 | 583 | scheduled_transactions_page, pagination = _paginate_items( 584 | all_scheduled_transactions, limit, offset 585 | ) 586 | 587 | return ScheduledTransactionsResponse( 588 | scheduled_transactions=scheduled_transactions_page, pagination=pagination 589 | ) 590 | 591 | 592 | @mcp.tool() 593 | def update_category_budget( 594 | category_id: str, 595 | budgeted: Decimal, 596 | month: date | Literal["current", "last", "next"] = "current", 597 | ) -> Category: 598 | """Update the budgeted amount for a category in a specific month. 599 | 600 | This tool allows you to assign money to budget categories, which is essential 601 | for monthly budget maintenance and reallocation. 602 | 603 | IMPORTANT: For categories with NEED goals (refill up to X monthly), budget the 604 | full goal_target amount regardless of current balance. These goals expect the 605 | full target to be budgeted each month. 606 | 607 | Args: 608 | category_id: Unique identifier for the category to update (required) 609 | budgeted: Amount to budget for this category in currency units (required) 610 | month: Budget month to update: 611 | • "current": Current calendar month 612 | • "last": Previous calendar month 613 | • "next": Next calendar month 614 | • date object: Specific month (uses first day of month) 615 | (default: "current") 616 | 617 | Returns: 618 | Category with updated budget information 619 | """ 620 | converted_month = convert_month_to_date(month) 621 | 622 | # Convert currency units to milliunits 623 | budgeted_milliunits = int(budgeted * 1000) 624 | 625 | # Use repository update method with cache invalidation 626 | updated_category = _repository.update_month_category( 627 | category_id, converted_month, budgeted_milliunits 628 | ) 629 | 630 | # Get category group name for the response 631 | category_groups = _repository.get_category_groups() 632 | category_group_map = _build_category_group_map(category_groups) 633 | group_name = category_group_map.get(category_id) 634 | 635 | return Category.from_ynab(updated_category, group_name) 636 | 637 | 638 | @mcp.tool() 639 | def update_transaction( 640 | transaction_id: str, 641 | category_id: str | None = None, 642 | payee_id: str | None = None, 643 | memo: str | None = None, 644 | ) -> Transaction: 645 | """Update an existing transaction's details. 646 | 647 | This tool allows you to modify transaction properties, most commonly 648 | to assign the correct category to imported or uncategorized transactions. 649 | 650 | Args: 651 | transaction_id: Unique identifier for the transaction to update (required) 652 | category_id: Category ID to assign (optional) 653 | payee_id: Payee ID to assign (optional) 654 | memo: Transaction memo (optional) 655 | 656 | Returns: 657 | Transaction with updated information 658 | """ 659 | # First, get the existing transaction to preserve its current values 660 | existing_txn = _repository.get_transaction_by_id(transaction_id) 661 | 662 | # Build the update data starting with existing transaction values 663 | update_data = { 664 | "account_id": existing_txn.account_id, 665 | "date": existing_txn.var_date, # ExistingTransaction uses 'date' 666 | "amount": existing_txn.amount, 667 | "payee_id": existing_txn.payee_id, 668 | "payee_name": existing_txn.payee_name, 669 | "category_id": existing_txn.category_id, 670 | "memo": existing_txn.memo, 671 | "cleared": existing_txn.cleared, 672 | "approved": existing_txn.approved, 673 | "flag_color": existing_txn.flag_color, 674 | "subtransactions": existing_txn.subtransactions, 675 | } 676 | 677 | # Apply only the fields we want to change 678 | if category_id is not None: 679 | update_data["category_id"] = category_id 680 | if payee_id is not None: 681 | update_data["payee_id"] = payee_id 682 | if memo is not None: 683 | update_data["memo"] = memo 684 | 685 | # Use repository update method with cache invalidation 686 | updated_transaction = _repository.update_transaction(transaction_id, update_data) 687 | 688 | return Transaction.from_ynab(updated_transaction, _repository) 689 | ``` -------------------------------------------------------------------------------- /tests/test_scheduled_transactions.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for scheduled transaction functionality in YNAB MCP Server. 3 | 4 | Tests the list_scheduled_transactions tool with various filters and scenarios. 5 | """ 6 | 7 | from datetime import date 8 | from unittest.mock import MagicMock 9 | 10 | import ynab 11 | from assertions import extract_response_data 12 | from fastmcp.client import Client, FastMCPTransport 13 | 14 | 15 | async def test_list_scheduled_transactions_basic( 16 | mock_repository: MagicMock, 17 | mcp_client: Client[FastMCPTransport], 18 | ) -> None: 19 | """Test basic scheduled transaction listing without filters.""" 20 | 21 | st1 = ynab.ScheduledTransactionDetail( 22 | id="st-1", 23 | date_first=date(2024, 1, 1), 24 | date_next=date(2024, 2, 1), 25 | frequency="monthly", 26 | amount=-120_000, # -$120.00 outflow 27 | memo="Netflix subscription", 28 | flag_color=ynab.TransactionFlagColor.RED, 29 | flag_name="Entertainment", 30 | account_id="acc-1", 31 | account_name="Checking", 32 | payee_id="payee-1", 33 | payee_name="Netflix", 34 | category_id="cat-1", 35 | category_name="Entertainment", 36 | transfer_account_id=None, 37 | deleted=False, 38 | subtransactions=[], 39 | ) 40 | 41 | st2 = ynab.ScheduledTransactionDetail( 42 | id="st-2", 43 | date_first=date(2024, 1, 15), 44 | date_next=date(2024, 1, 29), 45 | frequency="weekly", 46 | amount=-5_000, # -$5.00 outflow 47 | memo="Weekly coffee", 48 | flag_color=None, 49 | flag_name=None, 50 | account_id="acc-1", 51 | account_name="Checking", 52 | payee_id="payee-2", 53 | payee_name="Coffee Shop", 54 | category_id="cat-2", 55 | category_name="Dining Out", 56 | transfer_account_id=None, 57 | deleted=False, 58 | subtransactions=[], 59 | ) 60 | 61 | # Add a deleted scheduled transaction that should be filtered out 62 | st_deleted = ynab.ScheduledTransactionDetail( 63 | id="st-deleted", 64 | date_first=date(2024, 1, 1), 65 | date_next=date(2024, 3, 1), 66 | frequency="monthly", 67 | amount=-50_000, 68 | memo="Deleted subscription", 69 | flag_color=None, 70 | flag_name=None, 71 | account_id="acc-1", 72 | account_name="Checking", 73 | payee_id="payee-3", 74 | payee_name="Old Service", 75 | category_id="cat-1", 76 | category_name="Entertainment", 77 | transfer_account_id=None, 78 | deleted=True, # Should be excluded 79 | subtransactions=[], 80 | ) 81 | 82 | # Mock repository to return scheduled transactions 83 | mock_repository.get_scheduled_transactions.return_value = [ 84 | st2, 85 | st1, 86 | st_deleted, 87 | ] 88 | 89 | result = await mcp_client.call_tool("list_scheduled_transactions", {}) 90 | 91 | response_data = extract_response_data(result) 92 | 93 | # Should have 2 scheduled transactions (deleted one excluded) 94 | assert len(response_data["scheduled_transactions"]) == 2 95 | 96 | # Should be sorted by next date ascending (earliest scheduled first) 97 | assert response_data["scheduled_transactions"][0]["id"] == "st-2" 98 | assert response_data["scheduled_transactions"][0]["date_next"] == "2024-01-29" 99 | assert response_data["scheduled_transactions"][0]["frequency"] == "weekly" 100 | assert response_data["scheduled_transactions"][0]["amount"] == "-5" 101 | assert response_data["scheduled_transactions"][0]["payee_name"] == "Coffee Shop" 102 | 103 | assert response_data["scheduled_transactions"][1]["id"] == "st-1" 104 | assert response_data["scheduled_transactions"][1]["date_next"] == "2024-02-01" 105 | assert response_data["scheduled_transactions"][1]["frequency"] == "monthly" 106 | assert response_data["scheduled_transactions"][1]["amount"] == "-120" 107 | assert response_data["scheduled_transactions"][1]["flag"] == "Entertainment (Red)" 108 | 109 | # Check pagination 110 | assert response_data["pagination"]["total_count"] == 2 111 | assert response_data["pagination"]["has_more"] is False 112 | 113 | 114 | async def test_list_scheduled_transactions_with_frequency_filter( 115 | mock_repository: MagicMock, 116 | mcp_client: Client[FastMCPTransport], 117 | ) -> None: 118 | """Test scheduled transaction listing filtered by frequency.""" 119 | 120 | st_monthly = ynab.ScheduledTransactionDetail( 121 | id="st-monthly", 122 | date_first=date(2024, 1, 1), 123 | date_next=date(2024, 2, 1), 124 | frequency="monthly", 125 | amount=-100_000, 126 | memo="Monthly bill", 127 | flag_color=None, 128 | flag_name=None, 129 | account_id="acc-1", 130 | account_name="Checking", 131 | payee_id="payee-1", 132 | payee_name="Electric Company", 133 | category_id="cat-1", 134 | category_name="Utilities", 135 | transfer_account_id=None, 136 | deleted=False, 137 | subtransactions=[], 138 | ) 139 | 140 | st_weekly = ynab.ScheduledTransactionDetail( 141 | id="st-weekly", 142 | date_first=date(2024, 1, 8), 143 | date_next=date(2024, 1, 15), 144 | frequency="weekly", 145 | amount=-2_500, 146 | memo="Weekly groceries", 147 | flag_color=None, 148 | flag_name=None, 149 | account_id="acc-1", 150 | account_name="Checking", 151 | payee_id="payee-2", 152 | payee_name="Grocery Store", 153 | category_id="cat-2", 154 | category_name="Groceries", 155 | transfer_account_id=None, 156 | deleted=False, 157 | subtransactions=[], 158 | ) 159 | 160 | # Mock repository to return scheduled transactions 161 | mock_repository.get_scheduled_transactions.return_value = [st_monthly, st_weekly] 162 | 163 | # Test filtering by monthly frequency 164 | result = await mcp_client.call_tool( 165 | "list_scheduled_transactions", {"frequency": "monthly"} 166 | ) 167 | 168 | response_data = extract_response_data(result) 169 | 170 | # Should only have the monthly scheduled transaction 171 | assert len(response_data["scheduled_transactions"]) == 1 172 | assert response_data["scheduled_transactions"][0]["id"] == "st-monthly" 173 | assert response_data["scheduled_transactions"][0]["frequency"] == "monthly" 174 | assert ( 175 | response_data["scheduled_transactions"][0]["payee_name"] == "Electric Company" 176 | ) 177 | 178 | # Check pagination 179 | assert response_data["pagination"]["total_count"] == 1 180 | assert response_data["pagination"]["has_more"] is False 181 | 182 | 183 | async def test_list_scheduled_transactions_with_upcoming_days_filter( 184 | mock_repository: MagicMock, 185 | mcp_client: Client[FastMCPTransport], 186 | ) -> None: 187 | """Test scheduled transaction listing filtered by upcoming days.""" 188 | 189 | # Scheduled for 5 days from now 190 | st_soon = ynab.ScheduledTransactionDetail( 191 | id="st-soon", 192 | date_first=date(2024, 1, 1), 193 | date_next=date(2024, 1, 20), # 5 days from "today" (2024-01-15) 194 | frequency="monthly", 195 | amount=-50_000, 196 | memo="Due soon", 197 | flag_color=None, 198 | flag_name=None, 199 | account_id="acc-1", 200 | account_name="Checking", 201 | payee_id="payee-1", 202 | payee_name="Due Soon Co", 203 | category_id="cat-1", 204 | category_name="Bills", 205 | transfer_account_id=None, 206 | deleted=False, 207 | subtransactions=[], 208 | ) 209 | 210 | # Scheduled for 15 days from now 211 | st_later = ynab.ScheduledTransactionDetail( 212 | id="st-later", 213 | date_first=date(2024, 1, 1), 214 | date_next=date(2024, 1, 30), # 15 days from "today" (2024-01-15) 215 | frequency="monthly", 216 | amount=-75_000, 217 | memo="Due later", 218 | flag_color=None, 219 | flag_name=None, 220 | account_id="acc-1", 221 | account_name="Checking", 222 | payee_id="payee-2", 223 | payee_name="Due Later Co", 224 | category_id="cat-1", 225 | category_name="Bills", 226 | transfer_account_id=None, 227 | deleted=False, 228 | subtransactions=[], 229 | ) 230 | 231 | # Mock repository to return scheduled transactions 232 | mock_repository.get_scheduled_transactions.return_value = [st_soon, st_later] 233 | 234 | # Mock datetime.now() to return a fixed date for testing 235 | from unittest.mock import patch 236 | 237 | import server 238 | 239 | with patch.object(server, "datetime") as mock_datetime: 240 | mock_datetime.now.return_value.date.return_value = date(2024, 1, 15) 241 | 242 | # Test filtering by upcoming 7 days 243 | result = await mcp_client.call_tool( 244 | "list_scheduled_transactions", {"upcoming_days": 7} 245 | ) 246 | 247 | response_data = extract_response_data(result) 248 | 249 | # Should only have the transaction due within 7 days 250 | assert len(response_data["scheduled_transactions"]) == 1 251 | assert response_data["scheduled_transactions"][0]["id"] == "st-soon" 252 | assert response_data["scheduled_transactions"][0]["payee_name"] == "Due Soon Co" 253 | 254 | 255 | async def test_list_scheduled_transactions_with_amount_filter( 256 | mock_repository: MagicMock, 257 | mcp_client: Client[FastMCPTransport], 258 | ) -> None: 259 | """Test scheduled transaction listing filtered by amount range.""" 260 | 261 | st_small = ynab.ScheduledTransactionDetail( 262 | id="st-small", 263 | date_first=date(2024, 1, 1), 264 | date_next=date(2024, 2, 1), 265 | frequency="monthly", 266 | amount=-1_000, # -$1.00 267 | memo="Small expense", 268 | flag_color=None, 269 | flag_name=None, 270 | account_id="acc-1", 271 | account_name="Checking", 272 | payee_id="payee-1", 273 | payee_name="Small Store", 274 | category_id="cat-1", 275 | category_name="Misc", 276 | transfer_account_id=None, 277 | deleted=False, 278 | subtransactions=[], 279 | ) 280 | 281 | st_large = ynab.ScheduledTransactionDetail( 282 | id="st-large", 283 | date_first=date(2024, 1, 1), 284 | date_next=date(2024, 2, 1), 285 | frequency="monthly", 286 | amount=-500_000, # -$500.00 287 | memo="Large expense", 288 | flag_color=None, 289 | flag_name=None, 290 | account_id="acc-1", 291 | account_name="Checking", 292 | payee_id="payee-2", 293 | payee_name="Large Store", 294 | category_id="cat-1", 295 | category_name="Bills", 296 | transfer_account_id=None, 297 | deleted=False, 298 | subtransactions=[], 299 | ) 300 | 301 | # Mock repository to return scheduled transactions 302 | mock_repository.get_scheduled_transactions.return_value = [st_small, st_large] 303 | 304 | # Test filtering by minimum amount (expenses <= -$10, i.e., larger expenses) 305 | result = await mcp_client.call_tool( 306 | "list_scheduled_transactions", {"max_amount": -10} 307 | ) 308 | 309 | response_data = extract_response_data(result) 310 | 311 | # Should only have the large transaction (<= -$10) 312 | assert len(response_data["scheduled_transactions"]) == 1 313 | assert response_data["scheduled_transactions"][0]["id"] == "st-large" 314 | assert response_data["scheduled_transactions"][0]["amount"] == "-500" 315 | 316 | 317 | async def test_list_scheduled_transactions_with_account_filter( 318 | mock_repository: MagicMock, 319 | mcp_client: Client[FastMCPTransport], 320 | ) -> None: 321 | """Test scheduled transaction listing filtered by account.""" 322 | 323 | st_checking = ynab.ScheduledTransactionDetail( 324 | id="st-checking", 325 | date_first=date(2024, 1, 1), 326 | date_next=date(2024, 2, 1), 327 | frequency="monthly", 328 | amount=-100_000, 329 | memo="Checking account expense", 330 | flag_color=None, 331 | flag_name=None, 332 | account_id="acc-checking", 333 | account_name="Checking", 334 | payee_id="payee-1", 335 | payee_name="Merchant A", 336 | category_id="cat-1", 337 | category_name="Bills", 338 | transfer_account_id=None, 339 | deleted=False, 340 | subtransactions=[], 341 | ) 342 | 343 | st_savings = ynab.ScheduledTransactionDetail( 344 | id="st-savings", 345 | date_first=date(2024, 1, 1), 346 | date_next=date(2024, 2, 1), 347 | frequency="monthly", 348 | amount=-50_000, 349 | memo="Savings account expense", 350 | flag_color=None, 351 | flag_name=None, 352 | account_id="acc-savings", 353 | account_name="Savings", 354 | payee_id="payee-2", 355 | payee_name="Merchant B", 356 | category_id="cat-1", 357 | category_name="Bills", 358 | transfer_account_id=None, 359 | deleted=False, 360 | subtransactions=[], 361 | ) 362 | 363 | # Mock repository to return scheduled transactions 364 | mock_repository.get_scheduled_transactions.return_value = [st_checking, st_savings] 365 | 366 | # Test filtering by checking account 367 | result = await mcp_client.call_tool( 368 | "list_scheduled_transactions", {"account_id": "acc-checking"} 369 | ) 370 | 371 | response_data = extract_response_data(result) 372 | 373 | # Should only have the checking account scheduled transaction 374 | assert len(response_data["scheduled_transactions"]) == 1 375 | assert response_data["scheduled_transactions"][0]["id"] == "st-checking" 376 | assert response_data["scheduled_transactions"][0]["account_name"] == "Checking" 377 | 378 | 379 | async def test_list_scheduled_transactions_with_category_filter( 380 | mock_repository: MagicMock, 381 | mcp_client: Client[FastMCPTransport], 382 | ) -> None: 383 | """Test scheduled transaction listing filtered by category.""" 384 | 385 | st_bills = ynab.ScheduledTransactionDetail( 386 | id="st-bills", 387 | date_first=date(2024, 1, 1), 388 | date_next=date(2024, 2, 1), 389 | frequency="monthly", 390 | amount=-100_000, 391 | memo="Monthly bill", 392 | flag_color=None, 393 | flag_name=None, 394 | account_id="acc-1", 395 | account_name="Checking", 396 | payee_id="payee-1", 397 | payee_name="Utility Co", 398 | category_id="cat-bills", 399 | category_name="Bills", 400 | transfer_account_id=None, 401 | deleted=False, 402 | subtransactions=[], 403 | ) 404 | 405 | st_entertainment = ynab.ScheduledTransactionDetail( 406 | id="st-entertainment", 407 | date_first=date(2024, 1, 1), 408 | date_next=date(2024, 2, 1), 409 | frequency="monthly", 410 | amount=-1_500, 411 | memo="Entertainment subscription", 412 | flag_color=None, 413 | flag_name=None, 414 | account_id="acc-1", 415 | account_name="Checking", 416 | payee_id="payee-2", 417 | payee_name="Streaming Service", 418 | category_id="cat-entertainment", 419 | category_name="Entertainment", 420 | transfer_account_id=None, 421 | deleted=False, 422 | subtransactions=[], 423 | ) 424 | 425 | # Mock repository to return scheduled transactions 426 | mock_repository.get_scheduled_transactions.return_value = [ 427 | st_bills, 428 | st_entertainment, 429 | ] 430 | 431 | # Test filtering by bills category 432 | result = await mcp_client.call_tool( 433 | "list_scheduled_transactions", {"category_id": "cat-bills"} 434 | ) 435 | 436 | response_data = extract_response_data(result) 437 | 438 | # Should only have the bills category scheduled transaction 439 | assert len(response_data["scheduled_transactions"]) == 1 440 | assert response_data["scheduled_transactions"][0]["id"] == "st-bills" 441 | assert response_data["scheduled_transactions"][0]["category_name"] == "Bills" 442 | 443 | 444 | async def test_list_scheduled_transactions_with_min_amount_filter( 445 | mock_repository: MagicMock, 446 | mcp_client: Client[FastMCPTransport], 447 | ) -> None: 448 | """Test scheduled transaction listing filtered by minimum amount.""" 449 | 450 | st_small = ynab.ScheduledTransactionDetail( 451 | id="st-small", 452 | date_first=date(2024, 1, 1), 453 | date_next=date(2024, 2, 1), 454 | frequency="monthly", 455 | amount=-1_000, # -$1.00 456 | memo="Small expense", 457 | flag_color=None, 458 | flag_name=None, 459 | account_id="acc-1", 460 | account_name="Checking", 461 | payee_id="payee-1", 462 | payee_name="Small Store", 463 | category_id="cat-1", 464 | category_name="Misc", 465 | transfer_account_id=None, 466 | deleted=False, 467 | subtransactions=[], 468 | ) 469 | 470 | st_large = ynab.ScheduledTransactionDetail( 471 | id="st-large", 472 | date_first=date(2024, 1, 1), 473 | date_next=date(2024, 2, 1), 474 | frequency="monthly", 475 | amount=-500_000, # -$500.00 476 | memo="Large expense", 477 | flag_color=None, 478 | flag_name=None, 479 | account_id="acc-1", 480 | account_name="Checking", 481 | payee_id="payee-2", 482 | payee_name="Large Store", 483 | category_id="cat-1", 484 | category_name="Bills", 485 | transfer_account_id=None, 486 | deleted=False, 487 | subtransactions=[], 488 | ) 489 | 490 | # Mock repository to return scheduled transactions 491 | mock_repository.get_scheduled_transactions.return_value = [st_small, st_large] 492 | 493 | # Test filtering by minimum amount (only expenses >= -$5, excludes -$500) 494 | result = await mcp_client.call_tool( 495 | "list_scheduled_transactions", {"min_amount": -5} 496 | ) 497 | 498 | response_data = extract_response_data(result) 499 | 500 | # Should only have the small transaction (>= -$5) 501 | assert len(response_data["scheduled_transactions"]) == 1 502 | assert response_data["scheduled_transactions"][0]["id"] == "st-small" 503 | assert response_data["scheduled_transactions"][0]["amount"] == "-1" 504 | 505 | 506 | async def test_list_scheduled_transactions_with_payee_filter( 507 | mock_repository: MagicMock, 508 | mcp_client: Client[FastMCPTransport], 509 | ) -> None: 510 | """Test scheduled transaction listing filtered by payee.""" 511 | 512 | st_netflix = ynab.ScheduledTransactionDetail( 513 | id="st-netflix", 514 | date_first=date(2024, 1, 1), 515 | date_next=date(2024, 2, 1), 516 | frequency="monthly", 517 | amount=-1_500, 518 | memo="Netflix subscription", 519 | flag_color=None, 520 | flag_name=None, 521 | account_id="acc-1", 522 | account_name="Checking", 523 | payee_id="payee-netflix", 524 | payee_name="Netflix", 525 | category_id="cat-1", 526 | category_name="Entertainment", 527 | transfer_account_id=None, 528 | deleted=False, 529 | subtransactions=[], 530 | ) 531 | 532 | st_spotify = ynab.ScheduledTransactionDetail( 533 | id="st-spotify", 534 | date_first=date(2024, 1, 1), 535 | date_next=date(2024, 2, 1), 536 | frequency="monthly", 537 | amount=-1_000, 538 | memo="Spotify subscription", 539 | flag_color=None, 540 | flag_name=None, 541 | account_id="acc-1", 542 | account_name="Checking", 543 | payee_id="payee-spotify", 544 | payee_name="Spotify", 545 | category_id="cat-1", 546 | category_name="Entertainment", 547 | transfer_account_id=None, 548 | deleted=False, 549 | subtransactions=[], 550 | ) 551 | 552 | # Mock repository to return scheduled transactions 553 | mock_repository.get_scheduled_transactions.return_value = [st_netflix, st_spotify] 554 | 555 | # Test filtering by Netflix payee 556 | result = await mcp_client.call_tool( 557 | "list_scheduled_transactions", {"payee_id": "payee-netflix"} 558 | ) 559 | 560 | response_data = extract_response_data(result) 561 | 562 | # Should only have the Netflix scheduled transaction 563 | assert len(response_data["scheduled_transactions"]) == 1 564 | assert response_data["scheduled_transactions"][0]["id"] == "st-netflix" 565 | assert response_data["scheduled_transactions"][0]["payee_name"] == "Netflix" 566 | 567 | 568 | async def test_list_scheduled_transactions_pagination( 569 | mock_repository: MagicMock, 570 | mcp_client: Client[FastMCPTransport], 571 | ) -> None: 572 | """Test scheduled transaction listing with pagination.""" 573 | 574 | # Create multiple scheduled transactions 575 | scheduled_transactions = [] 576 | for i in range(15): 577 | st = ynab.ScheduledTransactionDetail( 578 | id=f"st-{i}", 579 | date_first=date(2024, 1, 1), 580 | date_next=date(2024, 2, i + 1), # Different next dates for sorting 581 | frequency="monthly", 582 | amount=-10_000 * (i + 1), 583 | memo=f"Transaction {i}", 584 | flag_color=None, 585 | flag_name=None, 586 | account_id="acc-1", 587 | account_name="Checking", 588 | payee_id=f"payee-{i}", 589 | payee_name=f"Payee {i}", 590 | category_id="cat-1", 591 | category_name="Bills", 592 | transfer_account_id=None, 593 | deleted=False, 594 | subtransactions=[], 595 | ) 596 | scheduled_transactions.append(st) 597 | 598 | # Mock repository to return scheduled transactions 599 | mock_repository.get_scheduled_transactions.return_value = scheduled_transactions 600 | 601 | # Test first page with limit 602 | result = await mcp_client.call_tool( 603 | "list_scheduled_transactions", {"limit": 5, "offset": 0} 604 | ) 605 | 606 | response_data = extract_response_data(result) 607 | 608 | # Should have 5 scheduled transactions 609 | assert len(response_data["scheduled_transactions"]) == 5 610 | assert response_data["pagination"]["total_count"] == 15 611 | assert response_data["pagination"]["has_more"] is True 612 | 613 | # Test second page 614 | result = await mcp_client.call_tool( 615 | "list_scheduled_transactions", {"limit": 5, "offset": 5} 616 | ) 617 | 618 | response_data = extract_response_data(result) 619 | 620 | # Should have next 5 scheduled transactions 621 | assert len(response_data["scheduled_transactions"]) == 5 622 | assert response_data["pagination"]["total_count"] == 15 623 | assert response_data["pagination"]["has_more"] is True 624 | 625 | 626 | async def test_list_scheduled_transactions_with_subtransactions( 627 | mock_repository: MagicMock, 628 | mcp_client: Client[FastMCPTransport], 629 | ) -> None: 630 | """Test scheduled transaction listing with split transactions (subtransactions).""" 631 | 632 | # Create scheduled subtransactions 633 | sub1 = ynab.ScheduledSubTransaction( 634 | id="sub-1", 635 | scheduled_transaction_id="st-split", 636 | amount=-30_000, # -$30.00 for groceries 637 | memo="Groceries portion", 638 | payee_id="payee-1", 639 | payee_name="Grocery Store", 640 | category_id="cat-groceries", 641 | category_name="Groceries", 642 | transfer_account_id=None, 643 | deleted=False, 644 | ) 645 | 646 | sub2 = ynab.ScheduledSubTransaction( 647 | id="sub-2", 648 | scheduled_transaction_id="st-split", 649 | amount=-20_000, # -$20.00 for household 650 | memo="Household portion", 651 | payee_id="payee-1", 652 | payee_name="Grocery Store", 653 | category_id="cat-household", 654 | category_name="Household", 655 | transfer_account_id=None, 656 | deleted=False, 657 | ) 658 | 659 | st_split = ynab.ScheduledTransactionDetail( 660 | id="st-split", 661 | date_first=date(2024, 1, 1), 662 | date_next=date(2024, 2, 1), 663 | frequency="monthly", 664 | amount=-50_000, # -$50.00 total (should equal sum of subtransactions) 665 | memo="Split transaction", 666 | flag_color=None, 667 | flag_name=None, 668 | account_id="acc-1", 669 | account_name="Checking", 670 | payee_id="payee-1", 671 | payee_name="Grocery Store", 672 | category_id=None, # Split transactions don't have a main category 673 | category_name=None, 674 | transfer_account_id=None, 675 | deleted=False, 676 | subtransactions=[sub1, sub2], 677 | ) 678 | 679 | # Mock repository to return scheduled transactions 680 | mock_repository.get_scheduled_transactions.return_value = [st_split] 681 | 682 | result = await mcp_client.call_tool("list_scheduled_transactions", {}) 683 | 684 | response_data = extract_response_data(result) 685 | 686 | # Should have 1 scheduled transaction with subtransactions 687 | assert len(response_data["scheduled_transactions"]) == 1 688 | st = response_data["scheduled_transactions"][0] 689 | 690 | assert st["id"] == "st-split" 691 | assert st["amount"] == "-50" 692 | assert st["memo"] == "Split transaction" 693 | 694 | # Check subtransactions 695 | assert len(st["subtransactions"]) == 2 696 | 697 | assert st["subtransactions"][0]["id"] == "sub-1" 698 | assert st["subtransactions"][0]["amount"] == "-30" 699 | assert st["subtransactions"][0]["category_name"] == "Groceries" 700 | 701 | assert st["subtransactions"][1]["id"] == "sub-2" 702 | assert st["subtransactions"][1]["amount"] == "-20" 703 | assert st["subtransactions"][1]["category_name"] == "Household" 704 | 705 | 706 | async def test_list_scheduled_transactions_with_deleted_subtransactions( 707 | mock_repository: MagicMock, 708 | mcp_client: Client[FastMCPTransport], 709 | ) -> None: 710 | """Test scheduled transaction listing excludes deleted subtransactions.""" 711 | 712 | # Create active and deleted scheduled subtransactions 713 | sub_active = ynab.ScheduledSubTransaction( 714 | id="sub-active", 715 | scheduled_transaction_id="st-mixed", 716 | amount=-30_000, # -$30.00 717 | memo="Active subtransaction", 718 | payee_id="payee-1", 719 | payee_name="Store", 720 | category_id="cat-1", 721 | category_name="Active Category", 722 | transfer_account_id=None, 723 | deleted=False, 724 | ) 725 | 726 | sub_deleted = ynab.ScheduledSubTransaction( 727 | id="sub-deleted", 728 | scheduled_transaction_id="st-mixed", 729 | amount=-20_000, # -$20.00 730 | memo="Deleted subtransaction", 731 | payee_id="payee-1", 732 | payee_name="Store", 733 | category_id="cat-2", 734 | category_name="Deleted Category", 735 | transfer_account_id=None, 736 | deleted=True, # Should be excluded 737 | ) 738 | 739 | st_mixed = ynab.ScheduledTransactionDetail( 740 | id="st-mixed", 741 | date_first=date(2024, 1, 1), 742 | date_next=date(2024, 2, 1), 743 | frequency="monthly", 744 | amount=-50_000, # -$50.00 total 745 | memo="Mixed subtransactions", 746 | flag_color=None, 747 | flag_name=None, 748 | account_id="acc-1", 749 | account_name="Checking", 750 | payee_id="payee-1", 751 | payee_name="Store", 752 | category_id=None, 753 | category_name=None, 754 | transfer_account_id=None, 755 | deleted=False, 756 | subtransactions=[sub_active, sub_deleted], 757 | ) 758 | 759 | # Mock repository to return scheduled transactions 760 | mock_repository.get_scheduled_transactions.return_value = [st_mixed] 761 | 762 | result = await mcp_client.call_tool("list_scheduled_transactions", {}) 763 | 764 | response_data = extract_response_data(result) 765 | 766 | # Should have 1 scheduled transaction with only active subtransactions 767 | assert len(response_data["scheduled_transactions"]) == 1 768 | st = response_data["scheduled_transactions"][0] 769 | 770 | assert st["id"] == "st-mixed" 771 | assert st["amount"] == "-50" 772 | 773 | # Should only have the active subtransaction (deleted one excluded) 774 | assert len(st["subtransactions"]) == 1 775 | assert st["subtransactions"][0]["id"] == "sub-active" 776 | assert st["subtransactions"][0]["category_name"] == "Active Category" 777 | ``` -------------------------------------------------------------------------------- /tests/test_repository.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Test suite for YNABRepository differential sync functionality. 3 | """ 4 | 5 | import logging 6 | import threading 7 | import time 8 | from datetime import date, datetime, timedelta 9 | from typing import Any 10 | from unittest.mock import MagicMock, patch 11 | 12 | import pytest 13 | import ynab 14 | from conftest import create_ynab_account, create_ynab_payee 15 | from ynab.exceptions import ConflictException 16 | 17 | from repository import YNABRepository 18 | 19 | 20 | @pytest.fixture 21 | def repository() -> YNABRepository: 22 | """Create a repository instance for testing.""" 23 | repo = YNABRepository(budget_id="test-budget", access_token="test-token") 24 | 25 | # Disable background sync by default to prevent real API calls during tests 26 | repo._background_sync_enabled = False 27 | 28 | return repo 29 | 30 | 31 | def test_repository_initial_sync(repository: YNABRepository) -> None: 32 | """Test repository initial sync without server knowledge.""" 33 | account1 = create_ynab_account(id="acc-1", name="Checking") 34 | account2 = create_ynab_account(id="acc-2", name="Savings") 35 | 36 | accounts_response = ynab.AccountsResponse( 37 | data=ynab.AccountsResponseData( 38 | accounts=[account1, account2], server_knowledge=100 39 | ) 40 | ) 41 | 42 | with patch("ynab.ApiClient") as mock_client_class: 43 | mock_client = MagicMock() 44 | mock_client_class.return_value.__enter__.return_value = mock_client 45 | 46 | mock_accounts_api = MagicMock() 47 | mock_accounts_api.get_accounts.return_value = accounts_response 48 | 49 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 50 | repository.sync_accounts() 51 | 52 | # Verify initial sync called without last_knowledge_of_server 53 | mock_accounts_api.get_accounts.assert_called_once_with("test-budget") 54 | 55 | # Verify data was stored 56 | accounts = repository.get_accounts() 57 | assert len(accounts) == 2 58 | assert accounts[0].id == "acc-1" 59 | assert accounts[1].id == "acc-2" 60 | 61 | # Verify server knowledge was stored 62 | assert repository._server_knowledge["accounts"] == 100 63 | assert repository.is_initialized() 64 | 65 | 66 | def test_repository_delta_sync(repository: YNABRepository) -> None: 67 | """Test repository delta sync with server knowledge.""" 68 | # Set up initial state 69 | account1 = create_ynab_account(id="acc-1", name="Checking") 70 | repository._data["accounts"] = [account1] 71 | repository._server_knowledge["accounts"] = 100 72 | repository._last_sync = datetime.now() 73 | 74 | # Delta sync with updated account and new account 75 | updated_account1 = create_ynab_account(id="acc-1", name="Updated Checking") 76 | new_account = create_ynab_account(id="acc-2", name="New Savings") 77 | 78 | delta_response = ynab.AccountsResponse( 79 | data=ynab.AccountsResponseData( 80 | accounts=[updated_account1, new_account], server_knowledge=110 81 | ) 82 | ) 83 | 84 | with patch("ynab.ApiClient") as mock_client_class: 85 | mock_client = MagicMock() 86 | mock_client_class.return_value.__enter__.return_value = mock_client 87 | 88 | mock_accounts_api = MagicMock() 89 | mock_accounts_api.get_accounts.return_value = delta_response 90 | 91 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 92 | repository.sync_accounts() 93 | 94 | # Verify delta sync called with last_knowledge_of_server 95 | mock_accounts_api.get_accounts.assert_called_once_with( 96 | "test-budget", last_knowledge_of_server=100 97 | ) 98 | 99 | # Verify deltas were applied 100 | accounts = repository.get_accounts() 101 | assert len(accounts) == 2 102 | 103 | # Find accounts by ID 104 | acc1 = next(acc for acc in accounts if acc.id == "acc-1") 105 | acc2 = next(acc for acc in accounts if acc.id == "acc-2") 106 | 107 | assert acc1.name == "Updated Checking" # Updated 108 | assert acc2.name == "New Savings" # Added 109 | 110 | # Verify server knowledge was updated 111 | assert repository._server_knowledge["accounts"] == 110 112 | 113 | 114 | def test_repository_handles_deleted_accounts(repository: YNABRepository) -> None: 115 | """Test repository handles deleted accounts in delta sync.""" 116 | # Set up initial state with two accounts 117 | account1 = create_ynab_account(id="acc-1", name="Checking") 118 | account2 = create_ynab_account(id="acc-2", name="Savings") 119 | repository._data["accounts"] = [account1, account2] 120 | repository._server_knowledge["accounts"] = 100 121 | repository._last_sync = datetime.now() 122 | 123 | # Delta with one deleted account 124 | deleted_account = create_ynab_account(id="acc-2", name="Savings", deleted=True) 125 | 126 | delta_response = ynab.AccountsResponse( 127 | data=ynab.AccountsResponseData(accounts=[deleted_account], server_knowledge=110) 128 | ) 129 | 130 | with patch("ynab.ApiClient") as mock_client_class: 131 | mock_client = MagicMock() 132 | mock_client_class.return_value.__enter__.return_value = mock_client 133 | 134 | mock_accounts_api = MagicMock() 135 | mock_accounts_api.get_accounts.return_value = delta_response 136 | 137 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 138 | repository.sync_accounts() 139 | 140 | # Verify deleted account was removed 141 | accounts = repository.get_accounts() 142 | assert len(accounts) == 1 143 | assert accounts[0].id == "acc-1" # Only checking account remains 144 | 145 | 146 | def test_repository_fallback_to_full_refresh_on_error( 147 | repository: YNABRepository, 148 | ) -> None: 149 | """Test repository falls back to full refresh when delta sync fails.""" 150 | # Set up initial state 151 | repository._server_knowledge["accounts"] = 100 152 | repository._last_sync = datetime.now() 153 | 154 | account1 = create_ynab_account(id="acc-1", name="Checking") 155 | account2 = create_ynab_account(id="acc-2", name="Savings") 156 | 157 | full_response = ynab.AccountsResponse( 158 | data=ynab.AccountsResponseData( 159 | accounts=[account1, account2], server_knowledge=120 160 | ) 161 | ) 162 | 163 | with patch("ynab.ApiClient") as mock_client_class: 164 | mock_client = MagicMock() 165 | mock_client_class.return_value.__enter__.return_value = mock_client 166 | 167 | mock_accounts_api = MagicMock() 168 | # First call (delta) raises API exception 169 | # Second call (full refresh) succeeds 170 | mock_accounts_api.get_accounts.side_effect = [ 171 | ynab.ApiException(status=500, reason="Server Error"), 172 | full_response, 173 | ] 174 | 175 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 176 | repository.sync_accounts() 177 | 178 | # Verify two calls were made 179 | assert mock_accounts_api.get_accounts.call_count == 2 180 | 181 | # First call with server knowledge (delta attempt) 182 | first_call = mock_accounts_api.get_accounts.call_args_list[0] 183 | assert first_call[0] == ("test-budget",) 184 | assert first_call[1] == {"last_knowledge_of_server": 100} 185 | 186 | # Second call without server knowledge (full refresh) 187 | second_call = mock_accounts_api.get_accounts.call_args_list[1] 188 | assert second_call[0] == ("test-budget",) 189 | assert "last_knowledge_of_server" not in second_call[1] 190 | 191 | # Verify data was stored from full refresh 192 | accounts = repository.get_accounts() 193 | assert len(accounts) == 2 194 | assert repository._server_knowledge["accounts"] == 120 195 | 196 | 197 | def test_repository_lazy_initialization(repository: YNABRepository) -> None: 198 | """Test repository initializes automatically when data is requested.""" 199 | account1 = create_ynab_account(id="acc-1", name="Checking") 200 | 201 | accounts_response = ynab.AccountsResponse( 202 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=100) 203 | ) 204 | 205 | with patch("ynab.ApiClient") as mock_client_class: 206 | mock_client = MagicMock() 207 | mock_client_class.return_value.__enter__.return_value = mock_client 208 | 209 | mock_accounts_api = MagicMock() 210 | mock_accounts_api.get_accounts.return_value = accounts_response 211 | 212 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 213 | # Repository is not initialized initially 214 | assert not repository.is_initialized() 215 | 216 | # Calling get_accounts should trigger sync 217 | accounts = repository.get_accounts() 218 | 219 | # Verify sync was called 220 | mock_accounts_api.get_accounts.assert_called_once() 221 | 222 | # Verify data is available 223 | assert len(accounts) == 1 224 | assert accounts[0].id == "acc-1" 225 | assert repository.is_initialized() 226 | 227 | 228 | def test_repository_thread_safety(repository: YNABRepository) -> None: 229 | """Test repository operations are thread-safe.""" 230 | # This test verifies the locking mechanism works 231 | account1 = create_ynab_account(id="acc-1", name="Checking") 232 | 233 | accounts_response = ynab.AccountsResponse( 234 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=100) 235 | ) 236 | 237 | with patch("ynab.ApiClient") as mock_client_class: 238 | mock_client = MagicMock() 239 | mock_client_class.return_value.__enter__.return_value = mock_client 240 | 241 | mock_accounts_api = MagicMock() 242 | mock_accounts_api.get_accounts.return_value = accounts_response 243 | 244 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 245 | # Multiple calls should be safe 246 | repository.sync_accounts() 247 | accounts1 = repository.get_accounts() 248 | accounts2 = repository.get_accounts() 249 | last_sync1 = repository.last_sync_time() 250 | last_sync2 = repository.last_sync_time() 251 | 252 | # All operations should complete successfully 253 | assert len(accounts1) == 1 254 | assert len(accounts2) == 1 255 | assert last_sync1 is not None 256 | assert last_sync2 is not None 257 | assert last_sync1 == last_sync2 258 | 259 | 260 | def test_repository_payees_initial_sync(repository: YNABRepository) -> None: 261 | """Test repository initial sync for payees without server knowledge.""" 262 | payee1 = create_ynab_payee(id="payee-1", name="Amazon") 263 | payee2 = create_ynab_payee(id="payee-2", name="Starbucks") 264 | 265 | payees_response = ynab.PayeesResponse( 266 | data=ynab.PayeesResponseData(payees=[payee1, payee2], server_knowledge=100) 267 | ) 268 | 269 | with patch("ynab.ApiClient") as mock_client_class: 270 | mock_client = MagicMock() 271 | mock_client_class.return_value.__enter__.return_value = mock_client 272 | 273 | mock_payees_api = MagicMock() 274 | mock_payees_api.get_payees.return_value = payees_response 275 | 276 | with patch("ynab.PayeesApi", return_value=mock_payees_api): 277 | repository.sync_payees() 278 | 279 | # Verify initial sync called without last_knowledge_of_server 280 | mock_payees_api.get_payees.assert_called_once_with("test-budget") 281 | 282 | # Verify data was stored 283 | payees = repository.get_payees() 284 | assert len(payees) == 2 285 | assert payees[0].id == "payee-1" 286 | assert payees[1].id == "payee-2" 287 | 288 | # Verify server knowledge was stored 289 | assert repository._server_knowledge["payees"] == 100 290 | 291 | 292 | def test_repository_payees_delta_sync(repository: YNABRepository) -> None: 293 | """Test repository delta sync for payees with server knowledge.""" 294 | # Set up initial state 295 | payee1 = create_ynab_payee(id="payee-1", name="Amazon") 296 | repository._data["payees"] = [payee1] 297 | repository._server_knowledge["payees"] = 100 298 | repository._last_sync = datetime.now() 299 | 300 | # Delta sync with updated payee and new payee 301 | updated_payee1 = create_ynab_payee(id="payee-1", name="Amazon.com") 302 | new_payee = create_ynab_payee(id="payee-2", name="Target") 303 | 304 | delta_response = ynab.PayeesResponse( 305 | data=ynab.PayeesResponseData( 306 | payees=[updated_payee1, new_payee], server_knowledge=110 307 | ) 308 | ) 309 | 310 | with patch("ynab.ApiClient") as mock_client_class: 311 | mock_client = MagicMock() 312 | mock_client_class.return_value.__enter__.return_value = mock_client 313 | 314 | mock_payees_api = MagicMock() 315 | mock_payees_api.get_payees.return_value = delta_response 316 | 317 | with patch("ynab.PayeesApi", return_value=mock_payees_api): 318 | repository.sync_payees() 319 | 320 | # Verify delta sync called with last_knowledge_of_server 321 | mock_payees_api.get_payees.assert_called_once_with( 322 | "test-budget", last_knowledge_of_server=100 323 | ) 324 | 325 | # Verify deltas were applied 326 | payees = repository.get_payees() 327 | assert len(payees) == 2 328 | 329 | # Find payees by ID 330 | p1 = next(p for p in payees if p.id == "payee-1") 331 | p2 = next(p for p in payees if p.id == "payee-2") 332 | 333 | assert p1.name == "Amazon.com" # Updated 334 | assert p2.name == "Target" # Added 335 | 336 | # Verify server knowledge was updated 337 | assert repository._server_knowledge["payees"] == 110 338 | 339 | 340 | def test_repository_payees_handles_deleted(repository: YNABRepository) -> None: 341 | """Test repository handles deleted payees in delta sync.""" 342 | # Set up initial state with two payees 343 | payee1 = create_ynab_payee(id="payee-1", name="Amazon") 344 | payee2 = create_ynab_payee(id="payee-2", name="Old Store") 345 | repository._data["payees"] = [payee1, payee2] 346 | repository._server_knowledge["payees"] = 100 347 | repository._last_sync = datetime.now() 348 | 349 | # Delta with one deleted payee 350 | deleted_payee = create_ynab_payee(id="payee-2", name="Old Store", deleted=True) 351 | 352 | delta_response = ynab.PayeesResponse( 353 | data=ynab.PayeesResponseData(payees=[deleted_payee], server_knowledge=110) 354 | ) 355 | 356 | with patch("ynab.ApiClient") as mock_client_class: 357 | mock_client = MagicMock() 358 | mock_client_class.return_value.__enter__.return_value = mock_client 359 | 360 | mock_payees_api = MagicMock() 361 | mock_payees_api.get_payees.return_value = delta_response 362 | 363 | with patch("ynab.PayeesApi", return_value=mock_payees_api): 364 | repository.sync_payees() 365 | 366 | # Verify deleted payee was removed 367 | payees = repository.get_payees() 368 | assert len(payees) == 1 369 | assert payees[0].id == "payee-1" # Only Amazon remains 370 | 371 | 372 | def test_repository_payees_lazy_initialization(repository: YNABRepository) -> None: 373 | """Test payees repository initializes automatically when data is requested.""" 374 | payee1 = create_ynab_payee(id="payee-1", name="Amazon") 375 | 376 | payees_response = ynab.PayeesResponse( 377 | data=ynab.PayeesResponseData(payees=[payee1], server_knowledge=100) 378 | ) 379 | 380 | with patch("ynab.ApiClient") as mock_client_class: 381 | mock_client = MagicMock() 382 | mock_client_class.return_value.__enter__.return_value = mock_client 383 | 384 | mock_payees_api = MagicMock() 385 | mock_payees_api.get_payees.return_value = payees_response 386 | 387 | with patch("ynab.PayeesApi", return_value=mock_payees_api): 388 | # Repository payees is not initialized initially 389 | assert "payees" not in repository._data 390 | 391 | # Calling get_payees should trigger sync 392 | payees = repository.get_payees() 393 | 394 | # Verify sync was called 395 | mock_payees_api.get_payees.assert_called_once() 396 | 397 | # Verify data is available 398 | assert len(payees) == 1 399 | assert payees[0].id == "payee-1" 400 | 401 | 402 | def create_ynab_category_group( 403 | *, 404 | id: str = "group-1", 405 | name: str = "Test Group", 406 | deleted: bool = False, 407 | **kwargs: Any, 408 | ) -> ynab.CategoryGroupWithCategories: 409 | """Create a YNAB CategoryGroupWithCategories for testing with sensible defaults.""" 410 | categories = kwargs.get("categories", []) 411 | return ynab.CategoryGroupWithCategories( 412 | id=id, 413 | name=name, 414 | hidden=kwargs.get("hidden", False), 415 | deleted=deleted, 416 | categories=categories, 417 | ) 418 | 419 | 420 | def test_repository_category_groups_initial_sync(repository: YNABRepository) -> None: 421 | """Test repository initial sync for category groups without server knowledge.""" 422 | group1 = create_ynab_category_group(id="group-1", name="Monthly Bills") 423 | group2 = create_ynab_category_group(id="group-2", name="Everyday Expenses") 424 | 425 | categories_response = ynab.CategoriesResponse( 426 | data=ynab.CategoriesResponseData( 427 | category_groups=[group1, group2], server_knowledge=100 428 | ) 429 | ) 430 | 431 | with patch("ynab.ApiClient") as mock_client_class: 432 | mock_client = MagicMock() 433 | mock_client_class.return_value.__enter__.return_value = mock_client 434 | 435 | mock_categories_api = MagicMock() 436 | mock_categories_api.get_categories.return_value = categories_response 437 | 438 | with patch("ynab.CategoriesApi", return_value=mock_categories_api): 439 | repository.sync_category_groups() 440 | 441 | # Verify initial sync called without last_knowledge_of_server 442 | mock_categories_api.get_categories.assert_called_once_with("test-budget") 443 | 444 | # Verify data was stored 445 | category_groups = repository.get_category_groups() 446 | assert len(category_groups) == 2 447 | assert category_groups[0].id == "group-1" 448 | assert category_groups[1].id == "group-2" 449 | 450 | # Verify server knowledge was stored 451 | assert repository._server_knowledge["category_groups"] == 100 452 | 453 | 454 | def test_repository_category_groups_delta_sync(repository: YNABRepository) -> None: 455 | """Test repository delta sync for category groups with server knowledge.""" 456 | # Set up initial state 457 | group1 = create_ynab_category_group(id="group-1", name="Monthly Bills") 458 | repository._data["category_groups"] = [group1] 459 | repository._server_knowledge["category_groups"] = 100 460 | repository._last_sync = datetime.now() 461 | 462 | # Delta sync with updated group and new group 463 | updated_group1 = create_ynab_category_group(id="group-1", name="Fixed Expenses") 464 | new_group = create_ynab_category_group(id="group-2", name="Variable Expenses") 465 | 466 | delta_response = ynab.CategoriesResponse( 467 | data=ynab.CategoriesResponseData( 468 | category_groups=[updated_group1, new_group], server_knowledge=110 469 | ) 470 | ) 471 | 472 | with patch("ynab.ApiClient") as mock_client_class: 473 | mock_client = MagicMock() 474 | mock_client_class.return_value.__enter__.return_value = mock_client 475 | 476 | mock_categories_api = MagicMock() 477 | mock_categories_api.get_categories.return_value = delta_response 478 | 479 | with patch("ynab.CategoriesApi", return_value=mock_categories_api): 480 | repository.sync_category_groups() 481 | 482 | # Verify delta sync called with last_knowledge_of_server 483 | mock_categories_api.get_categories.assert_called_once_with( 484 | "test-budget", last_knowledge_of_server=100 485 | ) 486 | 487 | # Verify deltas were applied 488 | category_groups = repository.get_category_groups() 489 | assert len(category_groups) == 2 490 | 491 | # Find groups by ID 492 | g1 = next(g for g in category_groups if g.id == "group-1") 493 | g2 = next(g for g in category_groups if g.id == "group-2") 494 | 495 | assert g1.name == "Fixed Expenses" # Updated 496 | assert g2.name == "Variable Expenses" # Added 497 | 498 | # Verify server knowledge was updated 499 | assert repository._server_knowledge["category_groups"] == 110 500 | 501 | 502 | def test_repository_category_groups_handles_deleted(repository: YNABRepository) -> None: 503 | """Test repository handles deleted category groups in delta sync.""" 504 | # Set up initial state with two groups 505 | group1 = create_ynab_category_group(id="group-1", name="Monthly Bills") 506 | group2 = create_ynab_category_group(id="group-2", name="Old Category") 507 | repository._data["category_groups"] = [group1, group2] 508 | repository._server_knowledge["category_groups"] = 100 509 | repository._last_sync = datetime.now() 510 | 511 | # Delta with one deleted group 512 | deleted_group = create_ynab_category_group( 513 | id="group-2", name="Old Category", deleted=True 514 | ) 515 | 516 | delta_response = ynab.CategoriesResponse( 517 | data=ynab.CategoriesResponseData( 518 | category_groups=[deleted_group], server_knowledge=110 519 | ) 520 | ) 521 | 522 | with patch("ynab.ApiClient") as mock_client_class: 523 | mock_client = MagicMock() 524 | mock_client_class.return_value.__enter__.return_value = mock_client 525 | 526 | mock_categories_api = MagicMock() 527 | mock_categories_api.get_categories.return_value = delta_response 528 | 529 | with patch("ynab.CategoriesApi", return_value=mock_categories_api): 530 | repository.sync_category_groups() 531 | 532 | # Verify deleted group was removed 533 | category_groups = repository.get_category_groups() 534 | assert len(category_groups) == 1 535 | assert category_groups[0].id == "group-1" # Only Monthly Bills remains 536 | 537 | 538 | def test_repository_category_groups_lazy_initialization( 539 | repository: YNABRepository, 540 | ) -> None: 541 | """Test category groups repository initializes automatically when data requested.""" 542 | group1 = create_ynab_category_group(id="group-1", name="Monthly Bills") 543 | 544 | categories_response = ynab.CategoriesResponse( 545 | data=ynab.CategoriesResponseData(category_groups=[group1], server_knowledge=100) 546 | ) 547 | 548 | with patch("ynab.ApiClient") as mock_client_class: 549 | mock_client = MagicMock() 550 | mock_client_class.return_value.__enter__.return_value = mock_client 551 | 552 | mock_categories_api = MagicMock() 553 | mock_categories_api.get_categories.return_value = categories_response 554 | 555 | with patch("ynab.CategoriesApi", return_value=mock_categories_api): 556 | # Repository category groups is not initialized initially 557 | assert "category_groups" not in repository._data 558 | 559 | # Calling get_category_groups should trigger sync 560 | category_groups = repository.get_category_groups() 561 | 562 | # Verify sync was called 563 | mock_categories_api.get_categories.assert_called_once() 564 | 565 | # Verify data is available 566 | assert len(category_groups) == 1 567 | assert category_groups[0].id == "group-1" 568 | 569 | 570 | def create_ynab_transaction( 571 | *, 572 | id: str = "txn-1", 573 | account_id: str = "acc-1", 574 | amount: int = -50_000, # -$50.00 575 | memo: str | None = "Test Transaction", 576 | cleared: str = "cleared", 577 | approved: bool = True, 578 | deleted: bool = False, 579 | **kwargs: Any, 580 | ) -> ynab.TransactionDetail: 581 | """Create a YNAB TransactionDetail for testing with sensible defaults.""" 582 | return ynab.TransactionDetail( 583 | id=id, 584 | date=kwargs.get("date", date.today()), 585 | amount=amount, 586 | memo=memo, 587 | cleared=ynab.TransactionClearedStatus(cleared), 588 | approved=approved, 589 | flag_color=kwargs.get("flag_color"), 590 | account_id=account_id, 591 | account_name=kwargs.get("account_name", "Test Account"), 592 | payee_id=kwargs.get("payee_id"), 593 | payee_name=kwargs.get("payee_name", "Test Payee"), 594 | category_id=kwargs.get("category_id"), 595 | category_name=kwargs.get("category_name"), 596 | transfer_account_id=kwargs.get("transfer_account_id"), 597 | transfer_transaction_id=kwargs.get("transfer_transaction_id"), 598 | matched_transaction_id=kwargs.get("matched_transaction_id"), 599 | import_id=kwargs.get("import_id"), 600 | import_payee_name=kwargs.get("import_payee_name"), 601 | import_payee_name_original=kwargs.get("import_payee_name_original"), 602 | debt_transaction_type=kwargs.get("debt_transaction_type"), 603 | subtransactions=kwargs.get("subtransactions", []), 604 | deleted=deleted, 605 | ) 606 | 607 | 608 | def test_repository_transactions_initial_sync(repository: YNABRepository) -> None: 609 | """Test repository initial sync for transactions without server knowledge.""" 610 | txn1 = create_ynab_transaction(id="txn-1", amount=-25_000, memo="Groceries") 611 | txn2 = create_ynab_transaction(id="txn-2", amount=-15_000, memo="Gas") 612 | 613 | transactions_response = ynab.TransactionsResponse( 614 | data=ynab.TransactionsResponseData( 615 | transactions=[txn1, txn2], server_knowledge=100 616 | ) 617 | ) 618 | 619 | with patch("ynab.ApiClient") as mock_client_class: 620 | mock_client = MagicMock() 621 | mock_client_class.return_value.__enter__.return_value = mock_client 622 | 623 | mock_transactions_api = MagicMock() 624 | mock_transactions_api.get_transactions.return_value = transactions_response 625 | 626 | with patch("ynab.TransactionsApi", return_value=mock_transactions_api): 627 | repository.sync_transactions() 628 | 629 | # Verify initial sync called without last_knowledge_of_server 630 | mock_transactions_api.get_transactions.assert_called_once_with("test-budget") 631 | 632 | # Verify data was stored 633 | transactions = repository.get_transactions() 634 | assert len(transactions) == 2 635 | assert transactions[0].id == "txn-1" 636 | assert transactions[1].id == "txn-2" 637 | 638 | # Verify server knowledge was stored 639 | assert repository._server_knowledge["transactions"] == 100 640 | 641 | 642 | def test_repository_transactions_delta_sync(repository: YNABRepository) -> None: 643 | """Test repository delta sync for transactions with server knowledge.""" 644 | # Set up initial state 645 | txn1 = create_ynab_transaction(id="txn-1", amount=-25_000, memo="Groceries") 646 | repository._data["transactions"] = [txn1] 647 | repository._server_knowledge["transactions"] = 100 648 | repository._last_sync = datetime.now() 649 | 650 | # Delta sync with updated transaction and new transaction 651 | updated_txn1 = create_ynab_transaction( 652 | id="txn-1", amount=-25_000, memo="Groceries (Updated)" 653 | ) 654 | new_txn = create_ynab_transaction(id="txn-2", amount=-15_000, memo="Gas") 655 | 656 | delta_response = ynab.TransactionsResponse( 657 | data=ynab.TransactionsResponseData( 658 | transactions=[updated_txn1, new_txn], server_knowledge=110 659 | ) 660 | ) 661 | 662 | with patch("ynab.ApiClient") as mock_client_class: 663 | mock_client = MagicMock() 664 | mock_client_class.return_value.__enter__.return_value = mock_client 665 | 666 | mock_transactions_api = MagicMock() 667 | mock_transactions_api.get_transactions.return_value = delta_response 668 | 669 | with patch("ynab.TransactionsApi", return_value=mock_transactions_api): 670 | repository.sync_transactions() 671 | 672 | # Verify delta sync called with last_knowledge_of_server 673 | mock_transactions_api.get_transactions.assert_called_once_with( 674 | "test-budget", last_knowledge_of_server=100 675 | ) 676 | 677 | # Verify deltas were applied 678 | transactions = repository.get_transactions() 679 | assert len(transactions) == 2 680 | 681 | # Find transactions by ID 682 | t1 = next(t for t in transactions if t.id == "txn-1") 683 | t2 = next(t for t in transactions if t.id == "txn-2") 684 | 685 | assert t1.memo == "Groceries (Updated)" # Updated 686 | assert t2.memo == "Gas" # Added 687 | 688 | # Verify server knowledge was updated 689 | assert repository._server_knowledge["transactions"] == 110 690 | 691 | 692 | def test_repository_transactions_handles_deleted(repository: YNABRepository) -> None: 693 | """Test repository handles deleted transactions in delta sync.""" 694 | # Set up initial state with two transactions 695 | txn1 = create_ynab_transaction(id="txn-1", amount=-25_000, memo="Groceries") 696 | txn2 = create_ynab_transaction(id="txn-2", amount=-15_000, memo="Gas") 697 | repository._data["transactions"] = [txn1, txn2] 698 | repository._server_knowledge["transactions"] = 100 699 | repository._last_sync = datetime.now() 700 | 701 | # Delta with one deleted transaction 702 | deleted_txn = create_ynab_transaction( 703 | id="txn-2", amount=-15_000, memo="Gas", deleted=True 704 | ) 705 | 706 | delta_response = ynab.TransactionsResponse( 707 | data=ynab.TransactionsResponseData( 708 | transactions=[deleted_txn], server_knowledge=110 709 | ) 710 | ) 711 | 712 | with patch("ynab.ApiClient") as mock_client_class: 713 | mock_client = MagicMock() 714 | mock_client_class.return_value.__enter__.return_value = mock_client 715 | 716 | mock_transactions_api = MagicMock() 717 | mock_transactions_api.get_transactions.return_value = delta_response 718 | 719 | with patch("ynab.TransactionsApi", return_value=mock_transactions_api): 720 | repository.sync_transactions() 721 | 722 | # Verify deleted transaction was removed 723 | transactions = repository.get_transactions() 724 | assert len(transactions) == 1 725 | assert transactions[0].id == "txn-1" # Only groceries transaction remains 726 | 727 | 728 | def test_repository_transactions_lazy_initialization( 729 | repository: YNABRepository, 730 | ) -> None: 731 | """Test transactions repository initializes automatically when data requested.""" 732 | txn1 = create_ynab_transaction(id="txn-1", amount=-25_000, memo="Groceries") 733 | 734 | transactions_response = ynab.TransactionsResponse( 735 | data=ynab.TransactionsResponseData(transactions=[txn1], server_knowledge=100) 736 | ) 737 | 738 | with patch("ynab.ApiClient") as mock_client_class: 739 | mock_client = MagicMock() 740 | mock_client_class.return_value.__enter__.return_value = mock_client 741 | 742 | mock_transactions_api = MagicMock() 743 | mock_transactions_api.get_transactions.return_value = transactions_response 744 | 745 | with patch("ynab.TransactionsApi", return_value=mock_transactions_api): 746 | # Repository transactions is not initialized initially 747 | assert "transactions" not in repository._data 748 | 749 | # Calling get_transactions should trigger sync 750 | transactions = repository.get_transactions() 751 | 752 | # Verify sync was called 753 | mock_transactions_api.get_transactions.assert_called_once() 754 | 755 | # Verify data is available 756 | assert len(transactions) == 1 757 | assert transactions[0].id == "txn-1" 758 | 759 | 760 | # ===== EDGE CASE AND ERROR HANDLING TESTS ===== 761 | 762 | 763 | def test_repository_needs_sync_functionality(repository: YNABRepository) -> None: 764 | """Test needs_sync() method behavior.""" 765 | # Fresh repository should need sync 766 | assert repository.needs_sync() 767 | 768 | # After setting last_sync to now, should not need sync 769 | repository._last_sync = datetime.now() 770 | assert not repository.needs_sync() 771 | 772 | # After 6 minutes, should need sync (default threshold is 5 minutes) 773 | repository._last_sync = datetime.now() - timedelta(minutes=6) 774 | assert repository.needs_sync() 775 | 776 | # Custom threshold - should not need sync at 3 minutes with 5 minute threshold 777 | repository._last_sync = datetime.now() - timedelta(minutes=3) 778 | assert not repository.needs_sync(max_age_minutes=5) 779 | 780 | # Custom threshold - should need sync at 3 minutes with 2 minute threshold 781 | assert repository.needs_sync(max_age_minutes=2) 782 | 783 | 784 | def test_repository_conflict_exception_fallback(repository: YNABRepository) -> None: 785 | """Test that ConflictException triggers fallback to full sync.""" 786 | repository._server_knowledge["accounts"] = 100 787 | repository._last_sync = datetime.now() 788 | 789 | account1 = create_ynab_account(id="acc-1", name="Checking") 790 | full_response = ynab.AccountsResponse( 791 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=120) 792 | ) 793 | 794 | with patch("ynab.ApiClient") as mock_client_class: 795 | mock_client = MagicMock() 796 | mock_client_class.return_value.__enter__.return_value = mock_client 797 | 798 | mock_accounts_api = MagicMock() 799 | # First call (delta) raises ConflictException (409) 800 | # Second call (full refresh) succeeds 801 | mock_accounts_api.get_accounts.side_effect = [ 802 | ConflictException(status=409, reason="Conflict"), 803 | full_response, 804 | ] 805 | 806 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 807 | repository.sync_accounts() 808 | 809 | # Verify fallback behavior 810 | assert mock_accounts_api.get_accounts.call_count == 2 811 | accounts = repository.get_accounts() 812 | assert len(accounts) == 1 813 | assert repository._server_knowledge["accounts"] == 120 814 | 815 | 816 | def test_repository_rate_limit_retry_behavior(repository: YNABRepository) -> None: 817 | """Test that 429 rate limit triggers retry with exponential backoff.""" 818 | account1 = create_ynab_account(id="acc-1", name="Checking") 819 | success_response = ynab.AccountsResponse( 820 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=100) 821 | ) 822 | 823 | with patch("ynab.ApiClient") as mock_client_class: 824 | mock_client = MagicMock() 825 | mock_client_class.return_value.__enter__.return_value = mock_client 826 | 827 | mock_accounts_api = MagicMock() 828 | # First call raises 429, second call succeeds 829 | mock_accounts_api.get_accounts.side_effect = [ 830 | ynab.ApiException(status=429, reason="Too Many Requests"), 831 | success_response, 832 | ] 833 | 834 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 835 | with patch("time.sleep") as mock_sleep: 836 | repository.sync_accounts() 837 | 838 | # Verify retry behavior 839 | assert mock_accounts_api.get_accounts.call_count == 2 840 | mock_sleep.assert_called_once_with(1) # First retry waits 2^0 = 1 second 841 | accounts = repository.get_accounts() 842 | assert len(accounts) == 1 843 | 844 | 845 | def test_repository_rate_limit_max_retries_exceeded(repository: YNABRepository) -> None: 846 | """Test that repeated 429s eventually give up after max retries.""" 847 | with patch("ynab.ApiClient") as mock_client_class: 848 | mock_client = MagicMock() 849 | mock_client_class.return_value.__enter__.return_value = mock_client 850 | 851 | mock_accounts_api = MagicMock() 852 | # Always return 429 853 | mock_accounts_api.get_accounts.side_effect = ynab.ApiException( 854 | status=429, reason="Too Many Requests" 855 | ) 856 | 857 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 858 | with patch("time.sleep") as mock_sleep: 859 | with pytest.raises(ynab.ApiException) as exc_info: 860 | repository.sync_accounts() 861 | 862 | # Verify max retries behavior (3 attempts total) 863 | assert mock_accounts_api.get_accounts.call_count == 3 864 | assert exc_info.value.status == 429 865 | # Should have called sleep twice (after first and second attempts) 866 | assert mock_sleep.call_count == 2 867 | 868 | 869 | def test_repository_unexpected_exception_not_caught(repository: YNABRepository) -> None: 870 | """Test that unexpected exceptions are re-raised, not silently caught.""" 871 | with patch("ynab.ApiClient") as mock_client_class: 872 | mock_client = MagicMock() 873 | mock_client_class.return_value.__enter__.return_value = mock_client 874 | 875 | mock_accounts_api = MagicMock() 876 | # Raise a non-API exception 877 | mock_accounts_api.get_accounts.side_effect = ValueError("Unexpected error") 878 | 879 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 880 | with pytest.raises(ValueError) as exc_info: 881 | repository.sync_accounts() 882 | 883 | assert str(exc_info.value) == "Unexpected error" 884 | 885 | 886 | def test_repository_background_sync_not_blocking(repository: YNABRepository) -> None: 887 | """Test that background sync doesn't block data access.""" 888 | # Enable background sync for this test 889 | repository._background_sync_enabled = True 890 | 891 | # Set up stale data 892 | account1 = create_ynab_account(id="acc-1", name="Checking") 893 | repository._data["accounts"] = [account1] 894 | repository._last_sync = datetime.now() - timedelta(minutes=10) # Stale 895 | 896 | # Mock needs_sync to return True (stale) 897 | with patch.object(repository, "needs_sync", return_value=True): 898 | with patch.object(repository, "_trigger_background_sync") as mock_bg_sync: 899 | # Getting accounts should return existing data immediately 900 | accounts = repository.get_accounts() 901 | 902 | # Verify we got the stale data instantly 903 | assert len(accounts) == 1 904 | assert accounts[0].id == "acc-1" 905 | 906 | # Verify background sync was triggered 907 | mock_bg_sync.assert_called_once_with("accounts") 908 | 909 | 910 | def test_repository_background_sync_error_handling(repository: YNABRepository) -> None: 911 | """Test that background sync errors don't crash or affect data access.""" 912 | account1 = create_ynab_account(id="acc-1", name="Checking") 913 | repository._data["accounts"] = [account1] 914 | repository._last_sync = datetime.now() - timedelta(minutes=10) # Stale 915 | 916 | # Should still return existing data without crashing 917 | accounts = repository.get_accounts() 918 | assert len(accounts) == 1 919 | assert accounts[0].id == "acc-1" 920 | 921 | 922 | def test_repository_concurrent_access_safety(repository: YNABRepository) -> None: 923 | """Test that concurrent access to repository data is thread-safe.""" 924 | 925 | # Set up initial data using the lock to ensure thread safety 926 | account1 = create_ynab_account(id="acc-1", name="Checking") 927 | with repository._lock: 928 | repository._data["accounts"] = [account1] 929 | repository._last_sync = datetime.now() 930 | 931 | results = [] 932 | errors = [] 933 | results_lock = threading.Lock() 934 | 935 | # Track which thread should fail for error coverage 936 | should_fail = [True] # Use list to make it mutable in closure 937 | 938 | def access_data() -> None: 939 | try: 940 | # Make the first thread fail to test exception handling 941 | if should_fail[0]: 942 | should_fail[0] = False # Only fail once 943 | raise RuntimeError("Test error for coverage") 944 | 945 | accounts = repository.get_accounts() 946 | # Simulate some processing time 947 | time.sleep(0.01) 948 | sync_time = repository.last_sync_time() 949 | 950 | # Thread-safe result collection 951 | with results_lock: 952 | results.append((len(accounts), sync_time is not None)) 953 | except Exception as e: 954 | with results_lock: 955 | errors.append(e) 956 | 957 | # Start multiple threads accessing data concurrently 958 | threads = [] 959 | for _ in range(10): 960 | t = threading.Thread(target=access_data) 961 | threads.append(t) 962 | t.start() 963 | 964 | for t in threads: 965 | t.join() 966 | 967 | # Verify error was captured and other threads succeeded 968 | assert len(errors) == 1, f"Expected 1 error, got {len(errors)}: {errors}" 969 | assert isinstance(errors[0], RuntimeError), ( 970 | f"Expected RuntimeError, got {type(errors[0])}" 971 | ) 972 | assert len(results) == 9, ( 973 | f"Expected 9 results, got {len(results)}: {results}" 974 | ) # 9 successful threads 975 | 976 | # Extract length and sync_time results from successful threads 977 | length_results = [r[0] for r in results] 978 | sync_time_results = [r[1] for r in results] 979 | 980 | assert all(r == 1 for r in length_results), ( 981 | f"Length results not all 1: {length_results}" 982 | ) 983 | assert all(r is True for r in sync_time_results), ( 984 | f"Sync time results not all True: {sync_time_results}" 985 | ) 986 | 987 | 988 | def test_repository_concurrent_access_error_handling( 989 | repository: YNABRepository, 990 | ) -> None: 991 | """Test that errors in concurrent access are properly captured.""" 992 | 993 | # Set up initial data 994 | account1 = create_ynab_account(id="acc-1", name="Checking") 995 | with repository._lock: 996 | repository._data["accounts"] = [account1] 997 | repository._last_sync = datetime.now() 998 | 999 | errors = [] 1000 | results_lock = threading.Lock() 1001 | 1002 | def access_data_with_error() -> None: 1003 | try: 1004 | # Intentionally cause an error by accessing invalid attribute 1005 | _ = repository.get_accounts() 1006 | raise ValueError("Test error for coverage") 1007 | except Exception as e: 1008 | with results_lock: 1009 | errors.append(e) 1010 | 1011 | # Start a thread that will cause an error 1012 | thread = threading.Thread(target=access_data_with_error) 1013 | thread.start() 1014 | thread.join() 1015 | 1016 | # Verify the error was captured 1017 | assert len(errors) == 1 1018 | assert isinstance(errors[0], ValueError) 1019 | assert str(errors[0]) == "Test error for coverage" 1020 | 1021 | 1022 | def test_repository_lazy_init_only_syncs_once(repository: YNABRepository) -> None: 1023 | """Test that lazy initialization only syncs once even with concurrent access.""" 1024 | account1 = create_ynab_account(id="acc-1", name="Checking") 1025 | success_response = ynab.AccountsResponse( 1026 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=100) 1027 | ) 1028 | 1029 | with patch("ynab.ApiClient") as mock_client_class: 1030 | mock_client = MagicMock() 1031 | mock_client_class.return_value.__enter__.return_value = mock_client 1032 | 1033 | mock_accounts_api = MagicMock() 1034 | mock_accounts_api.get_accounts.return_value = success_response 1035 | 1036 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1037 | # Multiple calls to get_accounts should only sync once 1038 | accounts1 = repository.get_accounts() 1039 | accounts2 = repository.get_accounts() 1040 | accounts3 = repository.get_accounts() 1041 | 1042 | # Verify only one API call was made 1043 | mock_accounts_api.get_accounts.assert_called_once() 1044 | 1045 | # All results should be consistent 1046 | assert len(accounts1) == len(accounts2) == len(accounts3) == 1 1047 | 1048 | 1049 | def test_repository_handles_empty_api_responses(repository: YNABRepository) -> None: 1050 | """Test repository gracefully handles empty API responses.""" 1051 | empty_response = ynab.AccountsResponse( 1052 | data=ynab.AccountsResponseData(accounts=[], server_knowledge=100) 1053 | ) 1054 | 1055 | with patch("ynab.ApiClient") as mock_client_class: 1056 | mock_client = MagicMock() 1057 | mock_client_class.return_value.__enter__.return_value = mock_client 1058 | 1059 | mock_accounts_api = MagicMock() 1060 | mock_accounts_api.get_accounts.return_value = empty_response 1061 | 1062 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1063 | repository.sync_accounts() 1064 | 1065 | # Should handle empty response gracefully 1066 | accounts = repository.get_accounts() 1067 | assert len(accounts) == 0 1068 | assert repository._server_knowledge["accounts"] == 100 1069 | assert repository.is_initialized() 1070 | 1071 | 1072 | def test_repository_server_knowledge_progression(repository: YNABRepository) -> None: 1073 | """Test that server knowledge progresses correctly through multiple syncs.""" 1074 | account1 = create_ynab_account(id="acc-1", name="Checking") 1075 | 1076 | # First sync 1077 | response1 = ynab.AccountsResponse( 1078 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=100) 1079 | ) 1080 | 1081 | # Second sync with higher knowledge 1082 | account2 = create_ynab_account(id="acc-2", name="Savings") 1083 | response2 = ynab.AccountsResponse( 1084 | data=ynab.AccountsResponseData( 1085 | accounts=[account1, account2], server_knowledge=110 1086 | ) 1087 | ) 1088 | 1089 | with patch("ynab.ApiClient") as mock_client_class: 1090 | mock_client = MagicMock() 1091 | mock_client_class.return_value.__enter__.return_value = mock_client 1092 | 1093 | mock_accounts_api = MagicMock() 1094 | mock_accounts_api.get_accounts.side_effect = [response1, response2] 1095 | 1096 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1097 | # First sync 1098 | repository.sync_accounts() 1099 | assert repository._server_knowledge["accounts"] == 100 1100 | 1101 | # Second sync should use previous knowledge 1102 | repository.sync_accounts() 1103 | assert repository._server_knowledge["accounts"] == 110 1104 | 1105 | # Verify second call used delta sync 1106 | calls = mock_accounts_api.get_accounts.call_args_list 1107 | assert len(calls) == 2 1108 | assert calls[0][1] == {} # First call without last_knowledge 1109 | assert calls[1][1] == { 1110 | "last_knowledge_of_server": 100 1111 | } # Second call with knowledge 1112 | 1113 | 1114 | def test_repository_mixed_entity_types_independent(repository: YNABRepository) -> None: 1115 | """Test that different entity types sync independently.""" 1116 | # Set up different sync states for different entity types 1117 | account1 = create_ynab_account(id="acc-1", name="Checking") 1118 | payee1 = create_ynab_payee(id="payee-1", name="Amazon") 1119 | 1120 | accounts_response = ynab.AccountsResponse( 1121 | data=ynab.AccountsResponseData(accounts=[account1], server_knowledge=100) 1122 | ) 1123 | payees_response = ynab.PayeesResponse( 1124 | data=ynab.PayeesResponseData(payees=[payee1], server_knowledge=200) 1125 | ) 1126 | 1127 | with patch("ynab.ApiClient") as mock_client_class: 1128 | mock_client = MagicMock() 1129 | mock_client_class.return_value.__enter__.return_value = mock_client 1130 | 1131 | mock_accounts_api = MagicMock() 1132 | mock_accounts_api.get_accounts.return_value = accounts_response 1133 | 1134 | mock_payees_api = MagicMock() 1135 | mock_payees_api.get_payees.return_value = payees_response 1136 | 1137 | with ( 1138 | patch("ynab.AccountsApi", return_value=mock_accounts_api), 1139 | patch("ynab.PayeesApi", return_value=mock_payees_api), 1140 | ): 1141 | # Sync different entity types 1142 | repository.sync_accounts() 1143 | repository.sync_payees() 1144 | 1145 | # Verify independent server knowledge tracking 1146 | assert repository._server_knowledge["accounts"] == 100 1147 | assert repository._server_knowledge["payees"] == 200 1148 | 1149 | # Verify data is separate 1150 | accounts = repository.get_accounts() 1151 | payees = repository.get_payees() 1152 | assert len(accounts) == 1 1153 | assert len(payees) == 1 1154 | 1155 | 1156 | def test_repository_background_sync_thread_safety(repository: YNABRepository) -> None: 1157 | """Test that background sync threading doesn't cause issues.""" 1158 | 1159 | # Enable background sync for this test 1160 | repository._background_sync_enabled = True 1161 | 1162 | # Set up initial stale data 1163 | account1 = create_ynab_account(id="acc-1", name="Checking") 1164 | repository._data["accounts"] = [account1] 1165 | repository._last_sync = datetime.now() - timedelta(minutes=10) # Stale 1166 | 1167 | # Track thread creations 1168 | original_thread = threading.Thread 1169 | created_threads = [] 1170 | 1171 | def track_thread_creation(*args: Any, **kwargs: Any) -> threading.Thread: 1172 | thread = original_thread(*args, **kwargs) 1173 | created_threads.append(thread) 1174 | return thread 1175 | 1176 | # Mock the actual sync to prevent real API calls 1177 | with patch.object(repository, "sync_accounts"): 1178 | # Mock needs_sync to return True for stale data 1179 | with patch.object(repository, "needs_sync", return_value=True): 1180 | with patch("threading.Thread", side_effect=track_thread_creation): 1181 | # Multiple rapid calls should trigger background sync threads 1182 | repository.get_accounts() 1183 | repository.get_accounts() 1184 | repository.get_accounts() 1185 | 1186 | # Give threads a moment to be created 1187 | time.sleep(0.1) 1188 | 1189 | # Should have created threads for background sync (up to 3, one per call) 1190 | assert len(created_threads) <= 3 # At most one per call 1191 | 1192 | 1193 | def test_repository_preserves_data_during_failed_sync( 1194 | repository: YNABRepository, 1195 | ) -> None: 1196 | """Test that existing data is preserved when sync fails.""" 1197 | # Set up initial good data 1198 | account1 = create_ynab_account(id="acc-1", name="Checking") 1199 | repository._data["accounts"] = [account1] 1200 | repository._server_knowledge["accounts"] = 100 1201 | repository._last_sync = datetime.now() 1202 | 1203 | # Mock sync to fail 1204 | with patch("ynab.ApiClient") as mock_client_class: 1205 | mock_client = MagicMock() 1206 | mock_client_class.return_value.__enter__.return_value = mock_client 1207 | 1208 | mock_accounts_api = MagicMock() 1209 | mock_accounts_api.get_accounts.side_effect = ynab.ApiException( 1210 | status=500, reason="Server Error" 1211 | ) 1212 | 1213 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1214 | with pytest.raises(ynab.ApiException): 1215 | repository.sync_accounts() 1216 | 1217 | # Original data should still be there 1218 | accounts = repository.get_accounts() 1219 | assert len(accounts) == 1 1220 | assert accounts[0].id == "acc-1" 1221 | assert repository._server_knowledge["accounts"] == 100 1222 | 1223 | 1224 | def test_repository_handles_malformed_api_responses(repository: YNABRepository) -> None: 1225 | """Test repository handles malformed or unexpected API response structures.""" 1226 | # Mock a response that might have unexpected structure 1227 | malformed_response = MagicMock() 1228 | malformed_response.data.accounts = None # Unexpected None 1229 | malformed_response.data.server_knowledge = 100 1230 | 1231 | with patch("ynab.ApiClient") as mock_client_class: 1232 | mock_client = MagicMock() 1233 | mock_client_class.return_value.__enter__.return_value = mock_client 1234 | 1235 | mock_accounts_api = MagicMock() 1236 | mock_accounts_api.get_accounts.return_value = malformed_response 1237 | 1238 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1239 | # Should handle malformed response gracefully 1240 | with pytest.raises((AttributeError, TypeError)): 1241 | repository.sync_accounts() 1242 | 1243 | 1244 | def test_repository_sync_entity_atomic_updates(repository: YNABRepository) -> None: 1245 | """Test that _sync_entity updates are atomic.""" 1246 | # Set up initial data 1247 | account1 = create_ynab_account(id="acc-1", name="Checking") 1248 | repository._data["accounts"] = [account1] 1249 | repository._server_knowledge["accounts"] = 100 1250 | repository._last_sync = datetime.now() 1251 | 1252 | # Mock successful API call but failing delta application 1253 | account2 = create_ynab_account(id="acc-2", name="Savings") 1254 | success_response = ynab.AccountsResponse( 1255 | data=ynab.AccountsResponseData( 1256 | accounts=[account1, account2], server_knowledge=110 1257 | ) 1258 | ) 1259 | 1260 | with patch("ynab.ApiClient") as mock_client_class: 1261 | mock_client = MagicMock() 1262 | mock_client_class.return_value.__enter__.return_value = mock_client 1263 | 1264 | mock_accounts_api = MagicMock() 1265 | mock_accounts_api.get_accounts.return_value = success_response 1266 | 1267 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1268 | # Mock _apply_deltas to fail 1269 | with patch.object( 1270 | repository, "_apply_deltas", side_effect=Exception("Delta failed") 1271 | ): 1272 | # Sync should fail 1273 | with pytest.raises(Exception, match="Delta failed"): 1274 | repository.sync_accounts() 1275 | 1276 | # Original data should be unchanged due to atomic failure 1277 | accounts = repository.get_accounts() 1278 | assert len(accounts) == 1 1279 | assert accounts[0].id == "acc-1" 1280 | assert repository._server_knowledge["accounts"] == 100 # Should not be updated 1281 | 1282 | 1283 | def test_repository_handles_very_large_server_knowledge_values( 1284 | repository: YNABRepository, 1285 | ) -> None: 1286 | """Test repository handles very large server knowledge values correctly.""" 1287 | # Test with a very large server knowledge value 1288 | large_knowledge = 999_999_999_999 1289 | 1290 | account1 = create_ynab_account(id="acc-1", name="Checking") 1291 | response = ynab.AccountsResponse( 1292 | data=ynab.AccountsResponseData( 1293 | accounts=[account1], server_knowledge=large_knowledge 1294 | ) 1295 | ) 1296 | 1297 | with patch("ynab.ApiClient") as mock_client_class: 1298 | mock_client = MagicMock() 1299 | mock_client_class.return_value.__enter__.return_value = mock_client 1300 | 1301 | mock_accounts_api = MagicMock() 1302 | mock_accounts_api.get_accounts.return_value = response 1303 | 1304 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1305 | repository.sync_accounts() 1306 | 1307 | # Should handle large values correctly 1308 | assert repository._server_knowledge["accounts"] == large_knowledge 1309 | 1310 | # Should be able to use it in subsequent delta calls 1311 | with patch("ynab.ApiClient") as mock_client_class: 1312 | mock_client = MagicMock() 1313 | mock_client_class.return_value.__enter__.return_value = mock_client 1314 | 1315 | mock_accounts_api = MagicMock() 1316 | mock_accounts_api.get_accounts.return_value = response 1317 | 1318 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1319 | repository.sync_accounts() 1320 | 1321 | # Verify large knowledge was passed correctly 1322 | mock_accounts_api.get_accounts.assert_called_with( 1323 | "test-budget", last_knowledge_of_server=large_knowledge 1324 | ) 1325 | 1326 | 1327 | def test_repository_background_sync_respects_staleness_threshold( 1328 | repository: YNABRepository, 1329 | ) -> None: 1330 | """Test that background sync only triggers when data is actually stale.""" 1331 | # Set up fresh data (not stale) 1332 | account1 = create_ynab_account(id="acc-1", name="Checking") 1333 | repository._data["accounts"] = [account1] 1334 | repository._last_sync = datetime.now() - timedelta(minutes=2) # Fresh (< 5 minutes) 1335 | 1336 | with patch.object(repository, "_trigger_background_sync") as mock_bg_sync: 1337 | # Getting accounts should NOT trigger background sync 1338 | accounts = repository.get_accounts() 1339 | 1340 | # Verify we got the data 1341 | assert len(accounts) == 1 1342 | assert accounts[0].id == "acc-1" 1343 | 1344 | # Verify background sync was NOT triggered 1345 | mock_bg_sync.assert_not_called() 1346 | 1347 | 1348 | def test_repository_error_logging_behavior(repository: YNABRepository) -> None: 1349 | """Test that errors are properly logged with appropriate levels.""" 1350 | 1351 | # Capture log messages 1352 | log_messages = [] 1353 | 1354 | class TestLogHandler(logging.Handler): 1355 | def emit(self, record: Any) -> None: 1356 | log_messages.append((record.levelname, record.getMessage())) 1357 | 1358 | # Add test handler to repository logger 1359 | test_handler = TestLogHandler() 1360 | repository_logger = logging.getLogger("repository") 1361 | repository_logger.addHandler(test_handler) 1362 | repository_logger.setLevel(logging.DEBUG) 1363 | 1364 | try: 1365 | with patch("ynab.ApiClient") as mock_client_class: 1366 | mock_client = MagicMock() 1367 | mock_client_class.return_value.__enter__.return_value = mock_client 1368 | 1369 | mock_accounts_api = MagicMock() 1370 | 1371 | # Set up initial server knowledge to trigger delta sync path 1372 | repository._server_knowledge["accounts"] = 50 1373 | 1374 | # Test different error scenarios 1375 | # 1. ConflictException should log as INFO (expected) 1376 | mock_accounts_api.get_accounts.side_effect = [ 1377 | ConflictException(status=409, reason="Conflict"), 1378 | ynab.AccountsResponse( 1379 | data=ynab.AccountsResponseData(accounts=[], server_knowledge=100) 1380 | ), 1381 | ] 1382 | 1383 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1384 | repository.sync_accounts() 1385 | 1386 | # 2. Generic ApiException should log as WARNING 1387 | # Reset server knowledge for next test 1388 | repository._server_knowledge["accounts"] = 60 1389 | mock_accounts_api.get_accounts.side_effect = [ 1390 | ynab.ApiException(status=500, reason="Server Error"), 1391 | ynab.AccountsResponse( 1392 | data=ynab.AccountsResponseData(accounts=[], server_knowledge=100) 1393 | ), 1394 | ] 1395 | 1396 | with patch("ynab.AccountsApi", return_value=mock_accounts_api): 1397 | repository.sync_accounts() 1398 | 1399 | # Verify appropriate log levels were used 1400 | info_logs = [msg for level, msg in log_messages if level == "INFO"] 1401 | warning_logs = [msg for level, msg in log_messages if level == "WARNING"] 1402 | 1403 | assert any("conflict" in msg.lower() for msg in info_logs) 1404 | assert any("api error" in msg.lower() for msg in warning_logs) 1405 | 1406 | finally: 1407 | # Clean up 1408 | repository_logger.removeHandler(test_handler) 1409 | ```