Add tests for CSV export
This commit is contained in:
parent
dfebee1a09
commit
68fdb5a644
547
expenses_manager/expenses/tests/test_export.py
Normal file
547
expenses_manager/expenses/tests/test_export.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user