Fixed bugs 2 #15
31
expenses_manager/expenses/tests/conftest.py
Normal file
31
expenses_manager/expenses/tests/conftest.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import pytest
|
||||||
|
from decimal import Decimal
|
||||||
|
from expenses.models import Account, Category
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user(db, django_user_model):
|
||||||
|
return django_user_model.objects.create_user(
|
||||||
|
username="tester", password="testpass123"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client, user):
|
||||||
|
client.login(username=user.username, password="testpass123")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def account(user):
|
||||||
|
return Account.objects.create(
|
||||||
|
owner=user,
|
||||||
|
name="Cuenta principal",
|
||||||
|
initial_balance=Decimal("0"),
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def category(user):
|
||||||
|
return Category.objects.create(owner=user, name="General")
|
||||||
86
expenses_manager/expenses/tests/test_accounts.py
Normal file
86
expenses_manager/expenses/tests/test_accounts.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from expenses.models import Account, Expense, Income, Category
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_balance_reflects_income_and_expenses(user, category):
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
|
||||||
|
Income.objects.create(owner=user, account=acc, name="Nomina", amount=Decimal("50"), date=date.today())
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("30"), date=date.today())
|
||||||
|
|
||||||
|
assert acc.current_balance() == Decimal("120")
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_balance_returns_12_rows_in_order(user):
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
|
||||||
|
|
||||||
|
data = acc.monthly_balance(2020)
|
||||||
|
|
||||||
|
assert len(data) == 12
|
||||||
|
assert [row["month"] for row in data] == list(range(1, 13))
|
||||||
|
assert all(row["balance"] == 100.0 for row in data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_balance_is_cumulative_across_months(user, category):
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||||
|
year = date.today().year - 1 # avoid the current-month live-balance patch
|
||||||
|
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("40"), date=date(year, 3, 1))
|
||||||
|
Income.objects.create(owner=user, account=acc, name="Extra", amount=Decimal("100"), date=date(year, 6, 1))
|
||||||
|
|
||||||
|
data = acc.monthly_balance(year)
|
||||||
|
|
||||||
|
assert data[2]["balance"] == -40.0 # month 3
|
||||||
|
assert data[5]["balance"] == 60.0 # month 6, cumulative
|
||||||
|
assert data[11]["balance"] == 60.0 # carries forward to December
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_balance_respects_year_param_and_excludes_other_years(user, category):
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||||
|
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("50"), date=date(2023, 6, 15))
|
||||||
|
Income.objects.create(owner=user, account=acc, name="Extra", amount=Decimal("30"), date=date(2024, 3, 10))
|
||||||
|
|
||||||
|
data_2023 = acc.monthly_balance(2023)
|
||||||
|
assert data_2023[5]["balance"] == -50.0 # month 6
|
||||||
|
assert data_2023[11]["balance"] == -50.0
|
||||||
|
|
||||||
|
data_2024 = acc.monthly_balance(2024)
|
||||||
|
assert data_2024[1]["balance"] == -50.0 # month 2: prior year's expense folded into start
|
||||||
|
assert data_2024[2]["balance"] == -20.0 # month 3: 2024 income applied
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_balance_current_month_patched_with_live_balance(user, category):
|
||||||
|
today = date.today()
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("100"))
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("30"), date=today)
|
||||||
|
|
||||||
|
data = acc.monthly_balance(today.year)
|
||||||
|
|
||||||
|
assert data[today.month - 1]["balance"] == float(acc.current_balance())
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_net_returns_income_minus_expense_per_month(user, category):
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||||
|
|
||||||
|
Income.objects.create(owner=user, account=acc, name="Nomina", amount=Decimal("100"), date=date(2024, 4, 5))
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("40"), date=date(2024, 4, 20))
|
||||||
|
|
||||||
|
data = acc.monthly_net(2024)
|
||||||
|
|
||||||
|
assert len(data) == 12
|
||||||
|
assert data[3]["net"] == 60.0 # month 4
|
||||||
|
assert data[0]["net"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_balance_until_only_counts_entries_on_or_before_date(user, category):
|
||||||
|
acc = Account.objects.create(owner=user, name="Cuenta", initial_balance=Decimal("0"))
|
||||||
|
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("20"), date=date(2024, 1, 10))
|
||||||
|
Expense.objects.create(owner=user, account=acc, category=category, amount=Decimal("5"), date=date(2024, 1, 15))
|
||||||
|
Income.objects.create(owner=user, account=acc, name="Later", amount=Decimal("50"), date=date(2024, 1, 20))
|
||||||
|
|
||||||
|
assert acc.balance_until(date(2024, 1, 15)) == Decimal("-25")
|
||||||
101
expenses_manager/expenses/tests/test_categories.py
Normal file
101
expenses_manager/expenses/tests/test_categories.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.contrib.messages import get_messages
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from expenses.models import Category, Expense
|
||||||
|
from expenses.forms import CategoryForm
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_auto_generated_from_name_on_save(user):
|
||||||
|
cat = Category.objects.create(name="Ropa y Calzado", owner=user)
|
||||||
|
|
||||||
|
assert cat.slug == slugify("Ropa y Calzado")
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_not_overwritten_if_explicitly_set(user):
|
||||||
|
cat = Category(name="Comida", slug="mi-slug-personalizado", owner=user)
|
||||||
|
cat.save()
|
||||||
|
|
||||||
|
assert cat.slug == "mi-slug-personalizado"
|
||||||
|
|
||||||
|
|
||||||
|
def test_descendant_ids_includes_grandchildren(user):
|
||||||
|
parent = Category.objects.create(name="A", owner=user)
|
||||||
|
child = Category.objects.create(name="B", owner=user, parent=parent)
|
||||||
|
grandchild = Category.objects.create(name="C", owner=user, parent=child)
|
||||||
|
|
||||||
|
ids = set(parent.descendant_ids())
|
||||||
|
|
||||||
|
assert ids == {parent.pk, child.pk, grandchild.pk}
|
||||||
|
|
||||||
|
|
||||||
|
def test_descendant_ids_can_exclude_self(user):
|
||||||
|
parent = Category.objects.create(name="A", owner=user)
|
||||||
|
child = Category.objects.create(name="B", owner=user, parent=parent)
|
||||||
|
|
||||||
|
ids = parent.descendant_ids(include_self=False)
|
||||||
|
|
||||||
|
assert parent.pk not in ids
|
||||||
|
assert child.pk in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_categoryform_parent_persists_on_save(auth_client, user):
|
||||||
|
other = Category.objects.create(name="Otra", owner=user)
|
||||||
|
|
||||||
|
auth_client.post(reverse('category_list'), {'name': 'Nueva', 'parent': str(other.pk)})
|
||||||
|
|
||||||
|
created = Category.objects.get(name='Nueva', owner=user)
|
||||||
|
assert created.parent_id == other.pk
|
||||||
|
|
||||||
|
|
||||||
|
def test_categoryform_excludes_self_and_descendants_from_parent_queryset_when_editing(user):
|
||||||
|
parent = Category.objects.create(name="P", owner=user)
|
||||||
|
child = Category.objects.create(name="C", owner=user, parent=parent)
|
||||||
|
|
||||||
|
form = CategoryForm(instance=parent, user=user)
|
||||||
|
queryset_ids = set(form.fields['parent'].queryset.values_list('pk', flat=True))
|
||||||
|
|
||||||
|
assert parent.pk not in queryset_ids
|
||||||
|
assert child.pk not in queryset_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_categoryform_clean_parent_rejects_self_as_parent(user):
|
||||||
|
cat = Category.objects.create(name="X", owner=user)
|
||||||
|
|
||||||
|
form = CategoryForm(data={'name': 'X', 'parent': str(cat.pk)}, instance=cat, user=user)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
assert 'parent' in form.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_categoryform_rejects_descendant_as_parent(user):
|
||||||
|
parent = Category.objects.create(name="P", owner=user)
|
||||||
|
child = Category.objects.create(name="C", owner=user, parent=parent)
|
||||||
|
|
||||||
|
form = CategoryForm(data={'name': 'P', 'parent': str(child.pk)}, instance=parent, user=user)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_delete_with_expenses_is_protected(auth_client, user, account, category):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||||
|
|
||||||
|
response = auth_client.post(reverse('category_delete', args=[category.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert Category.objects.filter(pk=category.pk).exists()
|
||||||
|
messages = [str(m) for m in get_messages(response.wsgi_request)]
|
||||||
|
assert any("gastos asociados" in m for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_delete_without_expenses_succeeds(auth_client, user):
|
||||||
|
cat = Category.objects.create(name="Vacia", owner=user)
|
||||||
|
|
||||||
|
response = auth_client.post(reverse('category_delete', args=[cat.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert not Category.objects.filter(pk=cat.pk).exists()
|
||||||
@ -2,7 +2,7 @@ import pytest
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from expenses.models import Account, Expense, Category
|
from expenses.models import Expense, Category
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@ -10,96 +10,73 @@ def test_dashboard_requires_login(client):
|
|||||||
url = reverse('dashboard')
|
url = reverse('dashboard')
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
def test_dashboard_logged_user_can_access(client, django_user_model):
|
def test_dashboard_logged_user_can_access(auth_client):
|
||||||
user = django_user_model.objects.create_user(
|
|
||||||
username='test',
|
|
||||||
password='1234'
|
|
||||||
)
|
|
||||||
client.login(username='test', password='1234')
|
|
||||||
|
|
||||||
url = reverse('dashboard')
|
url = reverse('dashboard')
|
||||||
response = client.get(url)
|
response = auth_client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_dashboard_groups_by_category(client, django_user_model):
|
def test_dashboard_groups_by_category(auth_client, user, account):
|
||||||
user = django_user_model.objects.create_user(
|
|
||||||
username='test',
|
|
||||||
password='1234'
|
|
||||||
)
|
|
||||||
client.login(username='test', password='1234')
|
|
||||||
|
|
||||||
food = Category.objects.create(name='Food', owner=user)
|
food = Category.objects.create(name='Food', owner=user)
|
||||||
rent = Category.objects.create(name='Rent', owner=user)
|
rent = Category.objects.create(name='Rent', owner=user)
|
||||||
|
|
||||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
|
|
||||||
|
|
||||||
Expense.objects.create(
|
Expense.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
category=food,
|
category=food,
|
||||||
amount=Decimal('10'),
|
amount=Decimal('10'),
|
||||||
date=date(2024, 1, 1),
|
date=date(2024, 1, 1),
|
||||||
account=general_account,
|
account=account,
|
||||||
)
|
)
|
||||||
Expense.objects.create(
|
Expense.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
category=food,
|
category=food,
|
||||||
amount=Decimal('5'),
|
amount=Decimal('5'),
|
||||||
date=date(2024, 1, 2),
|
date=date(2024, 1, 2),
|
||||||
account=general_account,
|
account=account,
|
||||||
)
|
)
|
||||||
Expense.objects.create(
|
Expense.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
category=rent,
|
category=rent,
|
||||||
amount=Decimal('20'),
|
amount=Decimal('20'),
|
||||||
date=date(2024, 1, 3),
|
date=date(2024, 1, 3),
|
||||||
account=general_account,
|
account=account,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(
|
response = auth_client.get(
|
||||||
reverse('dashboard'),
|
reverse('dashboard'),
|
||||||
{'year': 2024}
|
{'year': 2024}
|
||||||
)
|
)
|
||||||
|
|
||||||
data = list(response.context['by_category'])
|
data = list(response.context['by_category'])
|
||||||
|
|
||||||
assert {'category__name': 'Food', 'total': Decimal('15')} in data
|
assert {'category__name': 'Food', 'total': Decimal('15')} in data
|
||||||
assert {'category__name': 'Rent', 'total': Decimal('20')} in data
|
assert {'category__name': 'Rent', 'total': Decimal('20')} in data
|
||||||
|
|
||||||
def test_dashboard_filters_by_year(client, django_user_model):
|
def test_dashboard_filters_by_year(auth_client, user, account, category):
|
||||||
user = django_user_model.objects.create_user(
|
|
||||||
username='test',
|
|
||||||
password='1234'
|
|
||||||
)
|
|
||||||
client.login(username='test', password='1234')
|
|
||||||
|
|
||||||
cat = Category.objects.create(name='General', owner=user)
|
|
||||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
|
|
||||||
|
|
||||||
Expense.objects.create(
|
Expense.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
category=cat,
|
category=category,
|
||||||
amount=10,
|
amount=10,
|
||||||
date=date(2023, 5, 1),
|
date=date(2023, 5, 1),
|
||||||
account=general_account,
|
account=account,
|
||||||
)
|
)
|
||||||
|
|
||||||
Expense.objects.create(
|
Expense.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
category=cat,
|
category=category,
|
||||||
amount=20,
|
amount=20,
|
||||||
date=date(2024, 5, 1),
|
date=date(2024, 5, 1),
|
||||||
account=general_account,
|
account=account,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(
|
response = auth_client.get(
|
||||||
reverse('dashboard'),
|
reverse('dashboard'),
|
||||||
{'year': 2024},
|
{'year': 2024},
|
||||||
)
|
)
|
||||||
|
|
||||||
by_month = list(response.context['by_month'])
|
chart_data = response.context['chart_data']
|
||||||
|
|
||||||
totals = [row['total'] for row in by_month]
|
assert len(chart_data) == 12
|
||||||
assert Decimal('20') in totals
|
assert chart_data[4] == 20.0 # May = month 5 -> index 4
|
||||||
assert Decimal('10') not in totals
|
assert sum(chart_data) == 20.0 # only the 2024 expense contributes
|
||||||
|
|||||||
61
expenses_manager/expenses/tests/test_deletes.py
Normal file
61
expenses_manager/expenses/tests/test_deletes.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from expenses.models import Goal, Tag, Income
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_delete_get_does_not_delete(auth_client, user):
|
||||||
|
goal = Goal.objects.create(owner=user, name="Meta", target_amount=Decimal("100"))
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('goal_delete', args=[goal.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Goal.objects.filter(pk=goal.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_delete_post_deletes(auth_client, user):
|
||||||
|
goal = Goal.objects.create(owner=user, name="Meta", target_amount=Decimal("100"))
|
||||||
|
|
||||||
|
response = auth_client.post(reverse('goal_delete', args=[goal.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert not Goal.objects.filter(pk=goal.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_delete_get_does_not_delete(auth_client, user):
|
||||||
|
tag = Tag.objects.create(name="Food", owner=user)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('tag_delete', args=[tag.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Tag.objects.filter(pk=tag.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_delete_post_deletes(auth_client, user):
|
||||||
|
tag = Tag.objects.create(name="Food", owner=user)
|
||||||
|
|
||||||
|
response = auth_client.post(reverse('tag_delete', args=[tag.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert not Tag.objects.filter(pk=tag.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_delete_get_does_not_delete(auth_client, user, account):
|
||||||
|
income = Income.objects.create(owner=user, account=account, name="Nomina", amount=Decimal("500"), date=date.today())
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('income_delete', args=[income.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Income.objects.filter(pk=income.pk).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_income_delete_post_deletes(auth_client, user, account):
|
||||||
|
income = Income.objects.create(owner=user, account=account, name="Nomina", amount=Decimal("500"), date=date.today())
|
||||||
|
|
||||||
|
response = auth_client.post(reverse('income_delete', args=[income.pk]))
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert not Income.objects.filter(pk=income.pk).exists()
|
||||||
42
expenses_manager/expenses/tests/test_expense_list.py
Normal file
42
expenses_manager/expenses/tests/test_expense_list.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from expenses.models import Expense, Tag
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_filter_with_non_numeric_value_returns_200(auth_client, user, account, category):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('expense_list'), {'tag': 'abc'})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.context['page_obj']) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_filter_with_valid_tag_returns_only_matching_expenses(auth_client, user, account, category):
|
||||||
|
tag = Tag.objects.create(name="Food", owner=user)
|
||||||
|
tagged = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||||
|
tagged.tags.add(tag)
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date.today())
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('expense_list'), {'tag': str(tag.pk)})
|
||||||
|
|
||||||
|
results = list(response.context['page_obj'])
|
||||||
|
assert results == [tagged]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_filter_multiple_tags_are_ORed(auth_client, user, account, category):
|
||||||
|
tag1 = Tag.objects.create(name="Food", owner=user)
|
||||||
|
tag2 = Tag.objects.create(name="Transport", owner=user)
|
||||||
|
expense1 = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date.today())
|
||||||
|
expense1.tags.add(tag1)
|
||||||
|
expense2 = Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date.today())
|
||||||
|
expense2.tags.add(tag2)
|
||||||
|
|
||||||
|
response = auth_client.get(reverse('expense_list'), {'tag': [str(tag1.pk), str(tag2.pk)]})
|
||||||
|
|
||||||
|
results = set(response.context['page_obj'])
|
||||||
|
assert results == {expense1, expense2}
|
||||||
@ -1,41 +1,29 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from expenses.models import Expense, Category, Tag, Account
|
from expenses.models import Expense, Tag
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
def test_expense_can_have_tags(client, django_user_model):
|
def test_expense_can_have_tags(user, category, account):
|
||||||
user = django_user_model.objects.create_user(username='test', password='1234')
|
|
||||||
client.login(username='test', password='1234')
|
|
||||||
|
|
||||||
tag = Tag.objects.create(name='Food', owner=user)
|
tag = Tag.objects.create(name='Food', owner=user)
|
||||||
category = Category.objects.create(name='General', owner=user)
|
|
||||||
|
|
||||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=300, active=True)
|
|
||||||
|
|
||||||
expense = Expense.objects.create(
|
expense = Expense.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
category=category,
|
category=category,
|
||||||
amount=10,
|
amount=10,
|
||||||
date=date.today(),
|
date=date.today(),
|
||||||
account=general_account,
|
account=account,
|
||||||
)
|
)
|
||||||
expense.tags.add(tag)
|
expense.tags.add(tag)
|
||||||
|
|
||||||
assert expense.tags.count() == 1
|
assert expense.tags.count() == 1
|
||||||
assert tag in expense.tags.all()
|
assert tag in expense.tags.all()
|
||||||
|
|
||||||
def test_user_can_create_tag(client, django_user_model):
|
def test_user_can_create_tag(auth_client, user):
|
||||||
user = django_user_model.objects.create_user(
|
response = auth_client.post(
|
||||||
username='test', password='1234'
|
|
||||||
)
|
|
||||||
client.login(username='test', password='1234')
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
reverse('tag_create'),
|
reverse('tag_create'),
|
||||||
{'name': 'Food'}
|
{'name': 'Food'}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert Tag.objects.filter(owner=user, name='Food').exists()
|
assert Tag.objects.filter(owner=user, name='Food').exists()
|
||||||
|
|||||||
69
expenses_manager/expenses/tests/test_fuel.py
Normal file
69
expenses_manager/expenses/tests/test_fuel.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.urls import reverse
|
||||||
|
from expenses.models import Expense, FuelEntry
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fuel_entry(user, account, category, odometer, liters, amount=Decimal("50")):
|
||||||
|
expense = Expense.objects.create(
|
||||||
|
owner=user, account=account, category=category, amount=amount, date=date.today()
|
||||||
|
)
|
||||||
|
return FuelEntry.objects.create(expense=expense, odometer=odometer, liters=liters)
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_per_liter_divides_amount_by_liters(user, account, category):
|
||||||
|
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("25"), amount=Decimal("50"))
|
||||||
|
|
||||||
|
assert entry.price_per_liter() == Decimal("2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_per_liter_zero_when_no_liters(user, account, category):
|
||||||
|
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("0"))
|
||||||
|
|
||||||
|
assert entry.price_per_liter() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_km_since_previous_none_on_first_fillup(user, account, category):
|
||||||
|
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||||
|
|
||||||
|
assert entry.km_since_previous() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_km_since_previous_computes_delta_from_prior_entry(user, account, category):
|
||||||
|
_make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||||
|
second = _make_fuel_entry(user, account, category, odometer=1300, liters=Decimal("24"))
|
||||||
|
|
||||||
|
assert second.km_since_previous() == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumption_none_when_no_previous_entry(user, account, category):
|
||||||
|
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||||
|
|
||||||
|
assert entry.consumption() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumption_computes_liters_per_100km(user, account, category):
|
||||||
|
_make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||||
|
second = _make_fuel_entry(user, account, category, odometer=1300, liters=Decimal("24"))
|
||||||
|
|
||||||
|
assert second.consumption() == Decimal("8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuel_create_invalid_post_returns_200_not_500(auth_client):
|
||||||
|
response = auth_client.post(reverse('fuel_create'), {})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert FuelEntry.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuel_delete_post_cascades_to_fuelentry(auth_client, user, account, category):
|
||||||
|
entry = _make_fuel_entry(user, account, category, odometer=1000, liters=Decimal("20"))
|
||||||
|
expense = entry.expense
|
||||||
|
|
||||||
|
response = auth_client.post(reverse('fuel_delete', args=[expense.pk]))
|
||||||
|
|
||||||
|
assert not Expense.objects.filter(pk=expense.pk).exists()
|
||||||
|
assert not FuelEntry.objects.filter(pk=entry.pk).exists()
|
||||||
196
expenses_manager/expenses/tests/test_goals.py
Normal file
196
expenses_manager/expenses/tests/test_goals.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from expenses.models import Account, Category, Expense, Goal
|
||||||
|
from expenses.forms import GoalForm
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def _last_month_date():
|
||||||
|
today = date.today()
|
||||||
|
if today.month == 1:
|
||||||
|
return date(today.year - 1, 12, 15)
|
||||||
|
return date(today.year, today.month - 1, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_goal_sums_expenses_since_start_date(user, account, category):
|
||||||
|
start = date(2024, 6, 1)
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("10"), date=date(2024, 5, 1))
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("20"), date=date(2024, 6, 15))
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||||
|
include_subcategories=False, start_date=start, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress() == Decimal("20")
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_goal_include_subcategories_true_includes_child_expenses(user, account, category):
|
||||||
|
child = Category.objects.create(owner=user, name="Sub", parent=category)
|
||||||
|
Expense.objects.create(owner=user, account=account, category=child, amount=Decimal("15"), date=date.today())
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||||
|
include_subcategories=True, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress() == Decimal("15")
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_goal_include_subcategories_false_excludes_child_expenses(user, account, category):
|
||||||
|
child = Category.objects.create(owner=user, name="Sub", parent=category)
|
||||||
|
Expense.objects.create(owner=user, account=account, category=child, amount=Decimal("15"), date=date.today())
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category,
|
||||||
|
include_subcategories=False, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress() == Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_goal_period_month_counts_only_current_month(user, account, category):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("30"), date=date.today())
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("999"), date=_last_month_date())
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||||
|
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress() == Decimal("30")
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_goal_is_exceeded_true_over_target(user, account, category):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("150"), date=date.today())
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||||
|
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.is_exceeded() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_goal_is_exceeded_false_under_target(user, account, category):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("50"), date=date.today())
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category,
|
||||||
|
period=Goal.PERIOD_MONTH, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.is_exceeded() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_exceeded_always_false_for_non_budget_kind(user, account, category):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=Decimal("50"), date=date.today())
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category, target_amount=Decimal("10"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress() > goal.target_amount
|
||||||
|
assert goal.is_exceeded() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_goal_progress_equals_account_balance(user):
|
||||||
|
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("500"))
|
||||||
|
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("1000"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress() == acc.current_balance()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bar_width_caps_at_100_above_target(user):
|
||||||
|
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("250"))
|
||||||
|
goal = Goal.objects.create(owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("100"))
|
||||||
|
|
||||||
|
assert goal.percentage() == Decimal("250")
|
||||||
|
assert goal.bar_width() == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bar_width_uncapped_below_100(user):
|
||||||
|
acc = Account.objects.create(owner=user, name="Ahorros", initial_balance=Decimal("40"))
|
||||||
|
goal = Goal.objects.create(owner=user, name="Ahorro", kind=Goal.KIND_SAVING, account=acc, target_amount=Decimal("100"))
|
||||||
|
|
||||||
|
assert goal.bar_width() == 40.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("amount,expected_state", [
|
||||||
|
(Decimal("50"), "ok"),
|
||||||
|
(Decimal("90"), "warning"),
|
||||||
|
(Decimal("150"), "danger"),
|
||||||
|
])
|
||||||
|
def test_progress_state_budget_thresholds(user, account, category, amount, expected_state):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=amount, date=date.today())
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Presupuesto", kind=Goal.KIND_BUDGET, category=category, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress_state() == expected_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("amount,expected_state", [
|
||||||
|
(Decimal("50"), "ok"),
|
||||||
|
(Decimal("100"), "complete"),
|
||||||
|
(Decimal("150"), "complete"),
|
||||||
|
])
|
||||||
|
def test_progress_state_non_budget_thresholds(user, account, category, amount, expected_state):
|
||||||
|
Expense.objects.create(owner=user, account=account, category=category, amount=amount, date=date.today())
|
||||||
|
goal = Goal.objects.create(
|
||||||
|
owner=user, name="Pago", kind=Goal.KIND_PAYMENT, category=category, target_amount=Decimal("100"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert goal.progress_state() == expected_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_goalform_budget_requires_period(user, category):
|
||||||
|
data = {"name": "Presupuesto", "kind": Goal.KIND_BUDGET, "target_amount": "100", "category": str(category.pk), "period": Goal.PERIOD_NONE}
|
||||||
|
form = GoalForm(data=data, user=user)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
assert "period" in form.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_goalform_saving_requires_account(user):
|
||||||
|
data = {"name": "Ahorro", "kind": Goal.KIND_SAVING, "target_amount": "500", "period": Goal.PERIOD_NONE}
|
||||||
|
form = GoalForm(data=data, user=user)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
assert "account" in form.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_goalform_payment_requires_category(user):
|
||||||
|
data = {"name": "Pago", "kind": Goal.KIND_PAYMENT, "target_amount": "50", "period": Goal.PERIOD_NONE}
|
||||||
|
form = GoalForm(data=data, user=user)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
assert "category" in form.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_goalform_budget_requires_category(user):
|
||||||
|
data = {"name": "Presupuesto", "kind": Goal.KIND_BUDGET, "target_amount": "50", "period": Goal.PERIOD_MONTH}
|
||||||
|
form = GoalForm(data=data, user=user)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
assert "category" in form.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_goalform_valid_payment_saves_goal(user, category):
|
||||||
|
data = {
|
||||||
|
"name": "Ahorro viaje", "kind": Goal.KIND_PAYMENT, "target_amount": "200",
|
||||||
|
"category": str(category.pk), "period": Goal.PERIOD_NONE,
|
||||||
|
}
|
||||||
|
form = GoalForm(data=data, user=user)
|
||||||
|
|
||||||
|
assert form.is_valid(), form.errors
|
||||||
|
goal = form.save(commit=False)
|
||||||
|
goal.owner = user
|
||||||
|
goal.save()
|
||||||
|
|
||||||
|
assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists()
|
||||||
@ -1,20 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
|
||||||
from django.urls import reverse
|
|
||||||
from expenses.models import Income, Account
|
from expenses.models import Income, Account
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
def test_income_increases_account_balance(client, django_user_model):
|
def test_income_increases_account_balance(user):
|
||||||
user = django_user_model.objects.create_user(
|
|
||||||
username='test',
|
|
||||||
password='1234'
|
|
||||||
)
|
|
||||||
client.login(username='test', password='1234')
|
|
||||||
|
|
||||||
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
|
general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True)
|
||||||
|
|
||||||
Income.objects.create(
|
Income.objects.create(
|
||||||
owner=user,
|
owner=user,
|
||||||
account=general_account,
|
account=general_account,
|
||||||
@ -22,5 +14,5 @@ def test_income_increases_account_balance(client, django_user_model):
|
|||||||
name='Nomina enero',
|
name='Nomina enero',
|
||||||
date=date.today(),
|
date=date.today(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert general_account.current_balance() == 1500
|
assert general_account.current_balance() == 1500
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user