From 68fdb5a64400850913b07b771e49d7c0894354cd Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Thu, 1 Oct 2026 15:10:38 +0200 Subject: [PATCH] Add tests for CSV export --- .../expenses/tests/test_export.py | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_export.py diff --git a/expenses_manager/expenses/tests/test_export.py b/expenses_manager/expenses/tests/test_export.py new file mode 100644 index 0000000..ce4de43 --- /dev/null +++ b/expenses_manager/expenses/tests/test_export.py @@ -0,0 +1,547 @@ +"""Exportación a CSV: dialectos, filtros y aislamiento por usuario. + +Lo que más se mira aquí es el dialecto de Excel: el BOM y la coma decimal son +justo lo que hace que el fichero se abra con doble clic en vez de salir todo en +una columna. Las filas se leen siempre con csv.reader y nunca con split(), que +partiría por la mitad cualquier concepto con el delimitador dentro. +""" + +import csv +import io +from datetime import date +from decimal import Decimal + +import pytest +from django.urls import reverse + +from expenses.csv_export import ( + EXCEL_ES, + STANDARD, + csv_filename, + fmt_amount, + fmt_bool, + fmt_date, +) +from expenses.models import Account, Category, Expense, Income, Tag, Transfer + +pytestmark = pytest.mark.django_db + + +# -------------------------------------------------------------------------- +# Utilidades +# -------------------------------------------------------------------------- + + +def body(response): + """El cuerpo completo de una StreamingHttpResponse, como texto.""" + return b"".join(response.streaming_content).decode("utf-8") + + +def read_rows(response, delimiter=";"): + text = body(response).lstrip("") + + return list(csv.reader(io.StringIO(text), delimiter=delimiter)) + + +def download(client, **params): + params.setdefault("fmt", "excel") + + return client.get(reverse("export_download"), params) + + +@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 + + +# -------------------------------------------------------------------------- +# Formateo (sin base de datos ni HTTP) +# -------------------------------------------------------------------------- + + +def test_excel_dialect_formats_amounts_with_a_comma(): + assert fmt_amount(Decimal("1234.56"), EXCEL_ES) == "1234,56" + + +def test_standard_dialect_formats_amounts_with_a_dot(): + assert fmt_amount(Decimal("1234.56"), STANDARD) == "1234.56" + + +def test_amounts_never_carry_a_thousands_separator(): + # Un "1.234,56" partiría la fila en dos en el dialecto de Excel. + assert fmt_amount(Decimal("1234567.80"), EXCEL_ES) == "1234567,80" + + +def test_amounts_always_keep_two_decimals(): + assert fmt_amount(Decimal("5"), EXCEL_ES) == "5,00" + + +def test_a_missing_amount_is_an_empty_cell_not_a_zero(): + assert fmt_amount(None, EXCEL_ES) == "" + + +def test_dates_follow_the_dialect(): + assert fmt_date(date(2026, 1, 31), EXCEL_ES) == "31/01/2026" + assert fmt_date(date(2026, 1, 31), STANDARD) == "2026-01-31" + + +def test_booleans_follow_the_dialect(): + assert fmt_bool(True, EXCEL_ES) == "Sí" + assert fmt_bool(False, EXCEL_ES) == "No" + assert fmt_bool(True, STANDARD) == "true" + assert fmt_bool(False, STANDARD) == "false" + + +def test_filename_carries_the_range(): + name = csv_filename("gastos", date(2026, 1, 1), date(2026, 10, 1)) + + assert name == "gastos_2026-01-01_2026-10-01.csv" + + +def test_filename_without_a_range_carries_todays_date(): + assert csv_filename("gastos", None, None) == f"gastos_{date.today().isoformat()}.csv" + + +# -------------------------------------------------------------------------- +# Las tres descargas responden +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("kind", ["expenses", "incomes", "transfers"]) +def test_every_kind_downloads_a_csv(auth_client, kind): + response = download(auth_client, kind=kind) + + assert response.status_code == 200 + assert response["Content-Type"] == "text/csv; charset=utf-8" + assert ".csv" in response["Content-Disposition"] + + +def test_the_download_is_an_attachment_with_a_spanish_filename(auth_client): + response = download(auth_client, kind="transfers") + + assert "attachment" in response["Content-Disposition"] + assert "traspasos_" in response["Content-Disposition"] + + +def test_the_export_screen_renders(auth_client): + response = auth_client.get(reverse("export_index")) + + assert response.status_code == 200 + assert "form" in response.context + + +def test_the_settings_page_links_to_the_export_screen(auth_client): + # Un {% url %} mal escrito ahí no lo ve manage.py check: revienta en runtime. + response = auth_client.get(reverse("settings_index")) + + assert response.status_code == 200 + assert reverse("export_index") in response.content.decode() + + +def test_the_export_screen_needs_a_login(client): + response = client.get(reverse("export_index")) + + assert response.status_code == 302 + + +def test_the_download_needs_a_login(client): + response = client.get(reverse("export_download"), {"kind": "expenses"}) + + assert response.status_code == 302 + + +# -------------------------------------------------------------------------- +# Dialectos en la salida real +# -------------------------------------------------------------------------- + + +def test_excel_csv_starts_with_a_bom(auth_client, account, category): + Expense.objects.create( + owner=account.owner, + account=account, + category=category, + amount=Decimal("1234.56"), + date=date(2024, 5, 11), + ) + + response = download(auth_client, kind="expenses", fmt="excel") + + assert body(response).startswith("") + + +def test_standard_csv_has_no_bom(auth_client): + response = download(auth_client, kind="expenses", fmt="std") + + assert not body(response).startswith("") + + +def test_excel_csv_uses_semicolons_and_decimal_commas(auth_client, account, category): + Expense.objects.create( + owner=account.owner, + account=account, + category=category, + amount=Decimal("1234.56"), + date=date(2024, 5, 11), + ) + + rows = read_rows(download(auth_client, kind="expenses", fmt="excel")) + + assert rows[0][0] == "Fecha" + assert rows[1][0] == "11/05/2024" + assert rows[1][1] == "1234,56" + + +def test_standard_csv_uses_commas_and_decimal_dots(auth_client, account, category): + Expense.objects.create( + owner=account.owner, + account=account, + category=category, + amount=Decimal("1234.56"), + date=date(2024, 5, 11), + ) + + rows = read_rows(download(auth_client, kind="expenses", fmt="std"), delimiter=",") + + assert rows[1][0] == "2024-05-11" + assert rows[1][1] == "1234.56" + + +def test_a_concept_with_the_delimiter_inside_stays_in_one_cell( + auth_client, account, category +): + Expense.objects.create( + owner=account.owner, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + description="Cena; con punto y coma, y coma", + ) + + rows = read_rows(download(auth_client, kind="expenses")) + + assert rows[1][5] == "Cena; con punto y coma, y coma" + + +# -------------------------------------------------------------------------- +# Columnas +# -------------------------------------------------------------------------- + + +def test_expense_rows_carry_parent_category_and_tags(auth_client, user, account): + parent = Category.objects.create(owner=user, name="Casa") + child = Category.objects.create(owner=user, name="Luz", parent=parent) + expense = Expense.objects.create( + owner=user, + account=account, + category=child, + amount=Decimal("60"), + date=date(2024, 5, 11), + description="Factura", + ) + expense.tags.add( + Tag.objects.create(owner=user, name="fijo"), + Tag.objects.create(owner=user, name="hogar"), + ) + + rows = read_rows(download(auth_client, kind="expenses")) + + assert rows[1][2] == "Luz" + assert rows[1][3] == "Casa" + assert rows[1][4] == account.name + # Unidas con | porque la coma y el ; son delimitadores en uno u otro dialecto. + assert sorted(rows[1][6].split("|")) == ["fijo", "hogar"] + + +def test_a_top_level_category_leaves_the_parent_cell_empty( + auth_client, user, account, category +): + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + rows = read_rows(download(auth_client, kind="expenses")) + + assert rows[1][3] == "" + + +def test_income_rows_carry_account_and_concept(auth_client, user, account): + Income.objects.create( + owner=user, + account=account, + name="Nómina", + amount=Decimal("1500"), + date=date(2024, 5, 11), + ) + + rows = read_rows(download(auth_client, kind="incomes")) + + assert rows[0] == ["Fecha", "Importe", "Cuenta", "Concepto", "Traspaso"] + assert rows[1][2] == account.name + assert rows[1][3] == "Nómina" + + +def test_transfer_rows_carry_both_accounts(auth_client, user, account, savings): + make_transfer(user, account, savings, amount="400") + + rows = read_rows(download(auth_client, kind="transfers")) + + assert rows[0] == [ + "Fecha", + "Importe", + "Cuenta origen", + "Cuenta destino", + "Comisión", + ] + assert rows[1][1] == "400,00" + assert rows[1][2] == account.name + assert rows[1][3] == savings.name + + +def test_a_transfer_without_a_fee_leaves_the_fee_cell_empty( + auth_client, user, account, savings +): + make_transfer(user, account, savings) + + rows = read_rows(download(auth_client, kind="transfers")) + + # Vacío y no "0,00": un cero es un dato, y aquí el dato es "no hubo". + assert rows[1][4] == "" + + +def test_a_transfer_with_a_fee_carries_it(auth_client, user, account, savings): + make_transfer(user, account, savings, fee="1.50") + + rows = read_rows(download(auth_client, kind="transfers")) + + assert rows[1][4] == "1,50" + + +# -------------------------------------------------------------------------- +# Las patas del traspaso y la comisión: los dos avisos de la pantalla +# -------------------------------------------------------------------------- + + +def test_transfer_legs_also_show_up_flagged_in_the_movement_csvs( + auth_client, user, account, savings +): + make_transfer(user, account, savings, amount="400") + + expense_rows = read_rows(download(auth_client, kind="expenses")) + income_rows = read_rows(download(auth_client, kind="incomes")) + + # Sumar los tres ficheros duplica: esto es lo que avisa la pantalla. + assert [r[7] for r in expense_rows[1:]] == ["Sí"] + assert [r[4] for r in income_rows[1:]] == ["Sí"] + + +def test_the_fee_is_exported_as_a_normal_expense(auth_client, user, account, savings): + make_transfer(user, account, savings, amount="400", fee="1.50") + + rows = read_rows(download(auth_client, kind="expenses")) + by_amount = {row[1]: row for row in rows[1:]} + + # La pata va marcada como traspaso; la comisión no, porque es dinero que + # sale de verdad del patrimonio. + assert by_amount["400,00"][7] == "Sí" + assert by_amount["1,50"][7] == "No" + + +# -------------------------------------------------------------------------- +# Filtros +# -------------------------------------------------------------------------- + + +def test_the_date_range_filters_both_ends(auth_client, user, account, category): + for day in (1, 15, 28): + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(day), + date=date(2024, 5, day), + ) + + rows = read_rows( + download( + auth_client, + kind="expenses", + date_from="2024-05-10", + date_to="2024-05-20", + ) + ) + + assert [row[0] for row in rows[1:]] == ["15/05/2024"] + + +def test_the_account_filter_narrows_expenses(auth_client, user, account, savings, category): + for target in (account, savings): + Expense.objects.create( + owner=user, + account=target, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + rows = read_rows(download(auth_client, kind="expenses", account=account.pk)) + + assert [row[4] for row in rows[1:]] == [account.name] + + +def test_the_account_filter_catches_transfers_on_either_side( + auth_client, user, account, savings +): + third = Account.objects.create( + owner=user, name="Bolsillo", initial_balance=Decimal("0"), active=True + ) + make_transfer(user, account, savings, amount="100") + make_transfer(user, savings, account, amount="200") + make_transfer(user, savings, third, amount="300") + + rows = read_rows(download(auth_client, kind="transfers", account=account.pk)) + + assert sorted(row[1] for row in rows[1:]) == ["100,00", "200,00"] + + +def test_rows_come_out_ordered_by_date(auth_client, user, account, category): + for day in (28, 1, 15): + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, day), + ) + + rows = read_rows(download(auth_client, kind="expenses")) + + assert [row[0] for row in rows[1:]] == [ + "01/05/2024", + "15/05/2024", + "28/05/2024", + ] + + +def test_an_inactive_account_can_still_be_chosen(auth_client, user, account, category): + # Se exporta histórico: una cuenta cerrada tiene movimientos que interesan. + account.active = False + account.save() + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + ) + + rows = read_rows(download(auth_client, kind="expenses", account=account.pk)) + + assert len(rows) == 2 + + +# -------------------------------------------------------------------------- +# Aislamiento por usuario +# -------------------------------------------------------------------------- + + +def test_another_users_data_never_reaches_the_csv( + auth_client, user, account, category, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + their_account = Account.objects.create( + owner=intruder, name="Cuenta ajena", initial_balance=Decimal("0"), active=True + ) + their_category = Category.objects.create(owner=intruder, name="Ajena") + Expense.objects.create( + owner=intruder, + account=their_account, + category=their_category, + amount=Decimal("999"), + date=date(2024, 5, 11), + description="No es mío", + ) + Income.objects.create( + owner=intruder, + account=their_account, + name="Nómina ajena", + amount=Decimal("888"), + date=date(2024, 5, 11), + ) + Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal("10"), + date=date(2024, 5, 11), + description="Mío", + ) + + expenses_csv = body(download(auth_client, kind="expenses")) + incomes_csv = body(download(auth_client, kind="incomes")) + + assert "Mío" in expenses_csv + assert "No es mío" not in expenses_csv + assert "999" not in expenses_csv + assert "Nómina ajena" not in incomes_csv + + +def test_another_users_account_cannot_be_used_as_a_filter( + auth_client, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + their_account = Account.objects.create( + owner=intruder, name="Cuenta ajena", initial_balance=Decimal("0"), active=True + ) + + response = download(auth_client, kind="expenses", account=their_account.pk) + + assert response.status_code == 400 + + +# -------------------------------------------------------------------------- +# Parámetros inválidos +# -------------------------------------------------------------------------- + + +def test_an_unknown_kind_is_rejected(auth_client): + assert download(auth_client, kind="everything").status_code == 400 + + +def test_an_unknown_format_is_rejected(auth_client): + assert download(auth_client, kind="expenses", fmt="pdf").status_code == 400 + + +def test_a_broken_date_is_rejected(auth_client): + response = download(auth_client, kind="expenses", date_from="ayer") + + assert response.status_code == 400 + + +def test_an_inverted_range_is_rejected(auth_client): + response = download( + auth_client, kind="expenses", date_from="2024-05-20", date_to="2024-05-10" + ) + + # En un listado un rango invertido se ignora; aquí descargaría un fichero + # vacío sin explicación, así que se corta. + assert response.status_code == 400