From 2c05318e0bd7325618b89360df3e827c253a916f Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:31:23 +0200 Subject: [PATCH] Anade tests de los traspasos El campo is_transfer tiene dos comportamientos opuestos y uno de ellos falla en silencio: si el filtro se cuela en un calculo de saldo, los numeros siguen apareciendo, solo que mal. Los tests se organizan en dos bloques que cubren justo esa frontera. - Analisis: el traspaso no cuenta en los KPIs de home ni del dashboard, ni en el total del listado, ni en la comparativa de periodos, ni en el progreso de los objetivos de pago y presupuesto. - Saldo: el traspaso SI cuenta en current_balance, monthly_balance, balance_until, monthly_net y en el objetivo de ahorro. Tambien se cubre que el listado de gastos siga mostrando el traspaso aunque su total lo descuente, que es la parte que un filtro puesto de mas romperia. Co-Authored-By: Claude Opus 5 --- .../expenses/tests/test_transfers.py | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_transfers.py diff --git a/expenses_manager/expenses/tests/test_transfers.py b/expenses_manager/expenses/tests/test_transfers.py new file mode 100644 index 0000000..33f42c3 --- /dev/null +++ b/expenses_manager/expenses/tests/test_transfers.py @@ -0,0 +1,242 @@ +"""Traspasos entre cuentas (is_transfer). + +Un traspaso no es gasto ni ingreso real, pero sí mueve dinero entre cuentas. +De ahí los dos bloques de este fichero, que comprueban lo contrario el uno del +otro: los análisis excluyen los traspasos, los saldos los incluyen. +""" +import pytest +from datetime import date +from decimal import Decimal +from django.urls import reverse +from expenses.models import Account, Category, Expense, Goal, Income + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def savings(user): + return Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + + +def make_expense(user, account, category, amount, on, is_transfer=False): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + +def make_income(user, account, amount, on, name="Ingreso", is_transfer=False): + return Income.objects.create( + owner=user, + account=account, + name=name, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + +# -------------------------------------------------------------------------- +# Análisis: los traspasos NO cuentan +# -------------------------------------------------------------------------- + +def test_dashboard_kpi_total_ignores_transfer(auth_client, user, account, category): + make_expense(user, account, category, "100", date(2024, 5, 10)) + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + + +def test_dashboard_by_category_ignores_transfer(auth_client, user, account, category): + other = Category.objects.create(owner=user, name="Traspasos") + make_expense(user, account, category, "100", date(2024, 5, 10)) + make_expense(user, account, other, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + rows = {row["category__name"]: row["total"] for row in response.context["by_category"]} + assert rows == {category.name: Decimal("100")} + + +def test_dashboard_comparison_ignores_transfer(auth_client, user, account, category): + make_expense(user, account, category, "30", date(2024, 3, 10)) + make_expense(user, account, category, "20", date(2024, 2, 15)) + make_expense(user, account, category, "500", date(2024, 2, 16), is_transfer=True) + + response = auth_client.get( + reverse("dashboard"), {"year": 2024, "month": 3, "compare": "1"} + ) + + assert response.context["kpi_previous_total"] == Decimal("20") + assert response.context["kpi_difference"] == Decimal("10") + + +def test_home_kpi_total_ignores_transfer(auth_client, user, account, category): + today = date.today() + make_expense(user, account, category, "100", today) + make_expense(user, account, category, "400", today, is_transfer=True) + + response = auth_client.get(reverse("home")) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + + +def test_home_previous_month_comparison_ignores_transfer( + auth_client, user, account, category +): + today = date.today() + # Día 1 para que reste un mes sin caer en un día inexistente. + first = date(today.year, today.month, 1) + prev = date(first.year - 1, 12, 1) if first.month == 1 else date( + first.year, first.month - 1, 1 + ) + + make_expense(user, account, category, "10", prev) + make_expense(user, account, category, "900", prev, is_transfer=True) + + response = auth_client.get(reverse("home")) + + assert response.context["prev_total"] == Decimal("10") + + +def test_expense_list_total_ignores_transfer_but_row_is_listed( + auth_client, user, account, category +): + make_expense(user, account, category, "100", date(2024, 5, 10)) + transfer = make_expense( + user, account, category, "400", date(2024, 5, 11), is_transfer=True + ) + + response = auth_client.get(reverse("expense_list"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + # El traspaso sigue en la tabla aunque no sume en el total. + assert transfer in list(response.context["page_obj"]) + + +def test_budget_goal_progress_ignores_transfer(user, account, category): + today = date.today() + make_expense(user, account, category, "50", today) + make_expense(user, account, category, "300", today, is_transfer=True) + + goal = Goal.objects.create( + owner=user, + name="Presupuesto mensual", + target_amount=Decimal("200"), + kind=Goal.KIND_BUDGET, + category=category, + period=Goal.PERIOD_MONTH, + ) + + assert goal.progress == Decimal("50") + assert not goal.is_exceeded() + + +# -------------------------------------------------------------------------- +# Saldos: los traspasos SÍ cuentan +# -------------------------------------------------------------------------- + +def test_transfer_expense_still_lowers_source_balance(user, account, category): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + assert account.current_balance() == Decimal("600") + + +def test_transfer_income_still_raises_target_balance(user, savings): + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + assert savings.current_balance() == Decimal("400") + + +def test_transfer_keeps_total_balance_unchanged(auth_client, user, account, category, savings): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("home")) + + # El dinero cambia de sitio, el patrimonio no se mueve. + assert response.context["total_balance"] == Decimal("1000") + + +def test_saving_goal_progress_counts_the_transfer(user, savings): + """El punto más delicado: un traspaso a la cuenta de ahorro ES el progreso.""" + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + goal = Goal.objects.create( + owner=user, + name="Fondo de emergencia", + target_amount=Decimal("1000"), + kind=Goal.KIND_SAVING, + account=savings, + ) + + assert goal.progress == Decimal("400") + + +def test_account_list_balance_counts_the_transfer(auth_client, user, account, category): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("account_list")) + + rows = {row["account"].id: row["balance"] for row in response.context["account_rows"]} + assert rows[account.id] == Decimal("600") + + +# -------------------------------------------------------------------------- +# Formularios +# -------------------------------------------------------------------------- + +def test_expense_form_can_mark_a_transfer(auth_client, user, account, category): + response = auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "400", + "description": "", + "category": category.id, + "account": account.id, + "is_transfer": "on", + }, + ) + + assert response.status_code == 302 + assert Expense.objects.get(owner=user).is_transfer is True + + +def test_income_form_can_mark_a_transfer(auth_client, user, account): + response = auth_client.post( + reverse("income_create"), + { + "account": account.id, + "name": "Traspaso desde nómina", + "amount": "400", + "date": "2024-05-11", + "is_transfer": "on", + }, + ) + + assert response.status_code == 302 + assert Income.objects.get(owner=user).is_transfer is True + + +def test_expense_defaults_to_not_a_transfer(user, account, category): + expense = make_expense(user, account, category, "10", date(2024, 5, 11)) + + assert expense.is_transfer is False