From 0666e17a2595d2c3b8bd8780f410d9c469f16c73 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 08:53:16 +0200 Subject: [PATCH] Add smoke test for all views and context key tests --- expenses_manager/expenses/tests/test_smoke.py | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_smoke.py diff --git a/expenses_manager/expenses/tests/test_smoke.py b/expenses_manager/expenses/tests/test_smoke.py new file mode 100644 index 0000000..6fa49ae --- /dev/null +++ b/expenses_manager/expenses/tests/test_smoke.py @@ -0,0 +1,202 @@ +"""Red de seguridad: que todas las vistas rendericen y que el contexto no pierda claves. + +Dos agujeros que ni `manage.py check` ni el resto de la suite cubren: + +1. Un `{% url %}` mal escrito o una plantilla que falta solo revienta al pedir + la página. Es lo que pasó con `settings/index.html`, que estuvo roto sin que + nada avisara. +2. Un refactor que se lleva una clave del contexto por delante. Ya pasó dos + veces: `by_month` renombrado a `chart_data`, y `last_expenses` sustituido por + `movements`. Las claves que usan muchos tests están cubiertas de rebote; las + que se usan en un solo sitio se pierden en silencio. +""" + +import pytest +from django.urls import reverse + +from expenses import urls as expenses_urls + +pytestmark = pytest.mark.django_db + + +# -------------------------------------------------------------------------- +# 1. Smoke test de renderizado +# -------------------------------------------------------------------------- + +# Rutas que a propósito NO devuelven 200. Van una por línea con su motivo: la +# alternativa (aceptar cualquier 2xx/3xx) haría pasar el test aunque la vista +# estuviera rota, que es justo lo contrario de lo que se busca aquí. +NOT_RENDERABLE = { + # Endpoint de descarga, no página: valida ExportForm(request.GET) y sin + # `kind` ni `fmt` responde 400 por diseño. Su cobertura está en test_export. + "export_download", +} + + +def _argument_free_route_names(): + """Rutas del URLconf que se pueden pedir sin argumentos. + + Se saca del URLconf y no de una lista escrita a mano: así una vista nueva + entra en el smoke test el día que se añade, sin que nadie se acuerde. Una + lista a mano se queda desfasada y el test deja de servir en silencio. + """ + return [ + pattern.name + for pattern in expenses_urls.urlpatterns + if pattern.pattern.regex.groups == 0 + and pattern.name + and pattern.name not in NOT_RENDERABLE + ] + + +ROUTE_NAMES = _argument_free_route_names() + + +def test_the_route_list_is_not_empty(): + # Si el recorrido del URLconf se rompiera, el parametrize saldría vacío y + # los tests de abajo "pasarían" sin pedir una sola página. + assert len(ROUTE_NAMES) > 15 + + +@pytest.mark.parametrize("route_name", ROUTE_NAMES) +def test_every_view_renders(auth_client, route_name): + # Una ruta por caso para que el fallo diga qué vista se rompió. + response = auth_client.get(reverse(route_name)) + + assert response.status_code == 200 + + +@pytest.mark.parametrize("route_name", sorted(NOT_RENDERABLE)) +def test_the_excluded_routes_are_still_excluded_for_a_reason(auth_client, route_name): + # Si una excluida empieza a devolver 200, la exclusión sobra y hay que + # quitarla: así la lista no se queda con motivos caducados dentro. + response = auth_client.get(reverse(route_name)) + + assert response.status_code != 200 + + +# -------------------------------------------------------------------------- +# 2. Claves del contexto +# -------------------------------------------------------------------------- + +DASHBOARD_CONTEXT_KEYS = { + # Navegación y fecha de referencia + "active_menu", + "today", + # KPIs + "kpi_total", + "kpi_count", + "kpi_categories", + "kpi_balance", + # Comparativa con el periodo anterior + "compare_enabled", + "compare_suppressed", + "kpi_previous_total", + "kpi_difference", + "kpi_difference_abs", + "kpi_percentage", + "kpi_trend", + "category_comparison", + # Gráficos: eje compartido, serie de gastos, serie de ingresos y desglose + "chart_labels", + "chart_data", + "chart_income_data", + "chart_type", + "by_category", + "by_category_chart", + "accounts_charts", + # Filtros de periodo, cuenta y rango libre + "period", + "year_list", + "months", + "selected_year", + "selected_month", + "date_from", + "date_to", + "range_active", + "accounts", + "selected_account", + "selected_account_obj", + # Objetivos y listado reciente + "goals", + "recent_expenses", + # Medias y proyección + "daily_average", + "projected_end_of_month", +} + +HOME_CONTEXT_KEYS = { + "active_menu", + # KPIs del mes y su comparación con el anterior + "kpi_total", + "kpi_count", + "kpi_categories", + "prev_total", + "diff_amount", + "diff_pct", + # Saldos y alertas + "total_balance", + "account_balances", + "negative_accounts", + "has_alerts", + # Objetivos + "goals", + "exceeded_goals", + # Movimientos recientes y la mini gráfica + "movements", + "mini_chart_labels", + "mini_chart_data", +} + + +def _assert_context_has(response, expected_keys): + """Inclusión, nunca igualdad. + + Lo que hay que detectar es que falte una clave. Que aparezca una nueva es lo + normal al añadir una funcionalidad, así que endurecer esto a igualdad exacta + daría un test que falla cada vez que alguien amplía la vista, y acabaría + borrado por pesado. No lo cambies a ==. + """ + missing = sorted(key for key in expected_keys if key not in response.context) + + assert not missing, "faltan claves en el contexto: %s" % ", ".join(missing) + + +def test_dashboard_context_keeps_all_its_keys(auth_client): + response = _dashboard(auth_client) + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_dashboard_context_keeps_its_keys_with_comparison_on(auth_client): + # La comparativa rellena claves que en el camino normal se quedan a cero. + response = _dashboard(auth_client, compare="1") + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_dashboard_context_keeps_its_keys_with_a_free_range(auth_client): + # El rango libre entra por otra rama del eje, y es la que trae + # compara_suppressed y deja projected_end_of_month a cero. + response = _dashboard(auth_client, date_from="2024-03-01", date_to="2024-03-30") + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_dashboard_context_keeps_its_keys_with_a_preset(auth_client): + response = _dashboard(auth_client, period="this_month") + + _assert_context_has(response, DASHBOARD_CONTEXT_KEYS) + + +def test_home_context_keeps_all_its_keys(auth_client): + response = auth_client.get(reverse("home")) + + _assert_context_has(response, HOME_CONTEXT_KEYS) + + +def _dashboard(client, **params): + response = client.get(reverse("dashboard"), params) + + assert response.status_code == 200 + return response