feat: replace action row with a FAB

This commit is contained in:
Herculino Trotta
2025-06-15 23:12:22 -03:00
parent 02f6bb0c29
commit 1d3dc3f5a2
9 changed files with 1358 additions and 501 deletions
+171 -65
View File
@@ -9,22 +9,28 @@ from django.urls import reverse
from django.utils import timezone
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
from apps.accounts.models import Account # For ExchangeRateService target_accounts
from apps.accounts.models import Account # For ExchangeRateService target_accounts
User = get_user_model()
class BaseCurrencyAppTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(email="curtestuser@example.com", password="password")
self.user = User.objects.create_user(
email="curtestuser@example.com", password="password"
)
self.client = Client()
self.client.login(email="curtestuser@example.com", password="password")
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.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=""
)
class CurrencyModelTests(BaseCurrencyAppTest): # Changed from CurrencyTests
class CurrencyModelTests(BaseCurrencyAppTest): # Changed from CurrencyTests
def test_currency_creation(self):
"""Test basic currency creation"""
# self.usd is already created in BaseCurrencyAppTest
@@ -33,10 +39,11 @@ class CurrencyModelTests(BaseCurrencyAppTest): # Changed from CurrencyTests
self.assertEqual(self.usd.decimal_places, 2)
self.assertEqual(self.usd.prefix, "$")
# Test creation with suffix
jpy = Currency.objects.create(code="JPY", name="Japanese Yen", decimal_places=0, suffix="")
jpy = Currency.objects.create(
code="JPY", name="Japanese Yen", decimal_places=0, suffix=""
)
self.assertEqual(jpy.suffix, "")
def test_currency_decimal_places_validation(self):
"""Test decimal places validation for maximum value"""
currency = Currency(code="TESTMAX", name="Test Currency Max", decimal_places=31)
@@ -58,11 +65,14 @@ class CurrencyModelTests(BaseCurrencyAppTest): # Changed from CurrencyTests
self.usd.exchange_currency = self.usd
with self.assertRaises(ValidationError) as context:
self.usd.full_clean()
self.assertIn('exchange_currency', context.exception.message_dict)
self.assertIn("Currency cannot have itself as exchange currency.", context.exception.message_dict['exchange_currency'])
self.assertIn("exchange_currency", context.exception.message_dict)
self.assertIn(
"Currency cannot have itself as exchange currency.",
context.exception.message_dict["exchange_currency"],
)
class ExchangeRateModelTests(BaseCurrencyAppTest): # Changed from ExchangeRateTests
class ExchangeRateModelTests(BaseCurrencyAppTest): # Changed from ExchangeRateTests
def test_exchange_rate_creation(self):
"""Test basic exchange rate creation"""
rate = ExchangeRate.objects.create(
@@ -83,11 +93,11 @@ class ExchangeRateModelTests(BaseCurrencyAppTest): # Changed from ExchangeRateTe
rate=Decimal("0.85"),
date=date,
)
with self.assertRaises(IntegrityError): # Specifically expect IntegrityError
with self.assertRaises(IntegrityError): # Specifically expect IntegrityError
ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.86"), # Different rate, same pair and date
rate=Decimal("0.86"), # Different rate, same pair and date
date=date,
)
@@ -95,14 +105,17 @@ class ExchangeRateModelTests(BaseCurrencyAppTest): # Changed from ExchangeRateTe
"""Test that from_currency and to_currency cannot be the same."""
rate = ExchangeRate(
from_currency=self.usd,
to_currency=self.usd, # Same currency
to_currency=self.usd, # Same currency
rate=Decimal("1.00"),
date=timezone.now()
date=timezone.now(),
)
with self.assertRaises(ValidationError) as context:
rate.full_clean()
self.assertIn('to_currency', context.exception.message_dict)
self.assertIn("From and To currencies cannot be the same.", context.exception.message_dict['to_currency'])
self.assertIn("to_currency", context.exception.message_dict)
self.assertIn(
"From and To currencies cannot be the same.",
context.exception.message_dict["to_currency"],
)
class ExchangeRateServiceModelTests(BaseCurrencyAppTest):
@@ -111,7 +124,7 @@ class ExchangeRateServiceModelTests(BaseCurrencyAppTest):
name="Test Coingecko Free",
service_type=ExchangeRateService.ServiceType.COINGECKO_FREE,
interval_type=ExchangeRateService.IntervalType.EVERY,
fetch_interval="12" # Every 12 hours
fetch_interval="12", # Every 12 hours
)
self.assertEqual(str(service), "Test Coingecko Free")
self.assertTrue(service.is_active)
@@ -119,17 +132,22 @@ class ExchangeRateServiceModelTests(BaseCurrencyAppTest):
def test_fetch_interval_validation_every_x_hours(self):
# Valid
service = ExchangeRateService(
name="Valid Every", service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
interval_type=ExchangeRateService.IntervalType.EVERY, fetch_interval="6"
name="Valid Every",
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
interval_type=ExchangeRateService.IntervalType.EVERY,
fetch_interval="6",
)
service.full_clean() # Should not raise
service.full_clean() # Should not raise
# Invalid - not a digit
service.fetch_interval = "abc"
with self.assertRaises(ValidationError) as context:
service.full_clean()
self.assertIn("fetch_interval", context.exception.message_dict)
self.assertIn("'Every X hours' interval type requires a positive integer.", context.exception.message_dict['fetch_interval'][0])
self.assertIn(
"'Every X hours' interval type requires a positive integer.",
context.exception.message_dict["fetch_interval"][0],
)
# Invalid - out of range
service.fetch_interval = "0"
@@ -144,49 +162,66 @@ class ExchangeRateServiceModelTests(BaseCurrencyAppTest):
valid_intervals = ["1", "0,12", "1-5", "1-5,8,10-12", "0,1,2,3,22,23"]
for interval in valid_intervals:
service = ExchangeRateService(
name=f"Test On {interval}", service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
interval_type=ExchangeRateService.IntervalType.ON, fetch_interval=interval
name=f"Test On {interval}",
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
interval_type=ExchangeRateService.IntervalType.ON,
fetch_interval=interval,
)
service.full_clean() # Should not raise
service.full_clean() # Should not raise
# Check normalized form (optional, but good if model does it)
# self.assertEqual(service.fetch_interval, ",".join(str(h) for h in sorted(service._parse_hour_ranges(interval))))
invalid_intervals = [
"abc", "1-", "-5", "24", "-1", "1-24", "1,2,25", "5-1", # Invalid hour, range, or format
"1.5", "1, 2, 3," # decimal, trailing comma
"abc",
"1-",
"-5",
"24",
"-1",
"1-24",
"1,2,25",
"5-1", # Invalid hour, range, or format
"1.5",
"1, 2, 3,", # decimal, trailing comma
]
for interval in invalid_intervals:
service = ExchangeRateService(
name=f"Test On Invalid {interval}", service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
interval_type=ExchangeRateService.IntervalType.NOT_ON, fetch_interval=interval
name=f"Test On Invalid {interval}",
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
interval_type=ExchangeRateService.IntervalType.NOT_ON,
fetch_interval=interval,
)
with self.assertRaises(ValidationError) as context:
service.full_clean()
self.assertIn("fetch_interval", context.exception.message_dict)
self.assertTrue("Invalid hour format" in context.exception.message_dict['fetch_interval'][0] or \
"Hours must be between 0 and 23" in context.exception.message_dict['fetch_interval'][0] or \
"Invalid range" in context.exception.message_dict['fetch_interval'][0]
self.assertTrue(
"Invalid hour format"
in context.exception.message_dict["fetch_interval"][0]
or "Hours must be between 0 and 23"
in context.exception.message_dict["fetch_interval"][0]
or "Invalid range"
in context.exception.message_dict["fetch_interval"][0]
)
@patch('apps.currencies.exchange_rates.fetcher.PROVIDER_MAPPING')
@patch("apps.currencies.exchange_rates.fetcher.PROVIDER_MAPPING")
def test_get_provider(self, mock_provider_mapping):
# Mock a provider class
class MockProvider:
def __init__(self, api_key=None):
self.api_key = api_key
mock_provider_mapping.__getitem__.return_value = MockProvider
service = ExchangeRateService(
name="Test Get Provider",
service_type=ExchangeRateService.ServiceType.COINGECKO_FREE, # Any valid choice
api_key="testkey"
service_type=ExchangeRateService.ServiceType.COINGECKO_FREE, # Any valid choice
api_key="testkey",
)
provider_instance = service.get_provider()
self.assertIsInstance(provider_instance, MockProvider)
self.assertEqual(provider_instance.api_key, "testkey")
mock_provider_mapping.__getitem__.assert_called_with(ExchangeRateService.ServiceType.COINGECKO_FREE)
mock_provider_mapping.__getitem__.assert_called_with(
ExchangeRateService.ServiceType.COINGECKO_FREE
)
class CurrencyViewTests(BaseCurrencyAppTest):
@@ -197,21 +232,35 @@ class CurrencyViewTests(BaseCurrencyAppTest):
self.assertContains(response, self.eur.name)
def test_currency_add_view(self):
data = {"code": "GBP", "name": "British Pound", "decimal_places": 2, "prefix": "£"}
data = {
"code": "GBP",
"name": "British Pound",
"decimal_places": 2,
"prefix": "£",
}
response = self.client.post(reverse("currency_add"), data)
self.assertEqual(response.status_code, 204) # HTMX success
self.assertEqual(response.status_code, 204) # HTMX success
self.assertTrue(Currency.objects.filter(code="GBP").exists())
def test_currency_edit_view(self):
gbp = Currency.objects.create(code="GBP", name="Pound Sterling", decimal_places=2)
data = {"code": "GBP", "name": "British Pound Sterling", "decimal_places": 2, "prefix": "£"}
gbp = Currency.objects.create(
code="GBP", name="Pound Sterling", decimal_places=2
)
data = {
"code": "GBP",
"name": "British Pound Sterling",
"decimal_places": 2,
"prefix": "£",
}
response = self.client.post(reverse("currency_edit", args=[gbp.id]), data)
self.assertEqual(response.status_code, 204)
gbp.refresh_from_db()
self.assertEqual(gbp.name, "British Pound Sterling")
def test_currency_delete_view(self):
cad = Currency.objects.create(code="CAD", name="Canadian Dollar", decimal_places=2)
cad = Currency.objects.create(
code="CAD", name="Canadian Dollar", decimal_places=2
)
response = self.client.delete(reverse("currency_delete", args=[cad.id]))
self.assertEqual(response.status_code, 204)
self.assertFalse(Currency.objects.filter(code="CAD").exists())
@@ -220,38 +269,72 @@ class CurrencyViewTests(BaseCurrencyAppTest):
class ExchangeRateViewTests(BaseCurrencyAppTest):
def test_exchange_rate_list_view_main(self):
# This view lists pairs, not individual rates directly in the main list
ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.9"), date=timezone.now())
ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.9"),
date=timezone.now(),
)
response = self.client.get(reverse("exchange_rates_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.usd.name) # Check if pair components are mentioned
self.assertContains(
response, self.usd.name
) # Check if pair components are mentioned
self.assertContains(response, self.eur.name)
def test_exchange_rate_list_pair_view(self):
rate_date = timezone.now()
ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.9"), date=rate_date)
url = reverse("exchange_rates_list_pair") + f"?from={self.usd.name}&to={self.eur.name}"
ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.9"),
date=rate_date,
)
url = (
reverse("exchange_rates_list_pair")
+ f"?from={self.usd.name}&to={self.eur.name}"
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "0.9") # Check if the rate is displayed
self.assertContains(response, "0.9") # Check if the rate is displayed
def test_exchange_rate_add_view(self):
data = {
"from_currency": self.usd.id,
"to_currency": self.eur.id,
"rate": "0.88",
"date": timezone.now().strftime('%Y-%m-%d %H:%M:%S') # Match form field format
"date": timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
), # Match form field format
}
response = self.client.post(reverse("exchange_rate_add"), data)
self.assertEqual(response.status_code, 204, response.content.decode() if response.content and response.status_code != 204 else "No content on 204")
self.assertTrue(ExchangeRate.objects.filter(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.88")).exists())
self.assertEqual(
response.status_code,
204,
(
response.content.decode()
if response.content and response.status_code != 204
else "No content on 204"
),
)
self.assertTrue(
ExchangeRate.objects.filter(
from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.88")
).exists()
)
def test_exchange_rate_edit_view(self):
rate = ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.91"), date=timezone.now())
rate = ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.91"),
date=timezone.now(),
)
data = {
"from_currency": self.usd.id,
"to_currency": self.eur.id,
"rate": "0.92",
"date": rate.date.strftime('%Y-%m-%d %H:%M:%S')
"date": rate.date.strftime("%Y-%m-%d %H:%M:%S"),
}
response = self.client.post(reverse("exchange_rate_edit", args=[rate.id]), data)
self.assertEqual(response.status_code, 204)
@@ -259,7 +342,12 @@ class ExchangeRateViewTests(BaseCurrencyAppTest):
self.assertEqual(rate.rate, Decimal("0.92"))
def test_exchange_rate_delete_view(self):
rate = ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.93"), date=timezone.now())
rate = ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.93"),
date=timezone.now(),
)
response = self.client.delete(reverse("exchange_rate_delete", args=[rate.id]))
self.assertEqual(response.status_code, 204)
self.assertFalse(ExchangeRate.objects.filter(id=rate.id).exists())
@@ -267,8 +355,12 @@ class ExchangeRateViewTests(BaseCurrencyAppTest):
class ExchangeRateServiceViewTests(BaseCurrencyAppTest):
def test_exchange_rate_service_list_view(self):
service = ExchangeRateService.objects.create(name="My Test Service", service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE, fetch_interval="1")
response = self.client.get(reverse("exchange_rates_services_list"))
service = ExchangeRateService.objects.create(
name="My Test Service",
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
fetch_interval="1",
)
response = self.client.get(reverse("automatic_exchange_rates_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, service.name)
@@ -281,33 +373,47 @@ class ExchangeRateServiceViewTests(BaseCurrencyAppTest):
"fetch_interval": "24",
# target_currencies and target_accounts are M2M, handled differently or optional
}
response = self.client.post(reverse("exchange_rate_service_add"), data)
response = self.client.post(reverse("automatic_exchange_rate_add"), data)
self.assertEqual(response.status_code, 204)
self.assertTrue(ExchangeRateService.objects.filter(name="New Fetcher Service").exists())
self.assertTrue(
ExchangeRateService.objects.filter(name="New Fetcher Service").exists()
)
def test_exchange_rate_service_edit_view(self):
service = ExchangeRateService.objects.create(name="Editable Service", service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE, fetch_interval="1")
service = ExchangeRateService.objects.create(
name="Editable Service",
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
fetch_interval="1",
)
data = {
"name": "Edited Fetcher Service",
"service_type": service.service_type,
"is_active": "on",
"interval_type": service.interval_type,
"fetch_interval": "6", # Changed interval
"fetch_interval": "6", # Changed interval
}
response = self.client.post(reverse("exchange_rate_service_edit", args=[service.id]), data)
response = self.client.post(
reverse("automatic_exchange_rate_edit", args=[service.id]), data
)
self.assertEqual(response.status_code, 204)
service.refresh_from_db()
self.assertEqual(service.name, "Edited Fetcher Service")
self.assertEqual(service.fetch_interval, "6")
def test_exchange_rate_service_delete_view(self):
service = ExchangeRateService.objects.create(name="Deletable Service", service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE, fetch_interval="1")
response = self.client.delete(reverse("exchange_rate_service_delete", args=[service.id]))
service = ExchangeRateService.objects.create(
name="Deletable Service",
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
fetch_interval="1",
)
response = self.client.delete(
reverse("automatic_exchange_rate_delete", args=[service.id])
)
self.assertEqual(response.status_code, 204)
self.assertFalse(ExchangeRateService.objects.filter(id=service.id).exists())
@patch('apps.currencies.tasks.manual_fetch_exchange_rates.defer')
@patch("apps.currencies.tasks.manual_fetch_exchange_rates.defer")
def test_exchange_rate_service_force_fetch_view(self, mock_defer):
response = self.client.get(reverse("exchange_rate_service_force_fetch"))
self.assertEqual(response.status_code, 204) # Triggers toast
response = self.client.get(reverse("automatic_exchange_rate_force_fetch"))
self.assertEqual(response.status_code, 204) # Triggers toast
mock_defer.assert_called_once()