From 26a73d1d3b241838e4a22ae25afd0c58537afe34 Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 30 Apr 2026 22:35:11 +0100 Subject: [PATCH] feat: Update test configurations and add smoke tests for named view routes Co-authored-by: Copilot --- docker/docker-compose.test.yml | 12 ++- generic/views.py | 3 +- pytest.ini | 2 +- rad/settings_test.py | 26 ++++++ rad/tests/test_view_smoke.py | 141 +++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 rad/settings_test.py create mode 100644 rad/tests/test_view_smoke.py diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 11ba476e..2e38116d 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -14,13 +14,21 @@ services: POSTGRES_USER: ${POSTGRES_USER:-django} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-rad} + ports: + - "5432:5432" volumes: - postgres_test_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL","pg_isready -U ${POSTGRES_USER:-django}" ] - interval: 10s + interval: 5s timeout: 5s - retries: 5 + retries: 10 + networks: + - test_net + +networks: + test_net: + name: rad_test_net volumes: postgres_test_data: diff --git a/generic/views.py b/generic/views.py index 76bbc219..8bd74882 100644 --- a/generic/views.py +++ b/generic/views.py @@ -4506,8 +4506,7 @@ class GenericViewBase: @method_decorator(login_required) def author_detail(self, request, pk): # logging.debug(Author.objects.all()) - # author = get_object_or_404(Author, pk=pk) - author = User.objects.get(pk=pk) + author = get_object_or_404(User, pk=pk) questions = self.question_object.objects.filter(author=pk) diff --git a/pytest.ini b/pytest.ini index 31bba4c7..46aa1f08 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] -DJANGO_SETTINGS_MODULE=rad.settings +DJANGO_SETTINGS_MODULE=rad.settings_test python_files = tests.py test_*.py *_tests.py addopts = --nomigrations \ No newline at end of file diff --git a/rad/settings_test.py b/rad/settings_test.py new file mode 100644 index 00000000..d4d14d3e --- /dev/null +++ b/rad/settings_test.py @@ -0,0 +1,26 @@ +import os + +from .settings import * + + +# Test environment should be deterministic and isolated from remote services. +DEBUG = False + +# Local Postgres defaults align with docker-compose.test.yml. +DATABASES["default"] = { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("TEST_DB_NAME", os.environ.get("POSTGRES_DB", "rad")), + "USER": os.environ.get("TEST_DB_USER", os.environ.get("POSTGRES_USER", "django")), + "PASSWORD": os.environ.get("TEST_DB_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "postgres")), + "HOST": os.environ.get("TEST_DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("TEST_DB_PORT", "5432"), + "TEST": { + "NAME": os.environ.get("TEST_DB_TEST_NAME", "test_rad"), + }, +} + +# Keep tests fast and avoid external side effects. +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +CACHES["default"] = { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", +} diff --git a/rad/tests/test_view_smoke.py b/rad/tests/test_view_smoke.py new file mode 100644 index 00000000..c2e8c33e --- /dev/null +++ b/rad/tests/test_view_smoke.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable +from uuid import UUID + +import pytest +from django.contrib.auth import get_user_model +from django.test import Client +from django.urls import URLPattern, URLResolver, get_resolver, reverse + + +SAFE_STATUS_CODES = { + 200, + 201, + 202, + 204, + 301, + 302, + 303, + 304, + 307, + 308, + 400, + 401, + 403, + 404, + 405, +} + +SKIP_NAMES = { + "admin:index", # admin app has its own dedicated tests and redirects heavily. +} + + +@dataclass(frozen=True) +class NamedRoute: + name: str + kwargs: dict[str, object] + + +def _default_for_converter(converter_name: str) -> object: + if converter_name == "int": + return 1 + if converter_name == "str": + return "smoke" + if converter_name == "slug": + return "smoke" + if converter_name == "uuid": + return UUID("00000000-0000-0000-0000-000000000001") + if converter_name == "path": + return "smoke/path" + return "smoke" + + +def _iter_named_patterns( + patterns: Iterable[URLPattern | URLResolver], + namespace: str = "", +) -> Iterable[NamedRoute]: + for pattern in patterns: + if isinstance(pattern, URLResolver): + nested_namespace = namespace + if pattern.namespace: + nested_namespace = f"{namespace}:{pattern.namespace}" if namespace else pattern.namespace + yield from _iter_named_patterns(pattern.url_patterns, nested_namespace) + continue + + if not pattern.name: + continue + + full_name = f"{namespace}:{pattern.name}" if namespace else pattern.name + converters = getattr(pattern.pattern, "converters", {}) + kwargs = {key: _default_for_converter(converter.__class__.__name__.replace("Converter", "").lower()) for key, converter in converters.items()} + yield NamedRoute(name=full_name, kwargs=kwargs) + + +def _all_named_routes() -> list[NamedRoute]: + resolver = get_resolver() + routes = list(_iter_named_patterns(resolver.url_patterns)) + + unique_routes: dict[tuple[str, tuple[tuple[str, object], ...]], NamedRoute] = {} + for route in routes: + key = (route.name, tuple(sorted(route.kwargs.items()))) + unique_routes[key] = route + + return sorted(unique_routes.values(), key=lambda route: (route.name, sorted(route.kwargs))) + + +ALL_NAMED_ROUTES = _all_named_routes() + + +@pytest.fixture +def smoke_client(db) -> Client: + user_model = get_user_model() + user = user_model.objects.create_user( + username="view_smoke_user", + password="testpassword", + email="view_smoke_user@example.com", + ) + client = Client() + client.raise_request_exception = False + client.force_login(user) + return client + + +@pytest.mark.django_db +@pytest.mark.parametrize("route", ALL_NAMED_ROUTES, ids=lambda route: route.name) +def test_named_view_routes_do_not_return_server_errors(smoke_client: Client, route: NamedRoute): + if route.name in SKIP_NAMES: + pytest.skip("Route is intentionally excluded from this smoke suite.") + + try: + url = reverse(route.name, kwargs=route.kwargs or None) + except Exception as exc: # pragma: no cover + pytest.skip(f"Unable to reverse route {route.name}: {exc}") + + response = smoke_client.get(url, follow=False) + + assert response.status_code in SAFE_STATUS_CODES, ( + f"Unexpected status for {route.name} ({url}): {response.status_code}" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("route", ALL_NAMED_ROUTES, ids=lambda route: route.name) +def test_named_view_routes_anonymous_do_not_return_server_errors(route: NamedRoute): + if route.name in SKIP_NAMES: + pytest.skip("Route is intentionally excluded from this smoke suite.") + + try: + url = reverse(route.name, kwargs=route.kwargs or None) + except Exception as exc: # pragma: no cover + pytest.skip(f"Unable to reverse route {route.name}: {exc}") + + client = Client() + client.raise_request_exception = False + response = client.get(url, follow=False) + + assert response.status_code in SAFE_STATUS_CODES, ( + f"Unexpected anonymous status for {route.name} ({url}): {response.status_code}" + )