Configuración de automatización de CICD #35
@ -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)
|
||||
|
||||
977
expenses_manager/expenses/tests/test_transfer_model.py
Normal file
977
expenses_manager/expenses/tests/test_transfer_model.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user