Files
WYGIWYH/app/apps/api/tests.py
google-labs-jules[bot] 02f6bb0c29 Add initial Django tests for multiple apps
This commit introduces Django tests for several applications within your project. My goal was to cover the most important elements of each app.

Work Performed:

I analyzed and added tests for the following apps:
- apps.users: User authentication and profile management.
- apps.transactions: CRUD operations for transactions, categories, tags, entities, installment plans, and recurring transactions.
- apps.currencies: Management of currencies, exchange rates, and exchange rate services.
- apps.accounts: CRUD operations for accounts and account groups, including sharing.
- apps.common: Various utilities like custom fields, template tags, decorators, and management commands.
- apps.net_worth: Net worth calculation logic and display views.
- apps.import_app: Import profile validation, import service logic, and basic file processing.
- apps.export_app: Data export functionality using ModelResources and view logic for CSV/ZIP.
- apps.api: Core API endpoints for transactions and accounts, including permissions.

I also planned to cover:
- apps.rules
- apps.calendar_view
- apps.dca
2025-06-15 20:12:37 +00:00

245 lines
13 KiB
Python

from decimal import Decimal
from datetime import date, datetime
from unittest.mock import patch
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.conf import settings
from rest_framework.test import APIClient, APITestCase # APITestCase handles DB setup better for API tests
from rest_framework import status
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag, TransactionEntity
# Assuming thread_local is used for setting user for serializers if they auto-assign owner
from apps.common.middleware.thread_local import set_current_user
User = get_user_model()
class BaseAPITestCase(APITestCase): # Use APITestCase for DRF tests
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(email="apiuser@example.com", password="password")
cls.superuser = User.objects.create_superuser(email="apisuper@example.com", password="password")
cls.currency_usd = Currency.objects.create(code="USD", name="US Dollar API", decimal_places=2)
cls.account_group_api = AccountGroup.objects.create(name="API Group", owner=cls.user)
cls.account_usd_api = Account.objects.create(
name="API Checking USD", currency=cls.currency_usd, owner=cls.user, group=cls.account_group_api
)
cls.category_api = TransactionCategory.objects.create(name="API Food", owner=cls.user)
cls.tag_api = TransactionTag.objects.create(name="API Urgent", owner=cls.user)
cls.entity_api = TransactionEntity.objects.create(name="API Store", owner=cls.user)
def setUp(self):
self.client = APIClient()
# Authenticate as regular user by default, can be overridden in tests
self.client.force_authenticate(user=self.user)
set_current_user(self.user) # For serializers/models that might use get_current_user
def tearDown(self):
set_current_user(None)
class TransactionAPITests(BaseAPITestCase):
def test_list_transactions(self):
# Create a transaction for the authenticated user
Transaction.objects.create(
account=self.account_usd_api, owner=self.user, type=Transaction.Type.EXPENSE,
date=date(2023, 1, 1), amount=Decimal("10.00"), description="Test List"
)
url = reverse("transaction-list") # DRF default router name
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["pagination"]["count"], 1)
self.assertEqual(response.data["results"][0]["description"], "Test List")
def test_retrieve_transaction(self):
t = Transaction.objects.create(
account=self.account_usd_api, owner=self.user, type=Transaction.Type.INCOME,
date=date(2023, 2, 1), amount=Decimal("100.00"), description="Specific Salary"
)
url = reverse("transaction-detail", kwargs={'pk': t.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["description"], "Specific Salary")
self.assertIn("exchanged_amount", response.data) # Check for SerializerMethodField
@patch('apps.transactions.signals.transaction_created.send')
def test_create_transaction(self, mock_signal_send):
url = reverse("transaction-list")
data = {
"account_id": self.account_usd_api.pk,
"type": Transaction.Type.EXPENSE,
"date": "2023-03-01",
"reference_date": "2023-03", # Test custom format
"amount": "25.50",
"description": "New API Expense",
"category": self.category_api.name, # Assuming TransactionCategoryField handles name to instance
"tags": [self.tag_api.name], # Assuming TransactionTagField handles list of names
"entities": [self.entity_api.name] # Assuming TransactionEntityField handles list of names
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
self.assertTrue(Transaction.objects.filter(description="New API Expense").exists())
created_transaction = Transaction.objects.get(description="New API Expense")
self.assertEqual(created_transaction.owner, self.user) # Check if owner is set
self.assertEqual(created_transaction.category.name, self.category_api.name)
self.assertIn(self.tag_api, created_transaction.tags.all())
mock_signal_send.assert_called_once()
def test_create_transaction_missing_fields(self):
url = reverse("transaction-list")
data = {"account_id": self.account_usd_api.pk, "type": Transaction.Type.EXPENSE} # Missing date, amount, desc
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("date", response.data) # Or reference_date due to custom validate
self.assertIn("amount", response.data)
self.assertIn("description", response.data)
@patch('apps.transactions.signals.transaction_updated.send')
def test_update_transaction_put(self, mock_signal_send):
t = Transaction.objects.create(
account=self.account_usd_api, owner=self.user, type=Transaction.Type.EXPENSE,
date=date(2023, 4, 1), amount=Decimal("50.00"), description="Initial PUT"
)
url = reverse("transaction-detail", kwargs={'pk': t.pk})
data = {
"account_id": self.account_usd_api.pk,
"type": Transaction.Type.INCOME, # Changed type
"date": "2023-04-05", # Changed date
"amount": "75.00", # Changed amount
"description": "Updated PUT Transaction",
"category": self.category_api.name
}
response = self.client.put(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
t.refresh_from_db()
self.assertEqual(t.description, "Updated PUT Transaction")
self.assertEqual(t.type, Transaction.Type.INCOME)
self.assertEqual(t.amount, Decimal("75.00"))
mock_signal_send.assert_called_once()
@patch('apps.transactions.signals.transaction_updated.send')
def test_update_transaction_patch(self, mock_signal_send):
t = Transaction.objects.create(
account=self.account_usd_api, owner=self.user, type=Transaction.Type.EXPENSE,
date=date(2023, 5, 1), amount=Decimal("30.00"), description="Initial PATCH"
)
url = reverse("transaction-detail", kwargs={'pk': t.pk})
data = {"description": "Patched Description"}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
t.refresh_from_db()
self.assertEqual(t.description, "Patched Description")
mock_signal_send.assert_called_once()
def test_delete_transaction(self):
t = Transaction.objects.create(
account=self.account_usd_api, owner=self.user, type=Transaction.Type.EXPENSE,
date=date(2023, 6, 1), amount=Decimal("10.00"), description="To Delete"
)
url = reverse("transaction-detail", kwargs={'pk': t.pk})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
# Default manager should not find it (soft delete)
self.assertFalse(Transaction.objects.filter(pk=t.pk).exists())
self.assertTrue(Transaction.all_objects.filter(pk=t.pk, deleted=True).exists())
class AccountAPITests(BaseAPITestCase):
def test_list_accounts(self):
url = reverse("account-list")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# setUp creates one account (self.account_usd_api) for self.user
self.assertEqual(response.data["pagination"]["count"], 1)
self.assertEqual(response.data["results"][0]["name"], self.account_usd_api.name)
def test_create_account(self):
url = reverse("account-list")
data = {
"name": "API Savings EUR",
"currency_id": self.currency_eur.pk,
"group_id": self.account_group_api.pk,
"is_asset": False
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
self.assertTrue(Account.objects.filter(name="API Savings EUR", owner=self.user).exists())
# --- Permission Tests ---
class APIPermissionTests(BaseAPITestCase):
def test_not_in_demo_mode_permission_regular_user(self):
# Temporarily activate demo mode
with self.settings(DEMO=True):
url = reverse("transaction-list")
# Attempt POST as regular user (self.user is not superuser)
response = self.client.post(url, {"description": "test"}, format='json')
# This depends on default permissions. If IsAuthenticated allows POST, NotInDemoMode should deny.
# If default is ReadOnly, then GET would be allowed, POST denied regardless of NotInDemoMode for non-admin.
# Assuming NotInDemoMode is a primary gate for write operations.
# The permission itself doesn't check request.method, just user status in demo.
# So, even GET might be denied if NotInDemoMode were the *only* permission.
# However, ViewSets usually have IsAuthenticated or similar allowing GET.
# Let's assume NotInDemoMode is added to default_permission_classes and tested on a write view.
# For a POST to transactions:
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# GET should still be allowed if default permissions allow it (e.g. IsAuthenticatedOrReadOnly)
# and NotInDemoMode only blocks mutating methods or specific views.
# The current NotInDemoMode blocks *all* access for non-superusers in demo.
get_response = self.client.get(url)
self.assertEqual(get_response.status_code, status.HTTP_403_FORBIDDEN)
def test_not_in_demo_mode_permission_superuser(self):
self.client.force_authenticate(user=self.superuser)
set_current_user(self.superuser)
with self.settings(DEMO=True):
url = reverse("transaction-list")
data = { # Valid data for transaction creation
"account_id": self.account_usd_api.pk, "type": Transaction.Type.EXPENSE,
"date": "2023-07-01", "amount": "1.00", "description": "Superuser Demo Post"
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
get_response = self.client.get(url)
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
def test_access_in_non_demo_mode(self):
with self.settings(DEMO=False): # Explicitly ensure demo mode is off
url = reverse("transaction-list")
data = {
"account_id": self.account_usd_api.pk, "type": Transaction.Type.EXPENSE,
"date": "2023-08-01", "amount": "2.00", "description": "Non-Demo Post"
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
get_response = self.client.get(url)
self.assertEqual(get_response.status_code, status.HTTP_200_OK)
def test_unauthenticated_access(self):
self.client.logout() # Or self.client.force_authenticate(user=None)
set_current_user(None)
url = reverse("transaction-list")
response = self.client.get(url)
# Default behavior for DRF is IsAuthenticated, so should be 401 or 403
# If IsAuthenticatedOrReadOnly, GET would be 200.
# Given serializers specify IsAuthenticated, likely 401/403.
self.assertTrue(response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
# TODO: Add tests for pagination by providing `?page=X` and `?page_size=Y`
# TODO: Add tests for filtering if specific filter_backends are configured on ViewSets.
# TODO: Add tests for other ViewSets (Categories, Tags, Accounts, etc.)
# TODO: Test custom serializer fields like TransactionCategoryField more directly if their logic is complex.
# (e.g., creating category by name if it doesn't exist vs. only allowing existing by ID)
# The current create test for transactions implicitly tests this behavior.
```