diff --git a/expenses_manager/expenses/tests/test_date_range.py b/expenses_manager/expenses/tests/test_date_range.py new file mode 100644 index 0000000..445e0e0 --- /dev/null +++ b/expenses_manager/expenses/tests/test_date_range.py @@ -0,0 +1,271 @@ +"""Rango de fechas libre (date_from / date_to) en expense_list y dashboard. + +La regla que estructura todo: si hay rango activo, manda el rango y los +selectores de año y mes no se aplican. Un solo criterio de periodo a la vez. +""" +import pytest +from datetime import date, timedelta +from decimal import Decimal +from django.urls import reverse +from expenses.models import Expense + +pytestmark = pytest.mark.django_db + + +def make_expense(user, account, category, amount, on): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + ) + + +# -------------------------------------------------------------------------- +# expense_list +# -------------------------------------------------------------------------- + +def test_expense_list_filters_by_range(auth_client, user, account, category): + inside = make_expense(user, account, category, "10", date(2024, 3, 15)) + before = make_expense(user, account, category, "20", date(2024, 2, 20)) + after = make_expense(user, account, category, "30", date(2024, 4, 5)) + + response = auth_client.get( + reverse("expense_list"), + {"date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + rows = list(response.context["page_obj"]) + assert rows == [inside] + assert before not in rows and after not in rows + assert response.context["range_active"] is True + assert response.context["kpi_total"] == Decimal("10") + + +def test_expense_list_open_ended_range_reaches_the_end(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 2, 20)) + march = make_expense(user, account, category, "20", date(2024, 3, 15)) + december = make_expense(user, account, category, "30", date(2024, 12, 31)) + + response = auth_client.get(reverse("expense_list"), {"date_from": "2024-03-01"}) + + rows = list(response.context["page_obj"]) + assert set(rows) == {march, december} + + +def test_expense_list_open_ended_range_reaches_the_start(auth_client, user, account, category): + old = make_expense(user, account, category, "10", date(2020, 1, 1)) + february = make_expense(user, account, category, "20", date(2024, 2, 20)) + make_expense(user, account, category, "30", date(2024, 4, 5)) + + response = auth_client.get(reverse("expense_list"), {"date_to": "2024-03-01"}) + + rows = list(response.context["page_obj"]) + assert set(rows) == {old, february} + + +def test_expense_list_inverted_range_is_ignored_entirely(auth_client, user, account, category): + """Un rango invertido no debe devolver una lista vacía sin explicación.""" + expenses = [ + make_expense(user, account, category, "10", date(2024, 2, 20)), + make_expense(user, account, category, "20", date(2024, 3, 15)), + ] + + response = auth_client.get( + reverse("expense_list"), + {"date_from": "2024-12-01", "date_to": "2024-01-01"}, + ) + + assert response.context["range_active"] is False + assert set(response.context["page_obj"]) == set(expenses) + + +def test_expense_list_malformed_date_is_ignored(auth_client, user, account, category): + expense = make_expense(user, account, category, "10", date(2024, 3, 15)) + + response = auth_client.get(reverse("expense_list"), {"date_from": "loquesea"}) + + assert response.status_code == 200 + assert response.context["range_active"] is False + assert list(response.context["page_obj"]) == [expense] + + +def test_expense_list_range_overrides_year_and_month(auth_client, user, account, category): + """El año viene en la URL pero no debe aplicarse: manda el rango.""" + in_2023 = make_expense(user, account, category, "10", date(2023, 5, 10)) + make_expense(user, account, category, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("expense_list"), + {"year": 2024, "month": 5, "date_from": "2023-01-01", "date_to": "2023-12-31"}, + ) + + assert list(response.context["page_obj"]) == [in_2023] + + +# -------------------------------------------------------------------------- +# dashboard +# -------------------------------------------------------------------------- + +def test_dashboard_filters_by_range(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 15)) + make_expense(user, account, category, "20", date(2024, 4, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + assert response.context["range_active"] is True + assert response.context["kpi_total"] == Decimal("10") + assert response.context["kpi_count"] == 1 + + +def test_dashboard_range_overrides_year(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2023, 5, 10)) + make_expense(user, account, category, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("dashboard"), + {"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"}, + ) + + assert response.context["kpi_total"] == Decimal("10") + + +def test_dashboard_inverted_range_falls_back_to_the_normal_period( + auth_client, user, account, category +): + make_expense(user, account, category, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("dashboard"), + {"year": 2024, "date_from": "2024-12-01", "date_to": "2024-01-01"}, + ) + + assert response.context["range_active"] is False + assert response.context["kpi_total"] == Decimal("20") + + +def test_dashboard_short_range_charts_by_day(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-03-01", "date_to": "2024-03-30"}, # 30 días + ) + + assert response.context["chart_type"] == "day" + labels = response.context["chart_labels"] + assert len(labels) == 30 + assert labels[0] == "01/03" + assert labels[4] == "05/03" + # El eje es denso: el día con gasto lleva su importe, el resto va a cero. + assert response.context["chart_data"][4] == 10.0 + assert response.context["chart_data"][0] == 0 + + +def test_dashboard_long_range_charts_by_month(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 2, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-01-01", "date_to": "2024-04-29"}, # 120 días + ) + + assert response.context["chart_type"] == "month" + labels = response.context["chart_labels"] + assert labels == ["01/2024", "02/2024", "03/2024", "04/2024"] + assert response.context["chart_data"][1] == 10.0 + + +def test_dashboard_range_threshold_is_62_days(auth_client, user, account, category): + start = date(2024, 3, 1) + + at_limit = auth_client.get( + reverse("dashboard"), + {"date_from": start.isoformat(), "date_to": (start + timedelta(days=61)).isoformat()}, + ) + over_limit = auth_client.get( + reverse("dashboard"), + {"date_from": start.isoformat(), "date_to": (start + timedelta(days=62)).isoformat()}, + ) + + assert at_limit.context["chart_type"] == "day" # 62 días justos + assert over_limit.context["chart_type"] == "month" # 63 días + + +def test_dashboard_range_disables_comparison(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 15)) + + response = auth_client.get( + reverse("dashboard"), + {"compare": "1", "date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + assert response.context["compare_enabled"] is False + assert response.context["compare_suppressed"] is True + assert response.context["kpi_previous_total"] == 0 + assert response.context["category_comparison"] == [] + + +def test_dashboard_range_skips_the_end_of_month_projection( + auth_client, user, account, category +): + today = date.today() + make_expense(user, account, category, "10", today) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": today.replace(day=1).isoformat()}, + ) + + assert response.context["projected_end_of_month"] == 0 + + +def test_dashboard_range_daily_average_uses_the_range_length( + auth_client, user, account, category +): + make_expense(user, account, category, "100", date(2024, 3, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-03-01", "date_to": "2024-03-10"}, # 10 días + ) + + assert response.context["daily_average"] == Decimal("10") + + +def test_dashboard_empty_open_range_does_not_break(auth_client, user): + """Sin date_from y sin gastos no hay longitud que medir: eje mensual vacío.""" + response = auth_client.get(reverse("dashboard"), {"date_to": "2024-03-31"}) + + assert response.status_code == 200 + assert response.context["chart_type"] == "month" + assert response.context["chart_labels"] == [] + + +# -------------------------------------------------------------------------- +# Sin rango no cambia nada +# -------------------------------------------------------------------------- + +def test_dashboard_without_range_keeps_the_old_day_axis(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 5)) + + response = auth_client.get(reverse("dashboard"), {"year": 2024, "month": 3}) + + assert response.context["range_active"] is False + assert response.context["chart_type"] == "day" + # Números de día, no fechas. + assert response.context["chart_labels"] == list(range(1, 32)) + + +def test_dashboard_without_range_keeps_the_old_month_axis(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 5)) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["chart_type"] == "month" + assert response.context["chart_labels"][0] == "Ene" + assert len(response.context["chart_labels"]) == 12