255 lines
8.7 KiB
Python
255 lines
8.7 KiB
Python
"""Tests for YNAB service data formatting and filtering logic."""
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _mock_category(
|
|
name, budgeted, activity, balance, deleted=False, hidden=False, goal_type=None
|
|
):
|
|
cat = MagicMock()
|
|
cat.name = name
|
|
cat.budgeted = budgeted
|
|
cat.activity = activity
|
|
cat.balance = balance
|
|
cat.deleted = deleted
|
|
cat.hidden = hidden
|
|
cat.goal_type = goal_type
|
|
return cat
|
|
|
|
|
|
def _mock_transaction(
|
|
var_date, payee_name, category_name, amount, memo="", deleted=False, approved=True
|
|
):
|
|
txn = MagicMock()
|
|
txn.var_date = var_date
|
|
txn.payee_name = payee_name
|
|
txn.category_name = category_name
|
|
txn.amount = amount
|
|
txn.memo = memo
|
|
txn.deleted = deleted
|
|
txn.approved = approved
|
|
return txn
|
|
|
|
|
|
@pytest.fixture
|
|
def ynab_service():
|
|
"""Create a YNABService with mocked API client."""
|
|
with patch.dict(
|
|
os.environ, {"YNAB_ACCESS_TOKEN": "fake-token", "YNAB_BUDGET_ID": "test-budget"}
|
|
):
|
|
with patch("utils.ynab_service.ynab") as mock_ynab:
|
|
# Mock the configuration and API client chain
|
|
mock_ynab.Configuration.return_value = MagicMock()
|
|
mock_ynab.ApiClient.return_value = MagicMock()
|
|
mock_ynab.PlansApi.return_value = MagicMock()
|
|
mock_ynab.TransactionsApi.return_value = MagicMock()
|
|
mock_ynab.MonthsApi.return_value = MagicMock()
|
|
mock_ynab.CategoriesApi.return_value = MagicMock()
|
|
|
|
from utils.ynab_service import YNABService
|
|
|
|
service = YNABService()
|
|
yield service
|
|
|
|
|
|
class TestGetBudgetSummary:
|
|
def test_calculates_totals(self, ynab_service):
|
|
categories = [
|
|
_mock_category("Groceries", 500_000, -350_000, 150_000),
|
|
_mock_category("Rent", 1_500_000, -1_500_000, 0),
|
|
]
|
|
|
|
mock_month = MagicMock()
|
|
mock_month.to_be_budgeted = 200_000
|
|
|
|
mock_budget = MagicMock()
|
|
mock_budget.name = "My Budget"
|
|
mock_budget.months = [mock_month]
|
|
mock_budget.categories = categories
|
|
mock_budget.currency_format = MagicMock(iso_code="USD")
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.budget = mock_budget
|
|
ynab_service.plans_api.get_plan_by_id.return_value = mock_response
|
|
|
|
result = ynab_service.get_budget_summary()
|
|
|
|
assert result["budget_name"] == "My Budget"
|
|
assert result["to_be_budgeted"] == 200.0
|
|
assert result["total_budgeted"] == 2000.0 # (500k + 1500k) / 1000
|
|
assert result["total_activity"] == -1850.0
|
|
assert result["currency_format"] == "USD"
|
|
|
|
def test_skips_deleted_and_hidden(self, ynab_service):
|
|
categories = [
|
|
_mock_category("Active", 100_000, -50_000, 50_000),
|
|
_mock_category("Deleted", 999_000, -999_000, 0, deleted=True),
|
|
_mock_category("Hidden", 999_000, -999_000, 0, hidden=True),
|
|
]
|
|
|
|
mock_month = MagicMock()
|
|
mock_month.to_be_budgeted = 0
|
|
mock_budget = MagicMock()
|
|
mock_budget.name = "Budget"
|
|
mock_budget.months = [mock_month]
|
|
mock_budget.categories = categories
|
|
mock_budget.currency_format = None
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.budget = mock_budget
|
|
ynab_service.plans_api.get_plan_by_id.return_value = mock_response
|
|
|
|
result = ynab_service.get_budget_summary()
|
|
assert result["total_budgeted"] == 100.0
|
|
assert result["currency_format"] == "USD" # Default fallback
|
|
|
|
|
|
class TestGetTransactions:
|
|
def test_filters_by_date_range(self, ynab_service):
|
|
transactions = [
|
|
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
|
|
_mock_transaction("2026-01-15", "Gas", "Transport", -40_000),
|
|
_mock_transaction(
|
|
"2026-02-01", "Store", "Groceries", -30_000
|
|
), # Out of range
|
|
]
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.transactions = transactions
|
|
ynab_service.transactions_api.get_transactions.return_value = mock_response
|
|
|
|
result = ynab_service.get_transactions(
|
|
start_date="2026-01-01", end_date="2026-01-31"
|
|
)
|
|
|
|
assert result["count"] == 2
|
|
assert result["total_amount"] == -65.0
|
|
|
|
def test_filters_by_category(self, ynab_service):
|
|
transactions = [
|
|
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
|
|
_mock_transaction("2026-01-06", "Gas", "Transport", -40_000),
|
|
]
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.transactions = transactions
|
|
ynab_service.transactions_api.get_transactions.return_value = mock_response
|
|
|
|
result = ynab_service.get_transactions(
|
|
start_date="2026-01-01",
|
|
end_date="2026-01-31",
|
|
category_name="groceries", # Case insensitive
|
|
)
|
|
|
|
assert result["count"] == 1
|
|
assert result["transactions"][0]["category"] == "Groceries"
|
|
|
|
def test_filters_by_payee(self, ynab_service):
|
|
transactions = [
|
|
_mock_transaction("2026-01-05", "Whole Foods", "Groceries", -25_000),
|
|
_mock_transaction("2026-01-06", "Shell Gas", "Transport", -40_000),
|
|
]
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.transactions = transactions
|
|
ynab_service.transactions_api.get_transactions.return_value = mock_response
|
|
|
|
result = ynab_service.get_transactions(
|
|
start_date="2026-01-01",
|
|
end_date="2026-01-31",
|
|
payee_name="whole",
|
|
)
|
|
|
|
assert result["count"] == 1
|
|
assert result["transactions"][0]["payee"] == "Whole Foods"
|
|
|
|
def test_skips_deleted(self, ynab_service):
|
|
transactions = [
|
|
_mock_transaction("2026-01-05", "Store", "Groceries", -25_000),
|
|
_mock_transaction("2026-01-06", "Deleted", "Other", -10_000, deleted=True),
|
|
]
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.transactions = transactions
|
|
ynab_service.transactions_api.get_transactions.return_value = mock_response
|
|
|
|
result = ynab_service.get_transactions(
|
|
start_date="2026-01-01", end_date="2026-01-31"
|
|
)
|
|
|
|
assert result["count"] == 1
|
|
|
|
def test_converts_milliunits(self, ynab_service):
|
|
transactions = [
|
|
_mock_transaction("2026-01-05", "Store", "Groceries", -12_340),
|
|
]
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.transactions = transactions
|
|
ynab_service.transactions_api.get_transactions.return_value = mock_response
|
|
|
|
result = ynab_service.get_transactions(
|
|
start_date="2026-01-01", end_date="2026-01-31"
|
|
)
|
|
|
|
assert result["transactions"][0]["amount"] == -12.34
|
|
|
|
def test_sorts_by_date_descending(self, ynab_service):
|
|
transactions = [
|
|
_mock_transaction("2026-01-01", "A", "Cat", -10_000),
|
|
_mock_transaction("2026-01-15", "B", "Cat", -20_000),
|
|
_mock_transaction("2026-01-10", "C", "Cat", -30_000),
|
|
]
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.transactions = transactions
|
|
ynab_service.transactions_api.get_transactions.return_value = mock_response
|
|
|
|
result = ynab_service.get_transactions(
|
|
start_date="2026-01-01", end_date="2026-01-31"
|
|
)
|
|
|
|
dates = [t["date"] for t in result["transactions"]]
|
|
assert dates == sorted(dates, reverse=True)
|
|
|
|
|
|
class TestGetCategorySpending:
|
|
def test_month_format_normalization(self, ynab_service):
|
|
"""Passing YYYY-MM should be normalized to YYYY-MM-01."""
|
|
categories = [_mock_category("Food", 100_000, -50_000, 50_000)]
|
|
|
|
mock_month = MagicMock()
|
|
mock_month.categories = categories
|
|
mock_month.to_be_budgeted = 0
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.month = mock_month
|
|
ynab_service.months_api.get_plan_month.return_value = mock_response
|
|
|
|
result = ynab_service.get_category_spending("2026-03")
|
|
|
|
assert result["month"] == "2026-03"
|
|
|
|
def test_identifies_overspent(self, ynab_service):
|
|
categories = [
|
|
_mock_category("Dining", 200_000, -300_000, -100_000), # Overspent
|
|
_mock_category("Groceries", 500_000, -400_000, 100_000), # Fine
|
|
]
|
|
|
|
mock_month = MagicMock()
|
|
mock_month.categories = categories
|
|
mock_month.to_be_budgeted = 0
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.data.month = mock_month
|
|
ynab_service.months_api.get_plan_month.return_value = mock_response
|
|
|
|
result = ynab_service.get_category_spending("2026-03")
|
|
|
|
assert len(result["overspent_categories"]) == 1
|
|
assert result["overspent_categories"][0]["name"] == "Dining"
|
|
assert result["overspent_categories"][0]["overspent_by"] == 100.0
|