test: cobertura de traspasos

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-22 08:52:30 +02:00
parent d37cf0800d
commit b8d90c08af
3 changed files with 1010 additions and 4 deletions

View File

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

View File

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

View File

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