Compare commits

...

2 Commits

Author SHA1 Message Date
c058bc2cf1 Merge branch 'dev' of https://gitea.kuijper.es/jkuijperm/expenses_manager into dev
All checks were successful
Tests / CI / Run tests passed: 333
expenses_manager/pipeline/head This commit looks good
2026-10-14 08:53:23 +02:00
0666e17a25 Add smoke test for all views and context key tests 2026-10-14 08:53:16 +02:00

View File

@ -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