From c50d41656085444906c3181930cf93dab0fc3256 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 13:37:48 +0200 Subject: [PATCH] Add tests for goal links and collection goals --- .../expenses/tests/test_goal_links.py | 749 ++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_goal_links.py diff --git a/expenses_manager/expenses/tests/test_goal_links.py b/expenses_manager/expenses/tests/test_goal_links.py new file mode 100644 index 0000000..e33eca3 --- /dev/null +++ b/expenses_manager/expenses/tests/test_goal_links.py @@ -0,0 +1,749 @@ +"""Vinculación de movimientos a objetivos y el tipo "cobro pendiente". + +El test central es el del doble conteo: con la selección por categoría y la +vinculación a mano conviviendo, un gasto que esté en las dos tiene que contar +una sola vez. No da error si se cuenta dos veces, da un número mal, que es peor. +""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from expenses.forms import ExpenseForm, GoalForm, IncomeForm +from expenses.models import Account, Category, Expense, Goal, Income, Transfer + +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_payment_goal(user, category=None, target="1000", start=date(2024, 1, 1)): + return Goal.objects.create( + owner=user, + name="Pago", + kind=Goal.KIND_PAYMENT, + category=category, + include_subcategories=False, + start_date=start, + target_amount=Decimal(target), + ) + + +def make_budget_goal(user, category, target="100", period=Goal.PERIOD_MONTH): + return Goal.objects.create( + owner=user, + name="Presupuesto", + kind=Goal.KIND_BUDGET, + category=category, + include_subcategories=False, + period=period, + target_amount=Decimal(target), + ) + + +def make_collection_goal(user, target="1000", start=date(2024, 1, 1)): + return Goal.objects.create( + owner=user, + name="Cobro", + kind=Goal.KIND_COLLECTION, + start_date=start, + target_amount=Decimal(target), + ) + + +def make_expense(user, account, category, amount="10", on=date(2024, 5, 11)): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + ) + + +def make_income(user, account, amount="10", on=date(2024, 5, 11), name="Devolución"): + return Income.objects.create( + owner=user, account=account, name=name, amount=Decimal(amount), date=on + ) + + +# -------------------------------------------------------------------------- +# El doble conteo: el test central de la fase +# -------------------------------------------------------------------------- + + +def test_an_expense_in_the_category_and_linked_by_hand_counts_once( + user, account, category +): + expense = make_expense(user, account, category, amount="100") + goal = make_payment_goal(user, category=category) + + goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("100") + + +def test_the_category_and_the_links_add_up_without_overlapping( + user, account, category +): + other_category = Category.objects.create(owner=user, name="Otra") + in_category = make_expense(user, account, category, amount="100") + linked_outside = make_expense(user, account, other_category, amount="30") + goal = make_payment_goal(user, category=category) + + # El de la categoría también vinculado a mano, para que el solape exista. + goal.linked_expenses.add(in_category, linked_outside) + + assert goal.progress == Decimal("130") + + +def test_a_linked_expense_counted_in_several_goals_is_not_inflated( + user, account, category +): + expense = make_expense(user, account, category, amount="100") + goal = make_payment_goal(user, category=category) + other_goal = make_payment_goal(user, category=category) + + # El JOIN del M2M devuelve una fila por objetivo vinculado: si se suma sin + # deduplicar, este gasto cuenta tantas veces como objetivos tenga. + goal.linked_expenses.add(expense) + other_goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("100") + + +def test_a_goal_with_links_and_no_category_still_measures(user, account, category): + expense = make_expense(user, account, category, amount="40") + goal = make_payment_goal(user, category=None) + + goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("40") + + +def test_links_respect_the_start_date(user, account, category): + old = make_expense(user, account, category, amount="99", on=date(2023, 5, 1)) + goal = make_payment_goal(user, category=None, start=date(2024, 1, 1)) + + goal.linked_expenses.add(old) + + assert goal.progress == Decimal("0") + + +# -------------------------------------------------------------------------- +# Presupuesto: el caso con más riesgo de duplicar, porque siempre tiene +# categoría y además admite vinculaciones +# -------------------------------------------------------------------------- + + +def test_a_budget_counts_a_linked_expense_of_its_own_category_once( + user, account, category +): + # Un presupuesto siempre lleva categoría, así que el solape con la + # vinculación a mano es el caso normal, no el raro. + expense = make_expense(user, account, category, amount="30", on=date.today()) + goal = make_budget_goal(user, category, target="100") + + goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("30") + + +def test_a_budget_adds_a_linked_expense_from_another_category( + user, account, category +): + other_category = Category.objects.create(owner=user, name="Ocio") + make_expense(user, account, category, amount="30", on=date.today()) + outside = make_expense(user, account, other_category, amount="20", on=date.today()) + goal = make_budget_goal(user, category, target="100") + + # El caso que motiva vincular en un presupuesto: un gasto de otra categoría + # que quieres que compute aquí. + goal.linked_expenses.add(outside) + + assert goal.progress == Decimal("50") + + +def test_a_budget_blown_by_a_linked_expense_is_still_flagged(user, account, category): + other_category = Category.objects.create(owner=user, name="Ocio") + outside = make_expense(user, account, other_category, amount="120", on=date.today()) + goal = make_budget_goal(user, category, target="100") + + goal.linked_expenses.add(outside) + + assert goal.progress == Decimal("120") + assert goal.is_exceeded() is True + assert goal.progress_state() == "danger" + + +def test_a_budget_accepts_links(user, category): + assert make_budget_goal(user, category).accepts_links is True + + +def test_only_the_budget_inverts_the_criterion(user, account, category): + # Pasado del objetivo: el presupuesto lo marca como excedido, el pago lo + # considera conseguido. Solo el presupuesto invierte el criterio. + over_budget = make_budget_goal(user, category, target="100") + over_budget.linked_expenses.add( + make_expense(user, account, category, amount="120", on=date.today()) + ) + over_payment = make_payment_goal(user, category=None, target="100") + over_payment.linked_expenses.add( + make_expense(user, account, category, amount="120") + ) + + assert over_budget.is_exceeded() is True + assert over_budget.progress_state() == "danger" + assert over_payment.is_exceeded() is False + assert over_payment.progress_state() == "complete" + + +def test_a_budget_at_eighty_percent_warns(user, account, category): + goal = make_budget_goal(user, category, target="100") + goal.linked_expenses.add( + make_expense(user, account, category, amount="80", on=date.today()) + ) + + assert goal.progress_state() == "warning" + assert goal.is_exceeded() is False + + +def test_a_linked_expense_outside_the_budget_period_does_not_count( + user, account, category +): + other_category = Category.objects.create(owner=user, name="Ocio") + old = make_expense( + user, account, other_category, amount="50", on=date(2020, 1, 15) + ) + goal = make_budget_goal(user, category, target="100") + + # El presupuesto se reinicia cada mes: vincular algo viejo no lo resucita. + goal.linked_expenses.add(old) + + assert goal.progress == Decimal("0") + + +# -------------------------------------------------------------------------- +# Cobro pendiente +# -------------------------------------------------------------------------- + + +def test_a_collection_goal_sums_its_linked_incomes(user, account): + income = make_income(user, account, amount="250") + goal = make_collection_goal(user) + + goal.linked_incomes.add(income) + + assert goal.progress == Decimal("250") + + +def test_an_income_outside_the_collection_goal_does_not_count(user, account): + make_income(user, account, amount="250") + goal = make_collection_goal(user) + + assert goal.progress == Decimal("0") + + +def test_a_collection_goal_respects_the_start_date(user, account): + old = make_income(user, account, amount="250", on=date(2023, 1, 1)) + goal = make_collection_goal(user, start=date(2024, 1, 1)) + + goal.linked_incomes.add(old) + + assert goal.progress == Decimal("0") + + +def test_a_collection_goal_at_half_way_is_not_flagged(user, account): + goal = make_collection_goal(user, target="100") + goal.linked_incomes.add(make_income(user, account, amount="50")) + + assert goal.percentage() == Decimal("50") + # Acercarse al 100% es bueno aquí, como en pago y ahorro: solo el + # presupuesto invierte el criterio. + assert goal.progress_state() == "ok" + assert goal.is_exceeded() is False + + +def test_a_fully_collected_goal_is_complete(user, account): + goal = make_collection_goal(user, target="100") + goal.linked_incomes.add(make_income(user, account, amount="100")) + + assert goal.progress_state() == "complete" + assert goal.is_exceeded() is False + + +def test_the_new_kind_is_offered_in_the_goal_list_filter(auth_client, user): + make_collection_goal(user) + make_payment_goal(user) + + response = auth_client.get(reverse("goal_list"), {"kind": Goal.KIND_COLLECTION}) + + assert response.status_code == 200 + assert [g.kind for g in response.context["goals"]] == [Goal.KIND_COLLECTION] + + +def test_a_collection_goal_needs_a_start_date(user): + form = GoalForm( + { + "name": "Cobro", + "kind": Goal.KIND_COLLECTION, + "target_amount": "100", + "period": Goal.PERIOD_NONE, + }, + user=user, + ) + + assert not form.is_valid() + assert "start_date" in form.errors + + +def test_a_collection_goal_needs_neither_category_nor_account(user, category): + form = GoalForm( + { + "name": "Cobro", + "kind": Goal.KIND_COLLECTION, + "target_amount": "100", + "start_date": "2024-01-01", + "period": Goal.PERIOD_NONE, + }, + user=user, + ) + + assert form.is_valid(), form.errors + goal = form.save(commit=False) + assert goal.category is None + assert goal.account is None + + +# -------------------------------------------------------------------------- +# Qué es vinculable: las patas del traspaso no, la comisión sí +# -------------------------------------------------------------------------- + + +def test_a_transfer_leg_is_not_a_candidate(user, account, savings, category): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + ) + transfer.sync() + goal = make_payment_goal(user, category=category) + + candidates = list(goal.candidate_movements()) + + # Mover dinero entre cuentas propias no es ni un pago ni un cobro. + assert transfer.expense_leg not in candidates + + +def test_the_income_leg_is_not_a_candidate_for_a_collection_goal( + user, account, savings +): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + ) + transfer.sync() + goal = make_collection_goal(user) + + assert transfer.income_leg not in list(goal.candidate_movements()) + + +def test_the_transfer_fee_is_a_candidate(user, account, savings, category): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + fee_amount=Decimal("1.50"), + ) + transfer.sync() + goal = make_payment_goal(user, category=category) + + # La comisión es gasto real: va con is_transfer=False y cuenta en análisis. + assert transfer.fee_expense in list(goal.candidate_movements()) + + +def test_a_saving_goal_does_not_offer_linking(user, savings): + goal = Goal.objects.create( + owner=user, + name="Ahorro", + kind=Goal.KIND_SAVING, + account=savings, + target_amount=Decimal("100"), + ) + + assert goal.accepts_links is False + + +def test_the_linking_screen_turns_away_a_saving_goal(auth_client, user, savings): + goal = Goal.objects.create( + owner=user, + name="Ahorro", + kind=Goal.KIND_SAVING, + account=savings, + target_amount=Decimal("100"), + ) + + response = auth_client.get(reverse("goal_links", args=[goal.pk])) + + assert response.status_code == 302 + + +# -------------------------------------------------------------------------- +# Vía 1: el formulario del movimiento +# -------------------------------------------------------------------------- + + +def test_the_expense_form_only_offers_goals_that_take_expenses( + user, savings, category +): + payment = make_payment_goal(user, category=category) + budget = make_budget_goal(user, category) + collection = make_collection_goal(user) + saving = Goal.objects.create( + owner=user, + name="Ahorro", + kind=Goal.KIND_SAVING, + account=savings, + target_amount=Decimal("100"), + ) + + offered = list(ExpenseForm(user=user).fields["goals"].queryset) + + assert payment in offered + assert budget in offered + assert collection not in offered + assert saving not in offered + + +def test_the_goal_list_offers_linking_on_a_budget(auth_client, user, category): + goal = make_budget_goal(user, category) + + response = auth_client.get(reverse("goal_list")) + + assert reverse("goal_links", args=[goal.pk]) in response.content.decode() + + +def test_the_income_form_only_offers_collection_goals(user, category): + collection = make_collection_goal(user) + payment = make_payment_goal(user, category=category) + + offered = list(IncomeForm(user=user).fields["goals"].queryset) + + assert collection in offered + assert payment not in offered + + +def test_the_expense_form_does_not_offer_another_users_goals( + user, category, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + theirs = make_payment_goal(intruder, category=None) + + assert theirs not in list(ExpenseForm(user=user).fields["goals"].queryset) + + +def test_creating_an_expense_links_it_to_the_chosen_goal( + auth_client, user, account, category +): + goal = make_payment_goal(user, category=None) + + response = auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "75", + "category": category.pk, + "account": account.pk, + "description": "Compra", + "goals": [goal.pk], + }, + ) + + assert response.status_code == 302 + assert goal.progress == Decimal("75") + + +def test_creating_an_income_links_it_to_the_chosen_goal(auth_client, user, account): + goal = make_collection_goal(user) + + response = auth_client.post( + reverse("income_create"), + { + "date": "2024-05-11", + "amount": "75", + "account": account.pk, + "name": "Devolución", + "goals": [goal.pk], + }, + ) + + assert response.status_code == 302 + assert goal.progress == Decimal("75") + + +def test_editing_an_expense_can_unlink_it(auth_client, user, account, category): + expense = make_expense(user, account, category, amount="75") + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + auth_client.post( + reverse("expense_edit", args=[expense.pk]), + { + "date": "2024-05-11", + "amount": "75", + "category": category.pk, + "account": account.pk, + "description": "", + # Sin "goals": se queda sin vincular. + }, + ) + + assert goal.linked_expenses.count() == 0 + + +def test_the_expense_form_shows_the_current_links_when_editing( + user, account, category +): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + form = ExpenseForm(instance=expense, user=user) + + assert form.initial["goals"] == [goal.pk] + + +# -------------------------------------------------------------------------- +# Vía 2: la pantalla del objetivo +# -------------------------------------------------------------------------- + + +def test_the_linking_screen_lists_the_candidates(auth_client, user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + + response = auth_client.get(reverse("goal_links", args=[goal.pk])) + + assert response.status_code == 200 + assert expense in list(response.context["page_obj"]) + + +def test_linking_from_the_goal_screen_saves(auth_client, user, account, category): + expense = make_expense(user, account, category, amount="60") + goal = make_payment_goal(user, category=None) + + auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [expense.pk], "movements": [expense.pk]}, + ) + + assert list(goal.linked_expenses.all()) == [expense] + + +def test_both_ways_of_linking_end_up_the_same(auth_client, user, account, category): + first = make_expense(user, account, category, amount="60") + second = make_expense(user, account, category, amount="60") + by_screen = make_payment_goal(user, category=None) + by_form = make_payment_goal(user, category=None) + + auth_client.post( + reverse("goal_links", args=[by_screen.pk]), + {"shown": [first.pk], "movements": [first.pk]}, + ) + auth_client.post( + reverse("expense_edit", args=[second.pk]), + { + "date": second.date.isoformat(), + "amount": "60", + "category": category.pk, + "account": account.pk, + "description": "", + "goals": [by_form.pk], + }, + ) + + assert by_screen.progress == by_form.progress == Decimal("60") + + +def test_unchecking_on_the_goal_screen_unlinks(auth_client, user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [expense.pk]}, + ) + + assert goal.linked_expenses.count() == 0 + + +def test_a_get_on_the_linking_screen_saves_nothing( + auth_client, user, account, category +): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + + auth_client.get( + reverse("goal_links", args=[goal.pk]), + {"shown": [expense.pk], "movements": [expense.pk]}, + ) + + assert goal.linked_expenses.count() == 0 + + +def test_saving_one_page_leaves_the_other_pages_alone( + auth_client, user, account, category +): + # La pantalla pagina de 10 en 10: un set() a secas con lo marcado en una + # página desvincularía todo lo de las demás. + expenses = [ + make_expense(user, account, category, on=date(2024, 5, day)) + for day in range(1, 15) + ] + goal = make_payment_goal(user, category=None) + goal.linked_expenses.set(expenses) + + first_page = list(auth_client.get(reverse("goal_links", args=[goal.pk])).context["page_obj"]) + # Se reenvía la primera página sin ninguna marca: solo esos se desvinculan. + auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [m.pk for m in first_page]}, + ) + + assert goal.linked_expenses.count() == len(expenses) - len(first_page) + + +# -------------------------------------------------------------------------- +# Aislamiento por usuario +# -------------------------------------------------------------------------- + + +def test_another_users_movement_cannot_be_linked_by_post( + auth_client, user, 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 + ) + their_category = Category.objects.create(owner=intruder, name="Ajena") + theirs = make_expense(intruder, their_account, their_category, amount="500") + goal = make_payment_goal(user, category=None) + + response = auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [theirs.pk], "movements": [theirs.pk]}, + ) + + assert response.status_code == 302 + assert goal.linked_expenses.count() == 0 + assert goal.progress == Decimal("0") + + +def test_another_users_goal_cannot_be_opened(auth_client, django_user_model): + intruder = django_user_model.objects.create_user(username="otro", password="x") + theirs = make_payment_goal(intruder, category=None) + + response = auth_client.get(reverse("goal_links", args=[theirs.pk])) + + assert response.status_code == 404 + + +def test_another_users_goal_cannot_be_chosen_from_the_expense_form( + auth_client, user, account, category, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + theirs = make_payment_goal(intruder, category=None) + + auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "75", + "category": category.pk, + "account": account.pk, + "description": "Compra", + "goals": [theirs.pk], + }, + ) + + assert theirs.linked_expenses.count() == 0 + + +# -------------------------------------------------------------------------- +# Borrado y rendimiento +# -------------------------------------------------------------------------- + + +def test_deleting_a_goal_keeps_the_linked_movements(user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + goal.delete() + + # Se limpia la tabla intermedia y el gasto sigue ahí. + expense.refresh_from_db() + assert Expense.objects.filter(pk=expense.pk).exists() + + +def test_deleting_a_linked_movement_leaves_the_goal_alone(user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + expense.delete() + goal.refresh_from_db() + + assert goal.linked_expenses.count() == 0 + assert goal.progress == Decimal("0") + + +def _queries_painting(auth_client, user, account, category, goals, links_each): + """Consultas que cuesta pintar goal_list con esos objetivos y vinculaciones.""" + Goal.objects.filter(owner=user).delete() + for _ in range(goals): + goal = make_payment_goal(user, category=None) + goal.linked_expenses.set( + [make_expense(user, account, category) for _ in range(links_each)] + ) + + with CaptureQueriesContext(connection) as captured: + assert auth_client.get(reverse("goal_list")).status_code == 200 + + return len(captured) + + +def test_linked_movements_do_not_cost_a_query_each( + auth_client, user, account, category +): + # El N+1 que reaparece con el M2M: una consulta por movimiento vinculado al + # pintar la lista. Triplicar las vinculaciones no debe mover el número. + few = _queries_painting(auth_client, user, account, category, 2, 1) + many = _queries_painting(auth_client, user, account, category, 2, 3) + + assert few == many + + +def test_each_extra_goal_costs_a_single_query(auth_client, user, account, category): + # Cada objetivo cuesta el agregado de su progress(), y nada más. + two = _queries_painting(auth_client, user, account, category, 2, 2) + four = _queries_painting(auth_client, user, account, category, 4, 2) + + assert four - two == 2