diff --git a/tests/core/test_repository.py b/tests/core/test_repository.py index 84f81f7..83bfe73 100644 --- a/tests/core/test_repository.py +++ b/tests/core/test_repository.py @@ -1454,3 +1454,221 @@ def test_get_transaction_count_no_data(in_memory_repo): repo: TransactionRepository = in_memory_repo count = repo.get_transaction_count(date(2023, 1, 1), date(2023, 2, 1)) assert count == 0 + + +def test_get_all_transactions_by_category(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-10"), + amount=-30.0, + category="Food", + description="Restaurant", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-100.0, + category="Shopping", + description="Clothes", + ) + ) + + food_transactions = repo.get_all_transactions_by_category("Food") + assert len(food_transactions) == 2 + assert all(t.category == "Food" for t in food_transactions) + # Ordered by date DESC + assert food_transactions[0].date > food_transactions[1].date + + shopping_transactions = repo.get_all_transactions_by_category("Shopping") + assert len(shopping_transactions) == 1 + assert shopping_transactions[0].description == "Clothes" + + +def test_get_all_transactions_by_category_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + result = repo.get_all_transactions_by_category("NonExistent") + assert result == [] + + +def test_get_monthly_cashflow_trend_with_data(in_memory_repo): + repo: TransactionRepository = in_memory_repo + # Add transactions across 3 months + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-15"), + amount=-500.0, + category="Food", + description="Groceries", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-05"), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-02-15"), + amount=-800.0, + category="Shopping", + description="Clothes", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-03-05"), + amount=2000.0, + category="Income", + description="Salary", + ) + ) + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-03-15"), + amount=-300.0, + category="Food", + description="Lunch", + ) + ) + + trend = repo.get_monthly_cashflow_trend(3) + + # Ordered ascending by year/month + assert len(trend) == 3 + assert trend[0] == (2023, 1, 1500.0) # 2000 - 500 + assert trend[1] == (2023, 2, 1200.0) # 2000 - 800 + assert trend[2] == (2023, 3, 1700.0) # 2000 - 300 + + +def test_get_monthly_cashflow_trend_limits_results(in_memory_repo): + repo: TransactionRepository = in_memory_repo + for month in range(1, 7): + repo.add_transaction( + Transaction( + id=None, + date=date(2023, month, 10), + amount=-100.0, + category="Food", + description="Groceries", + ) + ) + + # Request only 3 most recent months + trend = repo.get_monthly_cashflow_trend(3) + + assert len(trend) == 3 + # Should return the 3 most recent months ascending + assert trend[0][1] == 4 + assert trend[1][1] == 5 + assert trend[2][1] == 6 + + +def test_get_monthly_cashflow_trend_empty(in_memory_repo): + repo: TransactionRepository = in_memory_repo + trend = repo.get_monthly_cashflow_trend(6) + assert trend == [] + + +def test_get_all_transactions_pagination_offset_beyond_count(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + # Offset beyond available data + result = repo.get_all_transactions(limit=100, offset=999) + assert result == [] + + +def test_get_all_transactions_limit_zero(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Food", + description="Groceries", + ) + ) + + result = repo.get_all_transactions(limit=0, offset=0) + assert result == [] + + +def test_search_by_keyword_offset_beyond_count(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Shopping", + description="Amazon Order", + ) + ) + + result = repo.search_by_keyword("Amazon", limit=100, offset=999) + assert result == [] + + +def test_search_by_keyword_limit_zero(in_memory_repo): + repo: TransactionRepository = in_memory_repo + repo.add_transaction( + Transaction( + id=None, + date=date.fromisoformat("2023-01-05"), + amount=-50.0, + category="Shopping", + description="Amazon Order", + ) + ) + + result = repo.search_by_keyword("Amazon", limit=0, offset=0) + assert result == [] diff --git a/tests/services/test_merchant.py b/tests/services/test_merchant.py new file mode 100644 index 0000000..4a9cd3e --- /dev/null +++ b/tests/services/test_merchant.py @@ -0,0 +1,216 @@ +from datetime import date + +import pytest + +from expense_tracker.core.models import MerchantCategory, Transaction +from expense_tracker.core.merchant_repository import MerchantCategoryRepository +from expense_tracker.core.transaction_repository import TransactionRepository +from expense_tracker.services.merchant import MerchantCategoryService +from expense_tracker.utils.merchant_normalizer import normalize_merchant + + +@pytest.fixture +def merchant_repo(): + repo = MerchantCategoryRepository(":memory:") + yield repo + repo.conn.close() + + +@pytest.fixture +def transaction_repo(): + repo = TransactionRepository(":memory:") + yield repo + repo.conn.close() + + +@pytest.fixture +def service(merchant_repo, transaction_repo): + return MerchantCategoryService( + merchant_repo=merchant_repo, + transaction_repo=transaction_repo, + normalizer=normalize_merchant, + ) + + +# --- update_category --- + + +def test_update_category_creates_mapping(service, merchant_repo): + """update_category should store a normalized merchant-category mapping.""" + service.update_category("Starbucks Coffee #123", "Coffee") + + mapping = merchant_repo.get_category("STARBUCKS COFFEE") + assert mapping is not None + assert mapping.category == "Coffee" + + +def test_update_category_overwrites_existing(service, merchant_repo): + """Calling update_category again for the same merchant should overwrite.""" + service.update_category("Starbucks Coffee", "Coffee") + service.update_category("Starbucks Coffee", "Dining") + + mapping = merchant_repo.get_category("STARBUCKS COFFEE") + assert mapping is not None + assert mapping.category == "Dining" + + +# --- categorize_merchant --- + + +def test_categorize_merchant_income_amount(service): + """Positive amounts should always return 'Income'.""" + category = service.categorize_merchant("Some Merchant", 500.0) + assert category == "Income" + + +def test_categorize_merchant_exact_match(service, merchant_repo): + """Should return the category from an exact merchant key match.""" + merchant_repo.set_category(MerchantCategory("WHOLE FOODS", "Groceries")) + + category = service.categorize_merchant("Whole Foods", -50.0) + assert category == "Groceries" + + +def test_categorize_merchant_fuzzy_match(service, merchant_repo): + """Should fall back to fuzzy matching when no exact match exists.""" + merchant_repo.set_category(MerchantCategory("STARBUCKS COFFEE", "Coffee")) + + # Slightly different description that normalizes differently but fuzzy-matches + category = service.categorize_merchant("STARBUCKS COFFE", -5.0) + assert category == "Coffee" + + +def test_categorize_merchant_uncategorized_fallback(service): + """Should return 'Uncategorized' when no exact or fuzzy match exists.""" + category = service.categorize_merchant("Random Unknown Store", -25.0) + assert category == "Uncategorized" + + +def test_categorize_merchant_no_fuzzy_match_below_threshold(service, merchant_repo): + """Should return 'Uncategorized' when fuzzy score is below the threshold.""" + merchant_repo.set_category(MerchantCategory("STARBUCKS COFFEE", "Coffee")) + + # Completely different name should not fuzzy match + category = service.categorize_merchant("WALMART GROCERY", -30.0) + assert category == "Uncategorized" + + +# --- fuzzy_lookup_merchant --- + + +def test_fuzzy_lookup_finds_close_match(service, merchant_repo): + """Should find a merchant with a close fuzzy match.""" + merchant_repo.set_category(MerchantCategory("TRADER JOES", "Groceries")) + + result = service.fuzzy_lookup_merchant("TRADER JOE'S") + assert result == "TRADER JOES" + + +def test_fuzzy_lookup_returns_none_below_threshold(service, merchant_repo): + """Should return None when the best match is below the threshold.""" + merchant_repo.set_category(MerchantCategory("STARBUCKS COFFEE", "Coffee")) + + result = service.fuzzy_lookup_merchant("COMPLETELY DIFFERENT") + assert result is None + + +def test_fuzzy_lookup_empty_merchants(service): + """Should return None when no merchants exist in the repository.""" + result = service.fuzzy_lookup_merchant("STARBUCKS") + assert result is None + + +# --- update_uncategorized_transactions --- + + +def test_update_uncategorized_transactions_recategorizes( + service, merchant_repo, transaction_repo +): + """Should recategorize uncategorized transactions when a mapping exists.""" + # Add uncategorized transactions + transaction_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Uncategorized", + description="Whole Foods Market", + ) + ) + transaction_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 10), + amount=-30.0, + category="Uncategorized", + description="Target Store", + ) + ) + # Add a categorized transaction that should not be touched + transaction_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 15), + amount=-100.0, + category="Shopping", + description="Amazon", + ) + ) + + # Add a merchant mapping for Whole Foods + merchant_repo.set_category(MerchantCategory("WHOLE FOODS MARKET", "Groceries")) + + service.update_uncategorized_transactions() + + # Whole Foods should now be categorized + uncategorized = transaction_repo.get_all_transactions_by_category("Uncategorized") + assert len(uncategorized) == 1 + assert uncategorized[0].description == "Target Store" + + groceries = transaction_repo.get_all_transactions_by_category("Groceries") + assert len(groceries) == 1 + assert groceries[0].description == "Whole Foods Market" + + # Already-categorized transaction should remain unchanged + shopping = transaction_repo.get_all_transactions_by_category("Shopping") + assert len(shopping) == 1 + + +def test_update_uncategorized_transactions_no_matches( + service, merchant_repo, transaction_repo +): + """Should leave transactions uncategorized when no mappings match.""" + transaction_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Uncategorized", + description="Random Unknown Store", + ) + ) + + service.update_uncategorized_transactions() + + uncategorized = transaction_repo.get_all_transactions_by_category("Uncategorized") + assert len(uncategorized) == 1 + + +def test_update_uncategorized_transactions_empty(service, transaction_repo): + """Should handle case with no uncategorized transactions gracefully.""" + transaction_repo.add_transaction( + Transaction( + id=None, + date=date(2023, 1, 5), + amount=-50.0, + category="Groceries", + description="Whole Foods", + ) + ) + + # Should not raise + service.update_uncategorized_transactions() + + # Nothing should change + groceries = transaction_repo.get_all_transactions_by_category("Groceries") + assert len(groceries) == 1 diff --git a/tests/utils/test_merchant.py b/tests/utils/test_merchant.py index 4adf8df..c48fae4 100644 --- a/tests/utils/test_merchant.py +++ b/tests/utils/test_merchant.py @@ -48,3 +48,24 @@ def get_category(merchant: str) -> MerchantCategory | None: repo.get_category.side_effect = get_category repo.get_all_merchants.return_value = list(merchants.values()) return repo + + +# Edge case tests for normalize_merchant +@pytest.mark.parametrize( + "input_str, expected_str", + [ + ("", ""), + (" ", ""), + ("###***", ""), + ("12345", ""), + ("café résumé", "CAFÉ RÉSUMÉ"), + ("über eats delivery", "ÜBER EATS DELIVERY"), + ( + "A" * 200, + "A" * 200, + ), + ], +) +def test_normalize_merchant_edge_cases(input_str, expected_str): + """Test normalize_merchant with edge-case inputs.""" + assert normalize_merchant(input_str) == expected_str