Files
WYGIWYH/app/apps/currencies/tests.py
google-labs-jules[bot] bf9f8bbf3a Add tests
2025-06-15 19:06:36 +00:00

413 lines
20 KiB
Python

from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from django.contrib.auth.models import User # Added for ERS owner
from datetime import date # Added for CurrencyConversionUtilsTests
from apps.currencies.utils.convert import get_exchange_rate, convert # Added convert
from unittest.mock import patch # Added patch
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
class CurrencyTests(TestCase):
def test_currency_creation(self):
"""Test basic currency creation"""
currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ ", suffix=" END "
)
self.assertEqual(str(currency), "US Dollar")
self.assertEqual(currency.code, "USD")
self.assertEqual(currency.decimal_places, 2)
self.assertEqual(currency.prefix, "$ ")
self.assertEqual(currency.suffix, " END ")
def test_currency_decimal_places_validation(self):
"""Test decimal places validation for maximum value"""
currency = Currency(
code="TEST",
name="Test Currency",
decimal_places=31, # Should fail as max is 30
)
with self.assertRaises(ValidationError):
currency.full_clean()
def test_currency_decimal_places_negative(self):
"""Test decimal places validation for negative value"""
currency = Currency(
code="TEST",
name="Test Currency",
decimal_places=-1, # Should fail as min is 0
)
with self.assertRaises(ValidationError):
currency.full_clean()
def test_currency_unique_code(self):
"""Test that currency codes must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
with self.assertRaises(IntegrityError):
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
def test_currency_unique_name(self):
"""Test that currency names must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
with self.assertRaises(IntegrityError):
Currency.objects.create(code="USD2", name="US Dollar", decimal_places=2)
def test_currency_exchange_currency_cannot_be_self(self):
"""Test that a currency's exchange_currency cannot be itself."""
currency = Currency.objects.create(
code="XYZ", name="Test XYZ", decimal_places=2
)
currency.exchange_currency = currency # Set exchange_currency to self
with self.assertRaises(ValidationError) as cm:
currency.full_clean()
self.assertIn('exchange_currency', cm.exception.error_dict)
# Optionally, check for a specific error message if known:
# self.assertTrue(any("cannot be the same as the currency itself" in e.message
# for e in cm.exception.error_dict['exchange_currency']))
class ExchangeRateServiceTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(username='ers_owner', password='password123')
self.base_currency = Currency.objects.create(code="BSC", name="Base Service Coin", decimal_places=2)
self.default_ers_params = {
'name': "Test ERS",
'owner': self.owner,
'base_currency': self.base_currency,
'provider_class': "dummy.provider.ClassName", # Placeholder
}
def _create_ers_instance(self, interval_type, fetch_interval, **kwargs):
params = {**self.default_ers_params, 'interval_type': interval_type, 'fetch_interval': fetch_interval, **kwargs}
return ExchangeRateService(**params)
# Tests for IntervalType.EVERY
def test_ers_interval_every_valid_integer(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.EVERY, "12")
try:
ers.full_clean()
except ValidationError:
self.fail("ValidationError raised unexpectedly for valid 'EVERY' interval '12'.")
def test_ers_interval_every_invalid_not_integer(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.EVERY, "abc")
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
def test_ers_interval_every_invalid_too_low(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.EVERY, "0")
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
def test_ers_interval_every_invalid_too_high(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.EVERY, "25") # Max is 24 for 'EVERY'
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
# Tests for IntervalType.ON (and by extension NOT_ON, as validation logic is shared)
def test_ers_interval_on_not_on_valid_single_hour(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "5")
try:
ers.full_clean() # Should normalize to "5" if not already
except ValidationError:
self.fail("ValidationError raised unexpectedly for valid 'ON' interval '5'.")
self.assertEqual(ers.fetch_interval, "5")
def test_ers_interval_on_not_on_valid_multiple_hours(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "1,8,22")
try:
ers.full_clean()
except ValidationError:
self.fail("ValidationError raised unexpectedly for valid 'ON' interval '1,8,22'.")
self.assertEqual(ers.fetch_interval, "1,8,22")
def test_ers_interval_on_not_on_valid_range(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "0-4")
ers.full_clean() # Should not raise ValidationError
self.assertEqual(ers.fetch_interval, "0,1,2,3,4")
def test_ers_interval_on_not_on_valid_mixed(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "1-3,8,10-12")
ers.full_clean() # Should not raise ValidationError
self.assertEqual(ers.fetch_interval, "1,2,3,8,10,11,12")
def test_ers_interval_on_not_on_invalid_char(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "1-3,a")
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
def test_ers_interval_on_not_on_invalid_hour_too_high(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "24") # Max is 23 for 'ON' type hours
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
def test_ers_interval_on_not_on_invalid_range_format(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "5-1")
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
def test_ers_interval_on_not_on_invalid_range_value_too_high(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "20-24") # 24 is invalid hour
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
def test_ers_interval_on_not_on_empty_interval(self):
ers = self._create_ers_instance(ExchangeRateService.IntervalType.ON, "")
with self.assertRaises(ValidationError) as cm:
ers.full_clean()
self.assertIn('fetch_interval', cm.exception.error_dict)
@patch('apps.currencies.exchange_rates.fetcher.PROVIDER_MAPPING')
def test_get_provider_valid_service_type(self, mock_provider_mapping):
"""Test get_provider returns a configured provider instance for a valid service_type."""
class MockSynthFinanceProvider:
def __init__(self, key):
self.key = key
# Configure the mock PROVIDER_MAPPING
mock_provider_mapping.get.return_value = MockSynthFinanceProvider
service_instance = self._create_ers_instance(
interval_type=ExchangeRateService.IntervalType.EVERY, # Needs some valid interval type
fetch_interval="1", # Needs some valid fetch interval
service_type=ExchangeRateService.ServiceType.SYNTH_FINANCE,
api_key="test_key"
)
# Ensure the service_type is correctly passed to the mock
# The actual get_provider method uses PROVIDER_MAPPING[self.service_type]
# So, we should make the mock_provider_mapping behave like a dict for the specific key
mock_provider_mapping = {ExchangeRateService.ServiceType.SYNTH_FINANCE: MockSynthFinanceProvider}
with patch('apps.currencies.exchange_rates.fetcher.PROVIDER_MAPPING', mock_provider_mapping):
provider = service_instance.get_provider()
self.assertIsInstance(provider, MockSynthFinanceProvider)
self.assertEqual(provider.key, "test_key")
@patch('apps.currencies.exchange_rates.fetcher.PROVIDER_MAPPING', {}) # Empty mapping
def test_get_provider_invalid_service_type(self, mock_provider_mapping_empty):
"""Test get_provider raises KeyError for an invalid or unmapped service_type."""
service_instance = self._create_ers_instance(
interval_type=ExchangeRateService.IntervalType.EVERY,
fetch_interval="1",
service_type="UNMAPPED_SERVICE_TYPE", # A type not in the (mocked) mapping
api_key="any_key"
)
with self.assertRaises(KeyError):
service_instance.get_provider()
class ExchangeRateTests(TestCase):
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=""
)
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):
ExchangeRate.objects.create(
from_currency=self.usd,
to_currency=self.eur,
rate=Decimal("0.86"),
date=date,
)
def test_from_and_to_currency_cannot_be_same(self):
"""Test that from_currency and to_currency cannot be the same."""
with self.assertRaises(ValidationError) as cm:
rate = ExchangeRate(
from_currency=self.usd,
to_currency=self.usd, # Same as from_currency
rate=Decimal("1.00"),
date=timezone.now().date(),
)
rate.full_clean()
# Check if the error message is as expected or if the error is associated with a specific field.
# The exact key ('to_currency' or '__all__') depends on how the model's clean() method is implemented.
# Assuming the validation error is raised with a message like "From and to currency cannot be the same."
# and is a non-field error or specifically tied to 'to_currency'.
self.assertTrue(
'__all__' in cm.exception.error_dict or 'to_currency' in cm.exception.error_dict,
"ValidationError should be for '__all__' or 'to_currency'"
)
# Optionally, check for a specific message if it's consistent:
# found_message = False
# if '__all__' in cm.exception.error_dict:
# found_message = any("cannot be the same" in e.message for e in cm.exception.error_dict['__all__'])
# if not found_message and 'to_currency' in cm.exception.error_dict:
# found_message = any("cannot be the same" in e.message for e in cm.exception.error_dict['to_currency'])
# self.assertTrue(found_message, "Error message about currencies being the same not found.")
class CurrencyConversionUtilsTests(TestCase):
def setUp(self):
self.usd = Currency.objects.create(code="USD", name="US Dollar", decimal_places=2, prefix="$", suffix="")
self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2, prefix="", suffix="")
self.gbp = Currency.objects.create(code="GBP", name="British Pound", decimal_places=2, prefix="£", suffix="")
# Rates for USD <-> EUR
self.usd_eur_rate_10th = ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.90"), date=date(2023, 1, 10))
self.usd_eur_rate_15th = ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.92"), date=date(2023, 1, 15))
ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.88"), date=date(2023, 1, 5))
# Rate for GBP <-> USD (for inverse lookup)
self.gbp_usd_rate_10th = ExchangeRate.objects.create(from_currency=self.gbp, to_currency=self.usd, rate=Decimal("1.25"), date=date(2023, 1, 10))
def test_get_direct_rate_closest_date(self):
"""Test fetching a direct rate, ensuring the closest date is chosen."""
result = get_exchange_rate(self.usd, self.eur, date(2023, 1, 16))
self.assertIsNotNone(result)
self.assertEqual(result.effective_rate, Decimal("0.92"))
self.assertEqual(result.original_from_currency, self.usd)
self.assertEqual(result.original_to_currency, self.eur)
def test_get_inverse_rate_closest_date(self):
"""Test fetching an inverse rate, ensuring the closest date and correct calculation."""
# We are looking for USD to GBP. We have GBP to USD on 2023-01-10.
# Target date is 2023-01-12.
result = get_exchange_rate(self.usd, self.gbp, date(2023, 1, 12))
self.assertIsNotNone(result)
self.assertEqual(result.effective_rate, Decimal("1") / self.gbp_usd_rate_10th.rate)
self.assertEqual(result.original_from_currency, self.gbp) # original_from_currency should be GBP
self.assertEqual(result.original_to_currency, self.usd) # original_to_currency should be USD
def test_get_rate_exact_date_preference(self):
"""Test that an exact date match is preferred over a closer one."""
# Existing rate is on 2023-01-15 (0.92)
# Add an exact match for the query date
exact_date_rate = ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.eur, rate=Decimal("0.91"), date=date(2023, 1, 16))
result = get_exchange_rate(self.usd, self.eur, date(2023, 1, 16))
self.assertIsNotNone(result)
self.assertEqual(result.effective_rate, Decimal("0.91"))
self.assertEqual(result.original_from_currency, self.usd)
self.assertEqual(result.original_to_currency, self.eur)
def test_get_rate_no_matching_pair(self):
"""Test that None is returned if no direct or inverse rate exists between the pair."""
# No rates exist for EUR <-> GBP in the setUp
result = get_exchange_rate(self.eur, self.gbp, date(2023, 1, 10))
self.assertIsNone(result)
def test_get_rate_prefer_direct_over_inverse_same_diff(self):
"""Test that a direct rate is preferred over an inverse if date differences are equal."""
# We have GBP-USD on 2023-01-10 (self.gbp_usd_rate_10th)
# This means an inverse USD-GBP rate is available for 2023-01-10.
# Add a direct USD-GBP rate for the same date.
direct_usd_gbp_rate = ExchangeRate.objects.create(from_currency=self.usd, to_currency=self.gbp, rate=Decimal("0.80"), date=date(2023, 1, 10))
result = get_exchange_rate(self.usd, self.gbp, date(2023, 1, 10))
self.assertIsNotNone(result)
self.assertEqual(result.effective_rate, Decimal("0.80"))
self.assertEqual(result.original_from_currency, self.usd)
self.assertEqual(result.original_to_currency, self.gbp)
# Now test the EUR to USD case from the problem description
# Add EUR to USD, rate 1.1, date 2023-01-10
eur_usd_direct_rate = ExchangeRate.objects.create(from_currency=self.eur, to_currency=self.usd, rate=Decimal("1.1"), date=date(2023, 1, 10))
# We also have USD to EUR on 2023-01-10 (rate 0.90), which would be an inverse match for EUR to USD.
result_eur_usd = get_exchange_rate(self.eur, self.usd, date(2023, 1, 10))
self.assertIsNotNone(result_eur_usd)
self.assertEqual(result_eur_usd.effective_rate, Decimal("1.1"))
self.assertEqual(result_eur_usd.original_from_currency, self.eur)
self.assertEqual(result_eur_usd.original_to_currency, self.usd)
def test_convert_successful_direct(self):
"""Test successful conversion using a direct rate."""
# Uses self.usd_eur_rate_15th (0.92) as it's closest to 2023-01-16
converted_amount, prefix, suffix, dp = convert(Decimal('100'), self.usd, self.eur, date(2023, 1, 16))
self.assertEqual(converted_amount, Decimal('92.00'))
self.assertEqual(prefix, self.eur.prefix)
self.assertEqual(suffix, self.eur.suffix)
self.assertEqual(dp, self.eur.decimal_places)
def test_convert_successful_inverse(self):
"""Test successful conversion using an inverse rate."""
# Uses self.gbp_usd_rate_10th (GBP to USD @ 1.25), so USD to GBP is 1/1.25 = 0.8
# Target date 2023-01-12, closest is 2023-01-10
converted_amount, prefix, suffix, dp = convert(Decimal('100'), self.usd, self.gbp, date(2023, 1, 12))
expected_amount = Decimal('100') * (Decimal('1') / self.gbp_usd_rate_10th.rate)
self.assertEqual(converted_amount, expected_amount.quantize(Decimal('0.01')))
self.assertEqual(prefix, self.gbp.prefix)
self.assertEqual(suffix, self.gbp.suffix)
self.assertEqual(dp, self.gbp.decimal_places)
def test_convert_no_rate_found(self):
"""Test conversion when no exchange rate is found."""
result_tuple = convert(Decimal('100'), self.eur, self.gbp, date(2023, 1, 10))
self.assertEqual(result_tuple, (None, None, None, None))
def test_convert_same_currency(self):
"""Test conversion when from_currency and to_currency are the same."""
result_tuple = convert(Decimal('100'), self.usd, self.usd, date(2023, 1, 10))
self.assertEqual(result_tuple, (None, None, None, None))
def test_convert_zero_amount(self):
"""Test conversion when the amount is zero."""
result_tuple = convert(Decimal('0'), self.usd, self.eur, date(2023, 1, 10))
self.assertEqual(result_tuple, (None, None, None, None))
@patch('apps.currencies.utils.convert.timezone')
def test_convert_no_date_uses_today(self, mock_timezone):
"""Test conversion uses today's date when no date is provided."""
# Mock timezone.now().date() to return a specific date
mock_today = date(2023, 1, 16)
mock_timezone.now.return_value.date.return_value = mock_today
# This should use self.usd_eur_rate_15th (0.92) as it's closest to mocked "today" (2023-01-16)
converted_amount, prefix, suffix, dp = convert(Decimal('100'), self.usd, self.eur)
self.assertEqual(converted_amount, Decimal('92.00'))
self.assertEqual(prefix, self.eur.prefix)
self.assertEqual(suffix, self.eur.suffix)
self.assertEqual(dp, self.eur.decimal_places)
# Verify that timezone.now().date() was called (indirectly, by get_exchange_rate)
# This specific assertion for get_exchange_rate being called with a specific date
# would require patching get_exchange_rate itself, which is more complex.
# For now, we rely on the correct outcome given the mocked date.
# A more direct way to test date passing is if convert took get_exchange_rate as a dependency.
mock_timezone.now.return_value.date.assert_called_once()