From 50e5492ea1d05609d92acfae99edd61df4b0208d Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 10 Jan 2026 14:10:21 -0300 Subject: [PATCH] feat(automatic-exchange-rate): track unsuccessful runs --- app/apps/currencies/exchange_rates/fetcher.py | 4 +- .../migrations/0023_add_failure_count.py | 18 +++ app/apps/currencies/models.py | 6 +- app/apps/currencies/tests/__init__.py | 1 + .../tests/test_automatic_exchange_rates.py | 109 ++++++++++++++++++ .../fragments/list.html | 10 +- 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 app/apps/currencies/migrations/0023_add_failure_count.py create mode 100644 app/apps/currencies/tests/__init__.py create mode 100644 app/apps/currencies/tests/test_automatic_exchange_rates.py diff --git a/app/apps/currencies/exchange_rates/fetcher.py b/app/apps/currencies/exchange_rates/fetcher.py index 11d43d4..41f9293 100644 --- a/app/apps/currencies/exchange_rates/fetcher.py +++ b/app/apps/currencies/exchange_rates/fetcher.py @@ -1,5 +1,4 @@ import logging -from datetime import timedelta from django.db.models import QuerySet from django.utils import timezone @@ -258,7 +257,10 @@ class ExchangeRateFetcher: processed_pairs.add((from_currency.id, to_currency.id)) service.last_fetch = timezone.now() + service.failure_count = 0 service.save() except Exception as e: logger.error(f"Error fetching rates for {service.name}: {e}") + service.failure_count += 1 + service.save() diff --git a/app/apps/currencies/migrations/0023_add_failure_count.py b/app/apps/currencies/migrations/0023_add_failure_count.py new file mode 100644 index 0000000..cc36129 --- /dev/null +++ b/app/apps/currencies/migrations/0023_add_failure_count.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-01-10 06:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('currencies', '0022_currency_is_archived'), + ] + + operations = [ + migrations.AddField( + model_name='exchangerateservice', + name='failure_count', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index 95ce0e1..f63dcff 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -136,6 +136,8 @@ class ExchangeRateService(models.Model): null=True, blank=True, verbose_name=_("Last Successful Fetch") ) + failure_count = models.PositiveIntegerField(default=0) + target_currencies = models.ManyToManyField( Currency, verbose_name=_("Target Currencies"), @@ -237,7 +239,7 @@ class ExchangeRateService(models.Model): hours = self._parse_hour_ranges(self.fetch_interval) # Store in normalized format (optional) self.fetch_interval = ",".join(str(h) for h in sorted(hours)) - except ValueError as e: + except ValueError: raise ValidationError( { "fetch_interval": _( @@ -248,7 +250,7 @@ class ExchangeRateService(models.Model): ) except ValidationError: raise - except Exception as e: + except Exception: raise ValidationError( { "fetch_interval": _( diff --git a/app/apps/currencies/tests/__init__.py b/app/apps/currencies/tests/__init__.py new file mode 100644 index 0000000..b98d908 --- /dev/null +++ b/app/apps/currencies/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for currencies app diff --git a/app/apps/currencies/tests/test_automatic_exchange_rates.py b/app/apps/currencies/tests/test_automatic_exchange_rates.py new file mode 100644 index 0000000..c3e04d9 --- /dev/null +++ b/app/apps/currencies/tests/test_automatic_exchange_rates.py @@ -0,0 +1,109 @@ +from decimal import Decimal +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.utils import timezone + +from apps.currencies.models import Currency, ExchangeRateService +from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher + + +class ExchangeRateServiceFailureTrackingTests(TestCase): + """Tests for the failure count tracking functionality.""" + + def setUp(self): + """Set up test data.""" + self.usd = Currency.objects.create( + code="USD", name="US Dollar", decimal_places=2, prefix="$ " + ) + self.eur = Currency.objects.create( + code="EUR", name="Euro", decimal_places=2, prefix="€ " + ) + self.eur.exchange_currency = self.usd + self.eur.save() + + self.service = ExchangeRateService.objects.create( + name="Test Service", + service_type=ExchangeRateService.ServiceType.FRANKFURTER, + is_active=True, + ) + self.service.target_currencies.add(self.eur) + + def test_failure_count_increments_on_provider_error(self): + """Test that failure_count increments when provider raises an exception.""" + self.assertEqual(self.service.failure_count, 0) + + with patch.object( + self.service, "get_provider", side_effect=Exception("API Error") + ): + ExchangeRateFetcher._fetch_service_rates(self.service) + + self.service.refresh_from_db() + self.assertEqual(self.service.failure_count, 1) + + def test_failure_count_resets_on_success(self): + """Test that failure_count resets to 0 on successful fetch.""" + # Set initial failure count + self.service.failure_count = 5 + self.service.save() + + # Mock a successful provider + mock_provider = MagicMock() + mock_provider.requires_api_key.return_value = False + mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))] + mock_provider.rates_inverted = False + + with patch.object(self.service, "get_provider", return_value=mock_provider): + ExchangeRateFetcher._fetch_service_rates(self.service) + + self.service.refresh_from_db() + self.assertEqual(self.service.failure_count, 0) + + def test_failure_count_accumulates_across_fetches(self): + """Test that failure_count accumulates with consecutive failures.""" + self.assertEqual(self.service.failure_count, 0) + + with patch.object( + self.service, "get_provider", side_effect=Exception("API Error") + ): + ExchangeRateFetcher._fetch_service_rates(self.service) + self.service.refresh_from_db() + self.assertEqual(self.service.failure_count, 1) + + ExchangeRateFetcher._fetch_service_rates(self.service) + self.service.refresh_from_db() + self.assertEqual(self.service.failure_count, 2) + + ExchangeRateFetcher._fetch_service_rates(self.service) + self.service.refresh_from_db() + self.assertEqual(self.service.failure_count, 3) + + def test_last_fetch_not_updated_on_failure(self): + """Test that last_fetch is NOT updated when a failure occurs.""" + original_last_fetch = self.service.last_fetch + self.assertIsNone(original_last_fetch) + + with patch.object( + self.service, "get_provider", side_effect=Exception("API Error") + ): + ExchangeRateFetcher._fetch_service_rates(self.service) + + self.service.refresh_from_db() + self.assertIsNone(self.service.last_fetch) + self.assertEqual(self.service.failure_count, 1) + + def test_last_fetch_updated_on_success(self): + """Test that last_fetch IS updated when fetch succeeds.""" + self.assertIsNone(self.service.last_fetch) + + mock_provider = MagicMock() + mock_provider.requires_api_key.return_value = False + mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))] + mock_provider.rates_inverted = False + + with patch.object(self.service, "get_provider", return_value=mock_provider): + ExchangeRateFetcher._fetch_service_rates(self.service) + + self.service.refresh_from_db() + self.assertIsNotNone(self.service.last_fetch) + self.assertEqual(self.service.failure_count, 0) diff --git a/app/templates/exchange_rates_services/fragments/list.html b/app/templates/exchange_rates_services/fragments/list.html index 2b74511..729919e 100644 --- a/app/templates/exchange_rates_services/fragments/list.html +++ b/app/templates/exchange_rates_services/fragments/list.html @@ -56,7 +56,15 @@ {% if service.is_active %}{% else %} {% endif %} - {{ service.name }} + + {{ service.name }} + {% if service.failure_count > 0 %} + + + {{ service.failure_count }} + + {% endif %} + {{ service.get_service_type_display }} {{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %} {{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}