From b8d90c08afdcf7f7dd01d87afc58c069f495c1fa Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 22 Sep 2026 08:52:30 +0200 Subject: [PATCH] test: cobertura de traspasos Co-Authored-By: Claude Opus 5 --- .../expenses/tests/test_categories.py | 20 + .../expenses/tests/test_transfer_model.py | 977 ++++++++++++++++++ .../expenses/tests/test_transfers.py | 17 +- 3 files changed, 1010 insertions(+), 4 deletions(-) create mode 100644 expenses_manager/expenses/tests/test_transfer_model.py diff --git a/expenses_manager/expenses/tests/test_categories.py b/expenses_manager/expenses/tests/test_categories.py index 6c9bf74..33a6329 100644 --- a/expenses_manager/expenses/tests/test_categories.py +++ b/expenses_manager/expenses/tests/test_categories.py @@ -101,6 +101,26 @@ def test_category_delete_without_expenses_succeeds(auth_client, user): assert not Category.objects.filter(pk=cat.pk).exists() +def test_system_category_cannot_be_deleted(auth_client, user): + """Las categorías de traspaso y comisión las pone la app, no el usuario.""" + cat = Category.objects.create(name="Traspaso", owner=user, is_system=True) + + response = auth_client.post(reverse('category_delete', args=[cat.pk])) + + assert response.status_code == 302 + assert Category.objects.filter(pk=cat.pk).exists() + messages = [str(m) for m in get_messages(response.wsgi_request)] + assert any("traspasos" in m for m in messages) + + +def test_system_category_has_no_delete_link_in_the_list(auth_client, user): + cat = Category.objects.create(name="Traspaso", owner=user, is_system=True) + + response = auth_client.get(reverse('category_list')) + + assert reverse('category_delete', args=[cat.pk]) not in response.content.decode() + + def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user): root_a = Category.objects.create(name="A raiz", owner=user) Category.objects.create(name="B raiz", owner=user) diff --git a/expenses_manager/expenses/tests/test_transfer_model.py b/expenses_manager/expenses/tests/test_transfer_model.py new file mode 100644 index 0000000..68bc266 --- /dev/null +++ b/expenses_manager/expenses/tests/test_transfer_model.py @@ -0,0 +1,977 @@ +"""El modelo Transfer: orquesta las dos patas de un traspaso. + +Transfer no sustituye al Expense de salida y al Income de entrada: los crea, los +vincula y los mantiene sincronizados. De ahí que aquí no se compruebe ningún +método de saldo nuevo, sino que los de siempre siguen dando el número correcto +porque las patas siguen siendo un gasto y un ingreso reales. + +El punto más delicado, y el que más tests tiene: la comisión va con +is_transfer=False, porque es dinero que sale del patrimonio de verdad y sí tiene +que contar en los análisis. +""" + +import importlib +from datetime import date +from decimal import Decimal +from urllib.parse import parse_qs, urlparse + +import pytest +from django.apps import apps as django_apps +from django.core.exceptions import ValidationError +from django.urls import reverse + +from expenses.models import Account, Category, Expense, 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_transfer(user, from_account, to_account, amount="400", on=None, fee=None): + transfer = Transfer( + owner=user, + date=on or date(2024, 5, 11), + 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 + + +# -------------------------------------------------------------------------- +# Creación: las dos patas +# -------------------------------------------------------------------------- + + +def test_creating_a_transfer_builds_and_links_both_legs(user, account, savings): + transfer = make_transfer(user, account, savings) + + assert transfer.expense_leg is not None + assert transfer.income_leg is not None + + assert transfer.expense_leg.account == account + assert transfer.expense_leg.amount == Decimal("400") + assert transfer.expense_leg.date == transfer.date + assert transfer.expense_leg.owner == user + assert transfer.expense_leg.is_transfer is True + + assert transfer.income_leg.account == savings + assert transfer.income_leg.amount == Decimal("400") + assert transfer.income_leg.date == transfer.date + assert transfer.income_leg.owner == user + assert transfer.income_leg.is_transfer is True + + # El vínculo se ve desde las dos puntas. + assert transfer.expense_leg.transfer == transfer + assert transfer.income_leg.transfer == transfer + + +def test_legs_use_the_system_transfer_category(user, account, savings): + transfer = make_transfer(user, account, savings) + + leg_category = transfer.expense_leg.category + assert leg_category.slug == "traspaso" + assert leg_category.is_system is True + assert leg_category.owner == user + + +def test_two_transfers_share_the_same_system_category(user, account, savings): + first = make_transfer(user, account, savings) + second = make_transfer(user, account, savings, on=date(2024, 6, 11)) + + assert first.expense_leg.category_id == second.expense_leg.category_id + assert Category.objects.filter(owner=user, slug="traspaso").count() == 1 + + +def test_both_balances_move(user, account, savings): + account.initial_balance = Decimal("1000") + account.save() + + make_transfer(user, account, savings) + + assert account.current_balance() == Decimal("600") + assert savings.current_balance() == Decimal("400") + + +def test_transfer_does_not_change_the_total_patrimony(user, account, savings): + account.initial_balance = Decimal("1000") + account.save() + + make_transfer(user, account, savings) + + total = account.current_balance() + savings.current_balance() + assert total == Decimal("1000") + + +# -------------------------------------------------------------------------- +# La comisión: un gasto real, sin is_transfer +# -------------------------------------------------------------------------- + + +def test_fee_creates_a_third_expense_that_is_not_a_transfer(user, account, savings): + transfer = make_transfer(user, account, savings, fee="3") + + assert transfer.fee_expense is not None + assert transfer.fee_expense.amount == Decimal("3") + assert transfer.fee_expense.account == account + assert transfer.fee_expense.date == transfer.date + # El punto donde es más fácil equivocarse: la comisión NO es un traspaso. + assert transfer.fee_expense.is_transfer is False + assert transfer.fee_expense.category.slug == "comision" + assert transfer.fee_expense.category.is_system is True + + +def test_fee_is_charged_to_the_origin_account(user, account, savings): + account.initial_balance = Decimal("1000") + account.save() + + make_transfer(user, account, savings, fee="3") + + # El origen paga el traspaso y la comisión; el destino solo recibe el traspaso. + assert account.current_balance() == Decimal("597") + assert savings.current_balance() == Decimal("400") + + +def test_dashboard_counts_the_fee_but_not_the_legs(auth_client, user, account, savings): + make_transfer(user, account, savings, fee="3", on=date(2024, 5, 11)) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("3") + assert response.context["kpi_count"] == 1 + + rows = {row["category__name"]: row["total"] for row in response.context["by_category"]} + assert rows == {"Comisión": Decimal("3")} + + +def test_without_fee_the_dashboard_sees_nothing(auth_client, user, account, savings): + make_transfer(user, account, savings, on=date(2024, 5, 11)) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("0") + assert response.context["kpi_count"] == 0 + + +# -------------------------------------------------------------------------- +# Edición: sincronizar las patas +# -------------------------------------------------------------------------- + + +def test_editing_the_amount_updates_both_legs(user, account, savings): + transfer = make_transfer(user, account, savings) + + transfer.amount = Decimal("250") + transfer.sync() + + transfer.expense_leg.refresh_from_db() + transfer.income_leg.refresh_from_db() + assert transfer.expense_leg.amount == Decimal("250") + assert transfer.income_leg.amount == Decimal("250") + + assert account.current_balance() == Decimal("-250") + assert savings.current_balance() == Decimal("250") + + +def test_editing_the_date_updates_both_legs(user, account, savings): + transfer = make_transfer(user, account, savings) + + transfer.date = date(2024, 8, 1) + transfer.sync() + + transfer.expense_leg.refresh_from_db() + transfer.income_leg.refresh_from_db() + assert transfer.expense_leg.date == date(2024, 8, 1) + assert transfer.income_leg.date == date(2024, 8, 1) + + +def test_editing_the_accounts_moves_the_legs(user, account, savings): + transfer = make_transfer(user, account, savings) + third = Account.objects.create( + owner=user, name="Bolsillo", initial_balance=Decimal("0"), active=True + ) + + transfer.from_account = savings + transfer.to_account = third + transfer.sync() + + transfer.expense_leg.refresh_from_db() + transfer.income_leg.refresh_from_db() + assert transfer.expense_leg.account == savings + assert transfer.income_leg.account == third + assert account.current_balance() == Decimal("0") + + +def test_editing_does_not_create_new_legs(user, account, savings): + transfer = make_transfer(user, account, savings) + expense_id, income_id = transfer.expense_leg_id, transfer.income_leg_id + + transfer.amount = Decimal("250") + transfer.sync() + + assert transfer.expense_leg_id == expense_id + assert transfer.income_leg_id == income_id + assert Expense.objects.count() == 1 + assert Income.objects.count() == 1 + + +def test_adding_a_fee_on_edit_creates_it(user, account, savings): + transfer = make_transfer(user, account, savings) + assert transfer.fee_expense is None + + transfer.fee_amount = Decimal("3") + transfer.sync() + + assert transfer.fee_expense is not None + assert transfer.fee_expense.is_transfer is False + assert Expense.objects.count() == 2 + + +def test_removing_the_fee_on_edit_deletes_it(user, account, savings): + transfer = make_transfer(user, account, savings, fee="3") + fee_id = transfer.fee_expense_id + + transfer.fee_amount = None + transfer.sync() + + assert transfer.fee_expense is None + assert not Expense.objects.filter(pk=fee_id).exists() + + transfer.refresh_from_db() + assert transfer.fee_expense_id is None + + +def test_editing_the_fee_amount_updates_the_expense(user, account, savings): + transfer = make_transfer(user, account, savings, fee="3") + + transfer.fee_amount = Decimal("5") + transfer.sync() + + transfer.fee_expense.refresh_from_db() + assert transfer.fee_expense.amount == Decimal("5") + assert Expense.objects.count() == 2 + + +# -------------------------------------------------------------------------- +# Borrado +# -------------------------------------------------------------------------- + + +def test_deleting_the_transfer_deletes_both_legs(user, account, savings): + transfer = make_transfer(user, account, savings) + + transfer.delete() + + assert Expense.objects.count() == 0 + assert Income.objects.count() == 0 + assert Transfer.objects.count() == 0 + + +def test_deleting_the_transfer_deletes_the_fee_too(user, account, savings): + transfer = make_transfer(user, account, savings, fee="3") + + transfer.delete() + + assert Expense.objects.count() == 0 + assert Income.objects.count() == 0 + assert Transfer.objects.count() == 0 + + +def test_deleting_the_transfer_restores_both_balances(user, account, savings): + account.initial_balance = Decimal("1000") + account.save() + transfer = make_transfer(user, account, savings, fee="3") + + transfer.delete() + + assert account.current_balance() == Decimal("1000") + assert savings.current_balance() == Decimal("0") + + +# -------------------------------------------------------------------------- +# Validación y atomicidad +# -------------------------------------------------------------------------- + + +def test_same_account_on_both_sides_is_rejected(user, account): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=account, + ) + + with pytest.raises(ValidationError) as error: + transfer.full_clean() + + assert "to_account" in error.value.message_dict + + +def test_different_accounts_pass_validation(user, account, savings): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + ) + + # A pelo y sin excluir nada: las patas son blank=True porque aún no existen. + transfer.full_clean() # no levanta + + +def test_a_failed_leg_leaves_nothing_behind(user, account, savings, monkeypatch): + """Atomicidad: si la segunda pata falla, la primera no queda suelta.""" + + def boom(self): + raise RuntimeError("fallo al construir la pata de entrada") + + monkeypatch.setattr(Transfer, "_income_name", boom) + + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + ) + + with pytest.raises(RuntimeError): + transfer.sync() + + assert Expense.objects.count() == 0 + assert Income.objects.count() == 0 + assert Transfer.objects.count() == 0 + + +# -------------------------------------------------------------------------- +# Las patas no se editan por separado +# -------------------------------------------------------------------------- + + +def test_expense_leg_cannot_be_edited_on_its_own(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings) + + response = auth_client.get( + reverse("expense_edit", args=[transfer.expense_leg_id]), follow=True + ) + + assert response.redirect_chain[0][0] == reverse("transfer_edit", args=[transfer.pk]) + + +def test_expense_leg_cannot_be_deleted_on_its_own(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings) + + auth_client.post(reverse("expense_delete", args=[transfer.expense_leg_id])) + + assert Expense.objects.filter(pk=transfer.expense_leg_id).exists() + + +def test_income_leg_cannot_be_deleted_on_its_own(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings) + + auth_client.post(reverse("income_delete", args=[transfer.income_leg_id])) + + assert Income.objects.filter(pk=transfer.income_leg_id).exists() + + +def test_the_fee_expense_cannot_be_edited_on_its_own(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings, fee="3") + + response = auth_client.get( + reverse("expense_edit", args=[transfer.fee_expense_id]), follow=True + ) + + assert response.redirect_chain[0][0] == reverse("transfer_edit", args=[transfer.pk]) + + +def test_a_normal_expense_is_still_editable(auth_client, user, account, category): + expense = Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + response = auth_client.get(reverse("expense_edit", args=[expense.pk])) + + assert response.status_code == 200 + assert expense.transfer is None + + +def test_the_listing_links_a_leg_to_its_transfer(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings) + + response = auth_client.get(reverse("expense_list"), {"year": 2024}) + html = response.content.decode() + + assert reverse("transfer_edit", args=[transfer.pk]) in html + assert reverse("expense_delete", args=[transfer.expense_leg_id]) not in html + + +@pytest.mark.parametrize("listing", ["expense_list", "income_list"]) +def test_no_template_comment_leaks_into_the_listing( + auth_client, user, account, savings, listing +): + """Los {# #} de Django son de una sola línea: partidos en dos se imprimen.""" + make_transfer(user, account, savings) + + response = auth_client.get(reverse(listing), {"year": 2024}) + + assert "{#" not in response.content.decode() + + +# -------------------------------------------------------------------------- +# Vistas CRUD +# -------------------------------------------------------------------------- + + +def test_create_view_builds_the_three_legs(auth_client, user, account, savings): + response = auth_client.post( + reverse("transfer_create"), + { + "date": "2024-05-11", + "amount": "400", + "from_account": account.id, + "to_account": savings.id, + "fee_amount": "3", + }, + ) + + assert response.status_code == 302 + transfer = Transfer.objects.get(owner=user) + assert transfer.expense_leg.is_transfer is True + assert transfer.income_leg.is_transfer is True + assert transfer.fee_expense.is_transfer is False + + +def test_create_view_without_fee(auth_client, user, account, savings): + auth_client.post( + reverse("transfer_create"), + { + "date": "2024-05-11", + "amount": "400", + "from_account": account.id, + "to_account": savings.id, + "fee_amount": "", + }, + ) + + transfer = Transfer.objects.get(owner=user) + assert transfer.fee_amount is None + assert transfer.fee_expense is None + assert Expense.objects.count() == 1 + + +def test_create_view_rejects_the_same_account_twice(auth_client, user, account): + response = auth_client.post( + reverse("transfer_create"), + { + "date": "2024-05-11", + "amount": "400", + "from_account": account.id, + "to_account": account.id, + }, + ) + + assert response.status_code == 200 + assert Transfer.objects.count() == 0 + assert "to_account" in response.context["form"].errors + + +def test_edit_view_syncs_the_legs(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings) + + response = auth_client.post( + reverse("transfer_edit", args=[transfer.pk]), + { + "date": "2024-05-11", + "amount": "250", + "from_account": account.id, + "to_account": savings.id, + "fee_amount": "", + }, + ) + + assert response.status_code == 302 + transfer.expense_leg.refresh_from_db() + transfer.income_leg.refresh_from_db() + assert transfer.expense_leg.amount == Decimal("250") + assert transfer.income_leg.amount == Decimal("250") + + +def test_delete_view_removes_everything(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings, fee="3") + + response = auth_client.post(reverse("transfer_delete", args=[transfer.pk])) + + assert response.status_code == 302 + assert Transfer.objects.count() == 0 + assert Expense.objects.count() == 0 + assert Income.objects.count() == 0 + + +def test_delete_view_needs_post(auth_client, user, account, savings): + transfer = make_transfer(user, account, savings) + + response = auth_client.get(reverse("transfer_delete", args=[transfer.pk])) + + assert response.status_code == 200 + assert Transfer.objects.count() == 1 + + +def test_list_view_only_shows_your_own(auth_client, user, account, savings, django_user_model): + make_transfer(user, account, savings) + + other = django_user_model.objects.create_user(username="otro", password="x") + other_account = Account.objects.create( + owner=other, name="Suya", initial_balance=Decimal("0"), active=True + ) + other_savings = Account.objects.create( + owner=other, name="Suya 2", initial_balance=Decimal("0"), active=True + ) + make_transfer(other, other_account, other_savings) + + response = auth_client.get(reverse("transfer_list")) + + assert [t.owner for t in response.context["transfers"]] == [user] + + +def test_form_only_offers_your_active_accounts(auth_client, user, account, savings): + Account.objects.create( + owner=user, name="Cerrada", initial_balance=Decimal("0"), active=False + ) + + response = auth_client.get(reverse("transfer_create")) + form = response.context["form"] + + names = {acc.name for acc in form.fields["from_account"].queryset} + assert names == {account.name, savings.name} + assert names == {acc.name for acc in form.fields["to_account"].queryset} + + +def test_transfer_edit_view_is_scoped_by_owner(auth_client, django_user_model): + other = django_user_model.objects.create_user(username="otro", password="x") + other_account = Account.objects.create( + owner=other, name="Suya", initial_balance=Decimal("0"), active=True + ) + other_savings = Account.objects.create( + owner=other, name="Suya 2", initial_balance=Decimal("0"), active=True + ) + transfer = make_transfer(other, other_account, other_savings) + + response = auth_client.get(reverse("transfer_edit", args=[transfer.pk])) + + assert response.status_code == 404 + + +# -------------------------------------------------------------------------- +# El atajo de la casilla: del gasto o del ingreso al traspaso +# -------------------------------------------------------------------------- + + +def _query(url): + return parse_qs(urlparse(url).query) + + +def test_the_expense_form_carries_its_data_to_the_transfer_form( + auth_client, user, account, category +): + """La cuenta de un gasto entra como origen: el dinero sale de ahí.""" + 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 urlparse(response.url).path == reverse("transfer_create") + assert _query(response.url) == { + "date": ["2024-05-11"], + "amount": ["400"], + "from_account": [str(account.id)], + } + + +def test_the_income_form_carries_its_account_as_destination(auth_client, user, savings): + response = auth_client.post( + reverse("income_create"), + { + "account": savings.id, + "name": "Traspaso", + "amount": "400", + "date": "2024-05-11", + "is_transfer": "on", + }, + ) + + assert _query(response.url) == { + "date": ["2024-05-11"], + "amount": ["400"], + "to_account": [str(savings.id)], + } + + +def test_a_half_filled_expense_still_reaches_the_transfer_form( + auth_client, user, account +): + """Sin categoría el gasto no valida, pero el salto no depende de que valide: + se lleva lo que haya y lo que falte se queda en blanco.""" + response = auth_client.post( + reverse("expense_create"), + {"date": "", "amount": "400", "account": account.id, "is_transfer": "on"}, + ) + + assert response.status_code == 302 + assert _query(response.url) == { + "amount": ["400"], + "from_account": [str(account.id)], + } + assert not Expense.objects.filter(owner=user).exists() + + +def test_the_transfer_form_prefills_from_the_url(auth_client, user, account, savings): + response = auth_client.get( + reverse("transfer_create"), + { + "date": "2024-05-11", + "amount": "400", + "from_account": account.id, + "to_account": savings.id, + }, + ) + + assert response.context["form"].initial == { + "date": date(2024, 5, 11), + "amount": Decimal("400"), + "from_account": account.id, + "to_account": savings.id, + } + + +@pytest.mark.parametrize( + "params", + [ + {"date": "11/05/2024"}, + {"date": "2024-13-45"}, + {"amount": "cuatrocientos"}, + {"amount": "NaN"}, + {"amount": "Infinity"}, + {"from_account": "primera"}, + {"to_account": "999999"}, + ], +) +def test_an_invalid_url_param_is_ignored_and_does_not_break(auth_client, user, params): + """La URL la escribe cualquiera: lo que no se entienda sale en blanco.""" + response = auth_client.get(reverse("transfer_create"), params) + + assert response.status_code == 200 + assert response.context["form"].initial == {} + + +def test_an_account_of_another_user_is_not_prefilled( + auth_client, user, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + foreign = Account.objects.create( + owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True + ) + + response = auth_client.get(reverse("transfer_create"), {"from_account": foreign.id}) + + assert response.context["form"].initial == {} + + +def test_an_inactive_account_is_not_prefilled(auth_client, user): + """Las cuentas desactivadas no están en el desplegable: preseleccionar una + dejaría el formulario con un valor que no se puede enviar.""" + closed = Account.objects.create( + owner=user, name="Cerrada", initial_balance=Decimal("0"), active=False + ) + + response = auth_client.get(reverse("transfer_create"), {"to_account": closed.id}) + + assert response.context["form"].initial == {} + + +# -------------------------------------------------------------------------- +# La casilla de traspaso no se ofrece al editar +# -------------------------------------------------------------------------- + + +def test_the_checkbox_is_offered_when_creating_an_expense(auth_client, user): + response = auth_client.get(reverse("expense_create")) + + assert "id_is_transfer" in response.content.decode() + + +def test_the_checkbox_is_offered_when_creating_an_income(auth_client, user): + response = auth_client.get(reverse("income_create")) + + assert "id_is_transfer" in response.content.decode() + + +@pytest.mark.parametrize( + "view_name,account_param", + [("expense_create", "from_account"), ("income_create", "to_account")], +) +def test_the_create_form_tells_the_javascript_where_to_jump( + auth_client, user, view_name, account_param +): + """El JS no sabe nada de traspasos: el destino y el papel de la cuenta se los + da la plantilla en data-*. Renombrarlos rompería el salto inmediato sin que + nada se queje, porque el POST seguiría redirigiendo igual. + """ + response = auth_client.get(reverse(view_name)) + html = response.content.decode() + + assert f'data-transfer-url="{reverse("transfer_create")}"' in html + assert f'data-transfer-account-param="{account_param}"' in html + assert "transfer_redirect.js" in html + + +def test_the_checkbox_is_hidden_when_editing_an_expense( + auth_client, user, account, category +): + """Editar un gasto no es convertirlo en traspaso: la casilla no pinta nada.""" + expense = Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + response = auth_client.get(reverse("expense_edit", args=[expense.pk])) + + assert "id_is_transfer" not in response.content.decode() + + +def test_the_checkbox_is_hidden_when_editing_an_income(auth_client, user, account): + income = Income.objects.create( + owner=user, + account=account, + name="Nómina", + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + response = auth_client.get(reverse("income_edit", args=[income.pk])) + + assert "id_is_transfer" not in response.content.decode() + + +def test_posting_is_transfer_while_editing_has_no_effect( + auth_client, user, account, category +): + """El campo no está en el formulario, así que mandarlo a mano tampoco cuela.""" + expense = Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + auth_client.post( + reverse("expense_edit", args=[expense.pk]), + { + "date": "2024-05-11", + "amount": "10", + "description": "", + "category": category.id, + "account": account.id, + "is_transfer": "on", + }, + ) + + expense.refresh_from_db() + assert expense.is_transfer is False + + +def test_a_leg_shows_its_transfer_link_and_no_checkbox( + auth_client, user, account, savings +): + """Una pata sigue mandando al traspaso: ni casilla, ni edición suelta.""" + transfer = make_transfer(user, account, savings) + + listing = auth_client.get(reverse("expense_list"), {"year": 2024}) + html = listing.content.decode() + + assert reverse("transfer_edit", args=[transfer.pk]) in html + assert "id_is_transfer" not in html + + edit = auth_client.get( + reverse("expense_edit", args=[transfer.expense_leg_id]), follow=True + ) + + assert edit.redirect_chain[0][0] == reverse("transfer_edit", args=[transfer.pk]) + assert "id_is_transfer" not in edit.content.decode() + + +# -------------------------------------------------------------------------- +# Migración de datos: emparejar los traspasos históricos +# -------------------------------------------------------------------------- + + +def link_legacy_transfers(): + """Lanza la función de la migración 0015 contra los modelos reales.""" + migration = importlib.import_module("expenses.migrations.0015_link_legacy_transfers") + migration.link_legacy_transfers(django_apps, None) + + +def legacy_pair(user, from_account, to_account, category, amount, on): + expense = Expense.objects.create( + owner=user, + account=from_account, + category=category, + amount=Decimal(amount), + date=on, + is_transfer=True, + ) + income = Income.objects.create( + owner=user, + account=to_account, + name="Traspaso", + amount=Decimal(amount), + date=on, + is_transfer=True, + ) + return expense, income + + +def test_migration_pairs_an_unambiguous_legacy_transfer( + user, account, savings, category +): + expense, income = legacy_pair( + user, account, savings, category, "200", date(2024, 5, 11) + ) + + link_legacy_transfers() + + transfer = Transfer.objects.get() + assert transfer.expense_leg_id == expense.pk + assert transfer.income_leg_id == income.pk + assert transfer.from_account_id == account.pk + assert transfer.to_account_id == savings.pk + assert transfer.amount == Decimal("200") + assert transfer.date == date(2024, 5, 11) + assert transfer.fee_amount is None + + +def test_migration_leaves_ambiguous_candidates_alone(user, account, savings, category): + # Dos gastos y dos ingresos con el mismo importe y fecha: no hay forma de + # saber cuál va con cuál, así que no se toca ninguno. + legacy_pair(user, account, savings, category, "200", date(2024, 5, 11)) + legacy_pair(user, account, savings, category, "200", date(2024, 5, 11)) + + link_legacy_transfers() + + assert Transfer.objects.count() == 0 + assert Expense.objects.filter(is_transfer=True).count() == 2 + assert Income.objects.filter(is_transfer=True).count() == 2 + + +def test_migration_leaves_orphans_alone(user, account, category): + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("200"), + date=date(2024, 5, 11), + is_transfer=True, + ) + + link_legacy_transfers() + + assert Transfer.objects.count() == 0 + assert Expense.objects.filter(is_transfer=True).count() == 1 + + +def test_migration_ignores_normal_movements(user, account, savings, category): + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("200"), + date=date(2024, 5, 11), + ) + Income.objects.create( + owner=user, + account=savings, + name="Nómina", + amount=Decimal("200"), + date=date(2024, 5, 11), + ) + + link_legacy_transfers() + + assert Transfer.objects.count() == 0 + + +def test_migration_skips_a_pair_on_the_same_account(user, account, category): + legacy_pair(user, account, account, category, "200", date(2024, 5, 11)) + + link_legacy_transfers() + + assert Transfer.objects.count() == 0 + + +def test_migration_does_not_cross_owners(user, account, category, django_user_model): + other = django_user_model.objects.create_user(username="otro", password="x") + other_account = Account.objects.create( + owner=other, name="Suya", initial_balance=Decimal("0"), active=True + ) + + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("200"), + date=date(2024, 5, 11), + is_transfer=True, + ) + Income.objects.create( + owner=other, + account=other_account, + name="Traspaso", + amount=Decimal("200"), + date=date(2024, 5, 11), + is_transfer=True, + ) + + link_legacy_transfers() + + assert Transfer.objects.count() == 0 + + +def test_migration_is_idempotent(user, account, savings, category): + legacy_pair(user, account, savings, category, "200", date(2024, 5, 11)) + + link_legacy_transfers() + link_legacy_transfers() + + assert Transfer.objects.count() == 1 + + +def test_migration_does_not_touch_legs_of_an_existing_transfer(user, account, savings): + make_transfer(user, account, savings) + + link_legacy_transfers() + + assert Transfer.objects.count() == 1 diff --git a/expenses_manager/expenses/tests/test_transfers.py b/expenses_manager/expenses/tests/test_transfers.py index 33f42c3..86727c3 100644 --- a/expenses_manager/expenses/tests/test_transfers.py +++ b/expenses_manager/expenses/tests/test_transfers.py @@ -203,7 +203,12 @@ def test_account_list_balance_counts_the_transfer(auth_client, user, account, ca # Formularios # -------------------------------------------------------------------------- -def test_expense_form_can_mark_a_transfer(auth_client, user, account, category): +# Marcar la casilla ya no guarda un movimiento marcado: reconduce al formulario de +# traspaso, que crea las dos patas. El detalle está en test_transfer_model.py. + +def test_marking_the_expense_form_as_transfer_creates_nothing( + auth_client, user, account, category +): response = auth_client.post( reverse("expense_create"), { @@ -217,10 +222,13 @@ def test_expense_form_can_mark_a_transfer(auth_client, user, account, category): ) assert response.status_code == 302 - assert Expense.objects.get(owner=user).is_transfer is True + assert reverse("transfer_create") in response.url + assert not Expense.objects.filter(owner=user).exists() -def test_income_form_can_mark_a_transfer(auth_client, user, account): +def test_marking_the_income_form_as_transfer_creates_nothing( + auth_client, user, account +): response = auth_client.post( reverse("income_create"), { @@ -233,7 +241,8 @@ def test_income_form_can_mark_a_transfer(auth_client, user, account): ) assert response.status_code == 302 - assert Income.objects.get(owner=user).is_transfer is True + assert reverse("transfer_create") in response.url + assert not Income.objects.filter(owner=user).exists() def test_expense_defaults_to_not_a_transfer(user, account, category):