expenses_manager/expenses_manager/expenses/tests/test_dashboard.py

667 lines
23 KiB
Python

import pytest
from datetime import date, timedelta
from decimal import Decimal
from django.db import connection
from django.test.utils import CaptureQueriesContext
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
# --------------------------------------------------------------------------
# Desglose por tipo de gasto
# --------------------------------------------------------------------------
def _breakdown(response):
return {row['label']: row['total'] for row in response.context['expense_type_breakdown']}
def test_expense_type_breakdown_groups_by_effective_type(auth_client, user, account):
home = Category.objects.create(
name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
power = Category.objects.create(name='Luz', owner=user, parent=home)
fun = Category.objects.create(
name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY
)
loose = Category.objects.create(name='Varios', owner=user)
for category, amount in [(home, '500'), (power, '60'), (fun, '40'), (loose, '7')]:
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal(amount), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
# La luz hereda "Fijo esencial" de Casa; Varios no tiene tipo en ninguna
# parte de la cadena y va a "Sin clasificar" en vez de desaparecer.
assert _breakdown(response) == {
'Fijo esencial': Decimal('560'),
'Fijo prescindible': Decimal('0'),
'Variable esencial': Decimal('0'),
'Variable prescindible': Decimal('40'),
'Sin clasificar': Decimal('7'),
}
assert sum(_breakdown(response).values()) == response.context['kpi_total']
assert 'id="expenseTypeChart"' in response.content.decode()
def test_expense_type_breakdown_follows_the_selected_period(auth_client, user, account, category):
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal('10'), date=date(2024, 3, 1),
)
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal('99'), date=date(2024, 4, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3})
assert _breakdown(response)['Sin clasificar'] == Decimal('10')
def test_expense_type_breakdown_ignores_transfers(auth_client, user, 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})
# Solo la comisión es gasto de verdad.
assert sum(_breakdown(response).values()) == Decimal('1.50')
def _classify_two_categories(user):
Category.objects.create(
name='Alquiler', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
Category.objects.create(
name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY
)
def test_expense_type_breakdown_shows_empty_state_without_expenses(auth_client, user):
_classify_two_categories(user)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert sum(_breakdown(response).values()) == 0
content = response.content.decode()
assert 'id="expenseTypeChart"' not in content
assert 'No hay gastos en este periodo' in content
assert 'Clasifica al menos dos categorías' not in content
def test_expense_type_breakdown_asks_to_classify_with_fewer_than_two(
auth_client, user, account
):
# Una sola categoría clasificada; la hija hereda pero no cuenta como
# clasificada, porque el usuario no la ha clasificado él.
parent = Category.objects.create(
name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
child = Category.objects.create(name='Luz', owner=user, parent=parent)
Expense.objects.create(
owner=user, account=account, category=child,
amount=Decimal('60'), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['expense_type_classified_count'] == 1
content = response.content.decode()
assert 'Clasifica al menos dos categorías' in content
assert 'href="%s"' % reverse('category_list') in content
assert 'id="expenseTypeChart"' not in content
assert 'id="expenseTypeGrouping"' not in content
assert 'No hay gastos en este periodo' not in content
@pytest.mark.parametrize('classified, asks_to_classify', [(1, True), (2, False)])
def test_expense_type_breakdown_classification_threshold(
auth_client, user, account, classified, asks_to_classify
):
types = [Category.EXPENSE_TYPE_FIXED_ESSENTIAL, Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL]
for i in range(classified):
category = Category.objects.create(name=f'Clasificada {i}', owner=user, expense_type=types[i])
Expense.objects.create(
owner=user, account=account, category=category,
amount=Decimal('10'), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
assert response.context['expense_type_classified_count'] == classified
content = response.content.decode()
assert ('Clasifica al menos dos categorías' in content) is asks_to_classify
assert ('id="expenseTypeChart"' in content) is not asks_to_classify
def test_expense_type_breakdown_renders_grouping_and_shape_controls(
auth_client, user, account
):
_classify_two_categories(user)
Expense.objects.create(
owner=user, account=account, category=Category.objects.get(name='Ocio'),
amount=Decimal('40'), date=date(2024, 3, 1),
)
response = auth_client.get(reverse('dashboard'), {'year': 2024})
content = response.content.decode()
assert 'id="expenseTypeGrouping"' in content
assert 'role="group" aria-label="Forma del gráfico"' in content
assert content.count('data-shape=') == 3
assert content.count('aria-pressed="true"') == 1
def _dashboard_queries(client):
with CaptureQueriesContext(connection) as ctx:
client.get(reverse('dashboard'), {'year': 2024})
return len(ctx.captured_queries)
def test_dashboard_query_count_does_not_grow_with_categories_and_expenses(
auth_client, user, account
):
root = Category.objects.create(
name='Raíz', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL
)
Expense.objects.create(
owner=user, account=account, category=root,
amount=Decimal('1'), date=date(2024, 3, 1),
)
baseline = _dashboard_queries(auth_client)
# Más categorías anidadas y más gastos que las 7 filas de recent_expenses:
# ni la herencia del desglose ni el listado reciente deben pedir una
# consulta por fila.
parent = root
for i in range(10):
parent = Category.objects.create(name=f'Nivel {i}', owner=user, parent=parent)
Expense.objects.create(
owner=user, account=account, category=parent,
amount=Decimal('1'), date=date(2024, 3, 1),
)
assert _dashboard_queries(auth_client) == baseline