expenses_manager/expenses_manager/expenses/tests/test_dashboard.py

484 lines
16 KiB
Python

import pytest
from datetime import date, timedelta
from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Expense, Category, Income, Transfer
pytestmark = pytest.mark.django_db
def test_dashboard_requires_login(client):
url = reverse('dashboard')
response = client.get(url)
assert response.status_code == 302
def test_dashboard_logged_user_can_access(auth_client):
url = reverse('dashboard')
response = auth_client.get(url)
assert response.status_code == 200
def test_dashboard_groups_by_category(auth_client, user, account):
food = Category.objects.create(name='Food', owner=user)
rent = Category.objects.create(name='Rent', owner=user)
Expense.objects.create(
owner=user,
category=food,
amount=Decimal('10'),
date=date(2024, 1, 1),
account=account,
)
Expense.objects.create(
owner=user,
category=food,
amount=Decimal('5'),
date=date(2024, 1, 2),
account=account,
)
Expense.objects.create(
owner=user,
category=rent,
amount=Decimal('20'),
date=date(2024, 1, 3),
account=account,
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024}
)
data = list(response.context['by_category'])
assert {'category__name': 'Food', 'total': Decimal('15')} in data
assert {'category__name': 'Rent', 'total': Decimal('20')} in data
def test_dashboard_filters_by_year(auth_client, user, account, category):
Expense.objects.create(
owner=user,
category=category,
amount=10,
date=date(2023, 5, 1),
account=account,
)
Expense.objects.create(
owner=user,
category=category,
amount=20,
date=date(2024, 5, 1),
account=account,
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024},
)
chart_data = response.context['chart_data']
assert len(chart_data) == 12
assert chart_data[4] == 20.0 # May = month 5 -> index 4
assert sum(chart_data) == 20.0 # only the 2024 expense contributes
def test_dashboard_preset_this_month(auth_client):
today = date.today()
response = auth_client.get(reverse('dashboard'), {'period': 'this_month'})
assert response.context['selected_year'] == today.year
assert response.context['selected_month'] == today.month
def test_dashboard_preset_last_month(auth_client):
today = date.today()
expected_month = today.month - 1 or 12
expected_year = today.year if today.month > 1 else today.year - 1
response = auth_client.get(reverse('dashboard'), {'period': 'last_month'})
assert response.context['selected_year'] == expected_year
assert response.context['selected_month'] == expected_month
def test_dashboard_preset_this_year_has_no_month(auth_client):
today = date.today()
response = auth_client.get(reverse('dashboard'), {'period': 'this_year'})
assert response.context['selected_year'] == today.year
assert response.context['selected_month'] is None
def test_dashboard_filters_by_account(auth_client, user, account, category):
other = Account.objects.create(
owner=user, name="Otra", initial_balance=Decimal("100"), active=True
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("10"), date=date(2024, 3, 1),
)
Expense.objects.create(
owner=user, account=other, category=category,
amount=Decimal("25"), date=date(2024, 3, 1),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'account': other.id},
)
assert response.context['kpi_total'] == Decimal('25')
assert response.context['kpi_count'] == 1
assert response.context['kpi_balance'] == Decimal('75') # 100 - 25
def test_dashboard_without_compare_keeps_comparison_defaults(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['compare_enabled'] is False
assert response.context['kpi_previous_total'] == 0
assert response.context['kpi_difference'] == 0
assert response.context['kpi_percentage'] == 0
assert response.context['kpi_trend'] == 'equal'
assert response.context['category_comparison'] == []
def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account, category):
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("20"), date=date(2024, 2, 15),
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("30"), date=date(2024, 3, 10),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'month': 3, 'compare': '1'},
)
assert response.context['compare_enabled'] is True
assert response.context['kpi_previous_total'] == Decimal('20')
assert response.context['kpi_difference'] == Decimal('10')
assert response.context['kpi_trend'] == 'up'
assert float(response.context['kpi_percentage']) == 50.0
rows = {row['category']: row for row in response.context['category_comparison']}
assert rows[category.name]['current'] == 30.0
assert rows[category.name]['previous'] == 20.0
assert rows[category.name]['difference'] == 10.0
def test_dashboard_comparison_is_ordered_by_absolute_difference(
auth_client, user, account
):
# Las categorías se juntan con un set, y el orden de un set no es estable
# entre ejecuciones: sin ordenar, la tabla sale distinta en cada recarga.
moved_little = Category.objects.create(owner=user, name="Poco")
moved_a_lot = Category.objects.create(owner=user, name="Mucho")
for cat, previous, current in (
(moved_little, "10", "15"),
(moved_a_lot, "10", "90"),
):
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal(previous), date=date(2024, 2, 15),
)
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal(current), date=date(2024, 3, 10),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'month': 3, 'compare': '1'},
)
order = [row['category'] for row in response.context['category_comparison']]
assert order == ["Mucho", "Poco"]
def test_dashboard_comparison_breaks_ties_by_category_name(
auth_client, user, account
):
# Misma diferencia en las tres: sin desempate explícito por nombre
# seguirían bailando entre recargas aunque el orden principal sea estable.
for name in ("Zeta", "Alfa", "Media"):
cat = Category.objects.create(owner=user, name=name)
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal("10"), date=date(2024, 2, 15),
)
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal("20"), date=date(2024, 3, 10),
)
response = auth_client.get(
reverse('dashboard'),
{'year': 2024, 'month': 3, 'compare': '1'},
)
order = [row['category'] for row in response.context['category_comparison']]
assert order == ["Alfa", "Media", "Zeta"]
# --------------------------------------------------------------------------
# Serie de ingresos contra gastos
# --------------------------------------------------------------------------
def _make_transfer(user, from_account, to_account, amount="400", on=None, fee=None):
transfer = Transfer(
owner=user,
date=on or date(2024, 3, 10),
amount=Decimal(amount),
from_account=from_account,
to_account=to_account,
fee_amount=Decimal(fee) if fee is not None else None,
)
transfer.sync()
return transfer
def test_dashboard_sends_both_series_on_the_same_axis(
auth_client, user, account, category
):
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("30"), date=date(2024, 5, 10),
)
Income.objects.create(
owner=user, account=account, name="Nómina",
amount=Decimal("1500"), date=date(2024, 3, 10),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
expenses = response.context['chart_data']
incomes = response.context['chart_income_data']
# Mismo eje para las dos: si cada una montara el suyo, se desalinearían.
assert len(incomes) == len(response.context['chart_labels']) == len(expenses)
assert incomes[2] == 1500.0 # marzo
assert expenses[4] == 30.0 # mayo
assert incomes[4] == 0 # mayo no tuvo ingresos: cero, no hueco
def test_dashboard_income_series_is_zero_filled_without_movements(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['chart_income_data'] == [0] * 12
def test_dashboard_income_series_is_zero_filled_in_a_month_without_movements(
auth_client
):
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
assert response.context['chart_income_data'] == [0] * 31
def test_dashboard_both_series_ignore_a_transfer(user, auth_client, account):
other = Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
)
_make_transfer(user, account, other, amount="400")
response = auth_client.get(reverse('dashboard'), {'year': 2024})
# Mandarse dinero a una cuenta propia no es ingreso ni gasto: si se
# excluyera solo de los gastos, el gráfico daría un pico de ingresos cada
# vez que el usuario se traspasa la nómina.
assert sum(response.context['chart_income_data']) == 0
assert sum(response.context['chart_data']) == 0
def test_dashboard_counts_the_transfer_fee_as_an_expense(user, auth_client, account):
other = Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True
)
_make_transfer(user, account, other, amount="400", fee="1.50")
response = auth_client.get(reverse('dashboard'), {'year': 2024})
# La comisión va con is_transfer=False: sale del patrimonio de verdad.
assert sum(response.context['chart_data']) == 1.5
assert sum(response.context['chart_income_data']) == 0
def test_dashboard_income_series_follows_the_day_axis_in_a_short_range(
auth_client, user, account
):
Income.objects.create(
owner=user, account=account, name="Nómina",
amount=Decimal("1500"), date=date(2024, 3, 5),
)
response = auth_client.get(
reverse('dashboard'),
{'date_from': '2024-03-01', 'date_to': '2024-03-30'}, # 30 días
)
assert response.context['chart_type'] == 'day'
incomes = response.context['chart_income_data']
assert len(incomes) == 30
assert incomes[4] == 1500.0
assert incomes[0] == 0
def test_dashboard_income_series_follows_the_month_axis_in_a_long_range(
auth_client, user, account
):
Income.objects.create(
owner=user, account=account, name="Nómina",
amount=Decimal("1500"), date=date(2024, 2, 5),
)
response = auth_client.get(
reverse('dashboard'),
{'date_from': '2024-01-01', 'date_to': '2024-04-29'}, # 120 días
)
assert response.context['chart_type'] == 'month'
incomes = response.context['chart_income_data']
assert len(incomes) == 4
assert incomes[1] == 1500.0
def test_dashboard_income_series_matches_the_62_day_threshold(
auth_client, user, account
):
start = date(2024, 3, 1)
at_limit = auth_client.get(
reverse('dashboard'),
{'date_from': start.isoformat(), 'date_to': (start + timedelta(days=61)).isoformat()},
)
over_limit = auth_client.get(
reverse('dashboard'),
{'date_from': start.isoformat(), 'date_to': (start + timedelta(days=62)).isoformat()},
)
# La serie de ingresos usa el mismo umbral que la de gastos, no el suyo.
assert at_limit.context['chart_type'] == 'day'
assert len(at_limit.context['chart_income_data']) == 62
assert over_limit.context['chart_type'] == 'month'
assert len(over_limit.context['chart_income_data']) == 3 # mar, abr, may
def test_dashboard_open_range_axis_covers_older_incomes(auth_client, user, account, category):
Income.objects.create(
owner=user, account=account, name="Nómina",
amount=Decimal("1500"), date=date(2024, 3, 1),
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("30"), date=date(2024, 3, 20),
)
response = auth_client.get(reverse('dashboard'), {'date_to': '2024-03-25'})
# Sin date_from el eje se acota con el movimiento más antiguo de las dos
# series: midiendo solo el gasto más viejo, ese ingreso se caería del eje.
assert response.context['chart_labels'][0] == "01/03"
assert response.context['chart_income_data'][0] == 1500.0
def test_dashboard_income_series_does_not_leak_between_users(
auth_client, user, account, django_user_model
):
intruder = django_user_model.objects.create_user(username="otro", password="x")
their_account = Account.objects.create(
owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True
)
Income.objects.create(
owner=intruder, account=their_account, name="Ajena",
amount=Decimal("999"), date=date(2024, 3, 10),
)
Income.objects.create(
owner=user, account=account, name="Mía",
amount=Decimal("100"), date=date(2024, 3, 10),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert sum(response.context['chart_income_data']) == 100.0
def test_dashboard_income_series_respects_the_account_filter(
auth_client, user, account
):
other = Account.objects.create(
owner=user, name="Otra", initial_balance=Decimal("0"), active=True
)
Income.objects.create(
owner=user, account=account, name="Aquí",
amount=Decimal("100"), date=date(2024, 3, 10),
)
Income.objects.create(
owner=user, account=other, name="Allí",
amount=Decimal("500"), date=date(2024, 3, 10),
)
response = auth_client.get(
reverse('dashboard'), {'year': 2024, 'account': account.id}
)
assert sum(response.context['chart_income_data']) == 100.0
def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
assert response.context['chart_type'] == 'day'
def test_dashboard_chart_type_is_month_for_the_whole_year(auth_client):
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['chart_type'] == 'month'
def test_dashboard_category_chart_keeps_only_top_ten(auth_client, user, account):
for i in range(12):
cat = Category.objects.create(name=f"Cat {i:02d}", owner=user)
Expense.objects.create(
owner=user, account=account, category=cat,
amount=Decimal(str(i + 1)), date=date(2024, 6, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert len(response.context['by_category_chart']) == 10
assert len(list(response.context['by_category'])) == 12
def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user, account, category):
savings = Account.objects.create(
owner=user, name="Ahorro", initial_balance=Decimal("500"), active=True
)
Account.objects.create(
owner=user, name="Cerrada", initial_balance=Decimal("999"), active=False
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal("40"), date=date(2024, 4, 1),
)
Income.objects.create(
owner=user, account=savings, name="Nomina",
amount=Decimal("60"), date=date(2024, 4, 2),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
expected = sum(
acc.current_balance()
for acc in Account.objects.filter(owner=user, active=True)
)
assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva
assert response.context['kpi_balance'] == expected