mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-01-11 20:00:26 +01:00
345 lines
15 KiB
Python
345 lines
15 KiB
Python
from django.test import TestCase, Client
|
|
from django.contrib.auth.models import User
|
|
from django.urls import reverse
|
|
from django.forms import NON_FIELD_ERRORS
|
|
from apps.currencies.models import Currency
|
|
from apps.dca.models import DCAStrategy, DCAEntry
|
|
from apps.dca.forms import DCAStrategyForm, DCAEntryForm # Added DCAEntryForm
|
|
from apps.accounts.models import Account, AccountGroup # Added Account models
|
|
from apps.transactions.models import TransactionCategory, Transaction # Added Transaction models
|
|
from decimal import Decimal
|
|
from datetime import date
|
|
from unittest.mock import patch
|
|
|
|
class DCATests(TestCase):
|
|
def setUp(self):
|
|
self.owner = User.objects.create_user(username='testowner', password='password123')
|
|
self.client = Client()
|
|
self.client.login(username='testowner', password='password123')
|
|
|
|
self.payment_curr = Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
|
self.target_curr = Currency.objects.create(code="BTC", name="Bitcoin", decimal_places=8)
|
|
|
|
# AccountGroup for accounts
|
|
self.account_group = AccountGroup.objects.create(name="DCA Test Group", owner=self.owner)
|
|
|
|
# Accounts for transactions
|
|
self.account1 = Account.objects.create(
|
|
name="Payment Account USD",
|
|
owner=self.owner,
|
|
currency=self.payment_curr,
|
|
group=self.account_group
|
|
)
|
|
self.account2 = Account.objects.create(
|
|
name="Target Account BTC",
|
|
owner=self.owner,
|
|
currency=self.target_curr,
|
|
group=self.account_group
|
|
)
|
|
|
|
# TransactionCategory for transactions
|
|
# Using INFO type as it's generic. TRANSFER might imply specific paired transaction logic not relevant here.
|
|
self.category1 = TransactionCategory.objects.create(
|
|
name="DCA Category",
|
|
owner=self.owner,
|
|
type=TransactionCategory.TransactionType.INFO
|
|
)
|
|
|
|
|
|
self.strategy1 = DCAStrategy.objects.create(
|
|
name="Test Strategy 1",
|
|
owner=self.owner,
|
|
payment_currency=self.payment_curr,
|
|
target_currency=self.target_curr
|
|
)
|
|
|
|
self.entries1 = [
|
|
DCAEntry.objects.create(
|
|
strategy=self.strategy1,
|
|
date=date(2023, 1, 1),
|
|
amount_paid=Decimal('100.00'),
|
|
amount_received=Decimal('0.010')
|
|
),
|
|
DCAEntry.objects.create(
|
|
strategy=self.strategy1,
|
|
date=date(2023, 2, 1),
|
|
amount_paid=Decimal('150.00'),
|
|
amount_received=Decimal('0.012')
|
|
),
|
|
DCAEntry.objects.create(
|
|
strategy=self.strategy1,
|
|
date=date(2023, 3, 1),
|
|
amount_paid=Decimal('120.00'),
|
|
amount_received=Decimal('0.008')
|
|
)
|
|
]
|
|
|
|
def test_strategy_index_view_authenticated_user(self):
|
|
# Uses self.client and self.owner from setUp
|
|
response = self.client.get(reverse('dca:dca_strategy_index'))
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_strategy_totals_and_average_price(self):
|
|
self.assertEqual(self.strategy1.total_entries(), 3)
|
|
self.assertEqual(self.strategy1.total_invested(), Decimal('370.00')) # 100 + 150 + 120
|
|
self.assertEqual(self.strategy1.total_received(), Decimal('0.030')) # 0.01 + 0.012 + 0.008
|
|
|
|
expected_avg_price = Decimal('370.00') / Decimal('0.030')
|
|
# Match precision of the model method if it's specific, e.g. quantize
|
|
# For now, direct comparison. The model might return a Decimal that needs specific quantizing.
|
|
self.assertEqual(self.strategy1.average_entry_price(), expected_avg_price)
|
|
|
|
def test_strategy_average_price_no_received(self):
|
|
strategy2 = DCAStrategy.objects.create(
|
|
name="Test Strategy 2",
|
|
owner=self.owner,
|
|
payment_currency=self.payment_curr,
|
|
target_currency=self.target_curr
|
|
)
|
|
DCAEntry.objects.create(
|
|
strategy=strategy2,
|
|
date=date(2023, 4, 1),
|
|
amount_paid=Decimal('100.00'),
|
|
amount_received=Decimal('0') # Total received is zero
|
|
)
|
|
self.assertEqual(strategy2.total_received(), Decimal('0'))
|
|
self.assertEqual(strategy2.average_entry_price(), Decimal('0'))
|
|
|
|
@patch('apps.dca.models.convert')
|
|
def test_dca_entry_value_and_pl(self, mock_convert):
|
|
entry = self.entries1[0] # amount_paid=100, amount_received=0.010
|
|
|
|
# Simulate current price: 1 target_curr = 20,000 payment_curr
|
|
# So, 0.010 target_curr should be 0.010 * 20000 = 200 payment_curr
|
|
simulated_converted_value = entry.amount_received * Decimal('20000')
|
|
mock_convert.return_value = (
|
|
simulated_converted_value,
|
|
self.payment_curr.prefix,
|
|
self.payment_curr.suffix,
|
|
self.payment_curr.decimal_places
|
|
)
|
|
|
|
current_val = entry.current_value()
|
|
self.assertEqual(current_val, Decimal('200.00'))
|
|
|
|
# Profit/Loss = current_value - amount_paid = 200 - 100 = 100
|
|
self.assertEqual(entry.profit_loss(), Decimal('100.00'))
|
|
|
|
# P/L % = (profit_loss / amount_paid) * 100 = (100 / 100) * 100 = 100
|
|
self.assertEqual(entry.profit_loss_percentage(), Decimal('100.00'))
|
|
|
|
# Check that convert was called correctly by current_value()
|
|
# current_value calls convert(self.amount_received, self.strategy.target_currency, self.strategy.payment_currency)
|
|
# The date argument defaults to None if not passed, which is the case here.
|
|
mock_convert.assert_called_once_with(
|
|
entry.amount_received,
|
|
self.strategy1.target_currency,
|
|
self.strategy1.payment_currency,
|
|
None # Date argument is optional and defaults to None
|
|
)
|
|
|
|
@patch('apps.dca.models.convert')
|
|
def test_dca_strategy_value_and_pl(self, mock_convert):
|
|
|
|
def side_effect_func(amount_to_convert, from_currency, to_currency, date=None):
|
|
if from_currency == self.target_curr and to_currency == self.payment_curr:
|
|
# Simulate current price: 1 target_curr = 20,000 payment_curr
|
|
converted_value = amount_to_convert * Decimal('20000')
|
|
return (converted_value, self.payment_curr.prefix, self.payment_curr.suffix, self.payment_curr.decimal_places)
|
|
# Fallback for any other unexpected calls, though not expected in this test
|
|
return (Decimal('0'), '', '', 2)
|
|
|
|
mock_convert.side_effect = side_effect_func
|
|
|
|
# strategy1 entries:
|
|
# 1: paid 100, received 0.010. Current value = 0.010 * 20000 = 200
|
|
# 2: paid 150, received 0.012. Current value = 0.012 * 20000 = 240
|
|
# 3: paid 120, received 0.008. Current value = 0.008 * 20000 = 160
|
|
# Total current value = 200 + 240 + 160 = 600
|
|
self.assertEqual(self.strategy1.current_total_value(), Decimal('600.00'))
|
|
|
|
# Total invested = 100 + 150 + 120 = 370
|
|
# Total profit/loss = current_total_value - total_invested = 600 - 370 = 230
|
|
self.assertEqual(self.strategy1.total_profit_loss(), Decimal('230.00'))
|
|
|
|
# Total P/L % = (total_profit_loss / total_invested) * 100
|
|
# (230 / 370) * 100 = 62.162162...
|
|
expected_pl_percentage = (Decimal('230.00') / Decimal('370.00')) * Decimal('100')
|
|
self.assertAlmostEqual(self.strategy1.total_profit_loss_percentage(), expected_pl_percentage, places=2)
|
|
|
|
def test_dca_strategy_form_valid_data(self):
|
|
form_data = {
|
|
'name': 'Form Test Strategy',
|
|
'target_currency': self.target_curr.pk,
|
|
'payment_currency': self.payment_curr.pk
|
|
}
|
|
form = DCAStrategyForm(data=form_data)
|
|
self.assertTrue(form.is_valid(), form.errors.as_text())
|
|
|
|
strategy = form.save(commit=False)
|
|
strategy.owner = self.owner
|
|
strategy.save()
|
|
|
|
self.assertEqual(strategy.name, 'Form Test Strategy')
|
|
self.assertEqual(strategy.owner, self.owner)
|
|
self.assertEqual(strategy.target_currency, self.target_curr)
|
|
self.assertEqual(strategy.payment_currency, self.payment_curr)
|
|
|
|
def test_dca_strategy_form_missing_name(self):
|
|
form_data = {
|
|
'target_currency': self.target_curr.pk,
|
|
'payment_currency': self.payment_curr.pk
|
|
}
|
|
form = DCAStrategyForm(data=form_data)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn('name', form.errors)
|
|
|
|
def test_dca_strategy_form_missing_target_currency(self):
|
|
form_data = {
|
|
'name': 'Form Test Missing Target',
|
|
'payment_currency': self.payment_curr.pk
|
|
}
|
|
form = DCAStrategyForm(data=form_data)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn('target_currency', form.errors)
|
|
|
|
# Tests for DCAEntryForm clean method
|
|
def test_dca_entry_form_clean_create_transaction_missing_accounts(self):
|
|
data = {
|
|
'date': date(2023, 1, 1),
|
|
'amount_paid': Decimal('100.00'),
|
|
'amount_received': Decimal('0.01'),
|
|
'create_transaction': True,
|
|
# from_account and to_account are missing
|
|
}
|
|
form = DCAEntryForm(data=data, strategy=self.strategy1, owner=self.owner)
|
|
self.assertFalse(form.is_valid())
|
|
self.assertIn('from_account', form.errors)
|
|
self.assertIn('to_account', form.errors)
|
|
|
|
def test_dca_entry_form_clean_create_transaction_same_accounts(self):
|
|
data = {
|
|
'date': date(2023, 1, 1),
|
|
'amount_paid': Decimal('100.00'),
|
|
'amount_received': Decimal('0.01'),
|
|
'create_transaction': True,
|
|
'from_account': self.account1.pk,
|
|
'to_account': self.account1.pk, # Same as from_account
|
|
'from_category': self.category1.pk,
|
|
'to_category': self.category1.pk,
|
|
}
|
|
form = DCAEntryForm(data=data, strategy=self.strategy1, owner=self.owner)
|
|
self.assertFalse(form.is_valid())
|
|
# Check for non-field error or specific field error based on form implementation
|
|
self.assertTrue(NON_FIELD_ERRORS in form.errors or 'to_account' in form.errors)
|
|
if NON_FIELD_ERRORS in form.errors:
|
|
self.assertTrue(any("From and To accounts must be different" in error for error in form.errors[NON_FIELD_ERRORS]))
|
|
|
|
|
|
# Tests for DCAEntryForm save method
|
|
def test_dca_entry_form_save_create_transactions(self):
|
|
data = {
|
|
'date': date(2023, 5, 1),
|
|
'amount_paid': Decimal('200.00'),
|
|
'amount_received': Decimal('0.025'),
|
|
'create_transaction': True,
|
|
'from_account': self.account1.pk,
|
|
'to_account': self.account2.pk,
|
|
'from_category': self.category1.pk,
|
|
'to_category': self.category1.pk,
|
|
'description': 'Test DCA entry transaction creation'
|
|
}
|
|
form = DCAEntryForm(data=data, strategy=self.strategy1, owner=self.owner)
|
|
|
|
if not form.is_valid():
|
|
print(form.errors.as_json()) # Print errors if form is invalid
|
|
self.assertTrue(form.is_valid())
|
|
|
|
entry = form.save()
|
|
|
|
self.assertIsNotNone(entry.pk)
|
|
self.assertEqual(entry.strategy, self.strategy1)
|
|
self.assertIsNotNone(entry.expense_transaction)
|
|
self.assertIsNotNone(entry.income_transaction)
|
|
|
|
# Check expense transaction
|
|
expense_tx = entry.expense_transaction
|
|
self.assertEqual(expense_tx.account, self.account1)
|
|
self.assertEqual(expense_tx.type, Transaction.Type.EXPENSE)
|
|
self.assertEqual(expense_tx.amount, data['amount_paid'])
|
|
self.assertEqual(expense_tx.category, self.category1)
|
|
self.assertEqual(expense_tx.owner, self.owner)
|
|
self.assertEqual(expense_tx.date, data['date'])
|
|
self.assertIn(str(entry.id)[:8], expense_tx.description) # Check if part of entry ID is in description
|
|
|
|
# Check income transaction
|
|
income_tx = entry.income_transaction
|
|
self.assertEqual(income_tx.account, self.account2)
|
|
self.assertEqual(income_tx.type, Transaction.Type.INCOME)
|
|
self.assertEqual(income_tx.amount, data['amount_received'])
|
|
self.assertEqual(income_tx.category, self.category1)
|
|
self.assertEqual(income_tx.owner, self.owner)
|
|
self.assertEqual(income_tx.date, data['date'])
|
|
self.assertIn(str(entry.id)[:8], income_tx.description)
|
|
|
|
|
|
def test_dca_entry_form_save_update_linked_transactions(self):
|
|
# 1. Create an initial DCAEntry with linked transactions
|
|
initial_data = {
|
|
'date': date(2023, 6, 1),
|
|
'amount_paid': Decimal('50.00'),
|
|
'amount_received': Decimal('0.005'),
|
|
'create_transaction': True,
|
|
'from_account': self.account1.pk,
|
|
'to_account': self.account2.pk,
|
|
'from_category': self.category1.pk,
|
|
'to_category': self.category1.pk,
|
|
}
|
|
initial_form = DCAEntryForm(data=initial_data, strategy=self.strategy1, owner=self.owner)
|
|
self.assertTrue(initial_form.is_valid(), initial_form.errors.as_json())
|
|
initial_entry = initial_form.save()
|
|
|
|
self.assertIsNotNone(initial_entry.expense_transaction)
|
|
self.assertIsNotNone(initial_entry.income_transaction)
|
|
|
|
# 2. Data for updating the form
|
|
update_data = {
|
|
'date': initial_entry.date, # Keep date same or change, as needed
|
|
'amount_paid': Decimal('55.00'), # New value
|
|
'amount_received': Decimal('0.006'), # New value
|
|
# 'create_transaction': False, # Or not present, form should not create new if instance has linked tx
|
|
'from_account': initial_entry.expense_transaction.account.pk, # Keep same accounts
|
|
'to_account': initial_entry.income_transaction.account.pk,
|
|
'from_category': initial_entry.expense_transaction.category.pk,
|
|
'to_category': initial_entry.income_transaction.category.pk,
|
|
}
|
|
|
|
# When create_transaction is not checked (or False), it means we are not creating *new* transactions,
|
|
# but if the instance already has linked transactions, they *should* be updated.
|
|
# The form's save method should handle this.
|
|
|
|
update_form = DCAEntryForm(data=update_data, instance=initial_entry, strategy=initial_entry.strategy, owner=self.owner)
|
|
|
|
if not update_form.is_valid():
|
|
print(update_form.errors.as_json()) # Print errors if form is invalid
|
|
self.assertTrue(update_form.is_valid())
|
|
|
|
updated_entry = update_form.save()
|
|
|
|
# Refresh from DB to ensure changes are saved and reflected
|
|
updated_entry.refresh_from_db()
|
|
if updated_entry.expense_transaction: # Check if it exists before trying to refresh
|
|
updated_entry.expense_transaction.refresh_from_db()
|
|
if updated_entry.income_transaction: # Check if it exists before trying to refresh
|
|
updated_entry.income_transaction.refresh_from_db()
|
|
|
|
|
|
self.assertEqual(updated_entry.amount_paid, Decimal('55.00'))
|
|
self.assertEqual(updated_entry.amount_received, Decimal('0.006'))
|
|
|
|
self.assertIsNotNone(updated_entry.expense_transaction, "Expense transaction should still be linked.")
|
|
self.assertEqual(updated_entry.expense_transaction.amount, Decimal('55.00'))
|
|
|
|
self.assertIsNotNone(updated_entry.income_transaction, "Income transaction should still be linked.")
|
|
self.assertEqual(updated_entry.income_transaction.amount, Decimal('0.006'))
|