Files
WYGIWYH/app/apps/currencies/tests.py
2025-06-15 23:12:22 -03:00

420 lines
16 KiB
Python

from decimal import Decimal
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase, Client
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
User = get_user_model()
class BaseCurrencyAppTest(TestCase):
def setUp(self):
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=""
)
class CurrencyModelTests(BaseCurrencyAppTest): # Changed from CurrencyTests
def test_currency_creation(self):
"""Test basic currency creation"""
# self.usd is already created in BaseCurrencyAppTest
self.assertEqual(str(self.usd), "US Dollar")
self.assertEqual(self.usd.code, "USD")
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=""
)
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)
with self.assertRaises(ValidationError):
currency.full_clean()
def test_currency_decimal_places_negative(self):
"""Test decimal places validation for negative value"""
currency = Currency(code="TESTNEG", name="Test Currency Neg", decimal_places=-1)
with self.assertRaises(ValidationError):
currency.full_clean()
# Note: unique_code and unique_name tests might behave differently with how Django handles
# model creation vs full_clean. IntegrityError is caught at DB level.
# These tests are fine as they are for DB level.
def test_currency_clean_self_exchange_currency(self):
"""Test that a currency cannot be its own exchange_currency."""
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"],
)
class ExchangeRateModelTests(BaseCurrencyAppTest): # Changed from ExchangeRateTests
def test_exchange_rate_creation(self):
"""Test basic exchange rate creation"""
rate = ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.85"),
date=timezone.now(),
)
self.assertEqual(rate.rate, Decimal("0.85"))
self.assertIn("USD to EUR", str(rate))
def test_unique_exchange_rate_constraint(self):
"""Test that duplicate exchange rates for same currency pair and date are prevented"""
date = timezone.now()
ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.85"),
date=date,
)
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
date=date,
)
def test_exchange_rate_clean_same_currency(self):
"""Test that from_currency and to_currency cannot be the same."""
rate = ExchangeRate(
from_currency=self.usd,
to_currency=self.usd, # Same currency
rate=Decimal("1.00"),
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"],
)
class ExchangeRateServiceModelTests(BaseCurrencyAppTest):
def test_service_creation(self):
service = ExchangeRateService.objects.create(
name="Test Coingecko Free",
service_type=ExchangeRateService.ServiceType.COINGECKO_FREE,
interval_type=ExchangeRateService.IntervalType.EVERY,
fetch_interval="12", # Every 12 hours
)
self.assertEqual(str(service), "Test Coingecko Free")
self.assertTrue(service.is_active)
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",
)
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],
)
# Invalid - out of range
service.fetch_interval = "0"
with self.assertRaises(ValidationError):
service.full_clean()
service.fetch_interval = "25"
with self.assertRaises(ValidationError):
service.full_clean()
def test_fetch_interval_validation_on_not_on(self):
# Valid examples for 'on' or 'not_on'
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,
)
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
]
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,
)
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]
)
@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",
)
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
)
class CurrencyViewTests(BaseCurrencyAppTest):
def test_currency_list_view(self):
response = self.client.get(reverse("currencies_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.usd.name)
self.assertContains(response, self.eur.name)
def test_currency_add_view(self):
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.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": "£",
}
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
)
response = self.client.delete(reverse("currency_delete", args=[cad.id]))
self.assertEqual(response.status_code, 204)
self.assertFalse(Currency.objects.filter(code="CAD").exists())
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(),
)
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.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}"
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
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
}
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()
)
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(),
)
data = {
"from_currency": self.usd.id,
"to_currency": self.eur.id,
"rate": "0.92",
"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)
rate.refresh_from_db()
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(),
)
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())
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("automatic_exchange_rates_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, service.name)
def test_exchange_rate_service_add_view(self):
data = {
"name": "New Fetcher Service",
"service_type": ExchangeRateService.ServiceType.COINGECKO_FREE,
"is_active": "on",
"interval_type": ExchangeRateService.IntervalType.EVERY,
"fetch_interval": "24",
# target_currencies and target_accounts are M2M, handled differently or optional
}
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()
)
def test_exchange_rate_service_edit_view(self):
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
}
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("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")
def test_exchange_rate_service_force_fetch_view(self, mock_defer):
response = self.client.get(reverse("automatic_exchange_rate_force_fetch"))
self.assertEqual(response.status_code, 204) # Triggers toast
mock_defer.assert_called_once()