Add tests for goal links and collection goals

This commit is contained in:
JKuijperM 2026-10-13 13:37:48 +02:00
parent a5f1c7b590
commit c50d416560

View File

@ -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