diff --git a/framework/hosting/tests/test_app.py b/framework/hosting/tests/test_app.py index e938a2d6..6066daff 100644 --- a/framework/hosting/tests/test_app.py +++ b/framework/hosting/tests/test_app.py @@ -27,12 +27,14 @@ async def test_app_state_has_registries(self, app: FastAPI): async def test_modules_enabled_limits_loaded_modules(self, settings: Settings): """Host respects settings.modules_enabled — only listed modules contribute routes.""" + from simple_module_test import effective_route_paths + # Only Auth should be loaded; Dashboard routes must be absent. restricted = settings.model_copy(update={"modules_enabled": ["Auth"]}) app = create_app(restricted) - paths: set[str] = {str(r.path) for r in app.routes if hasattr(r, "path")} + paths = effective_route_paths(app) # Auth is now contracts-only, so it has no routes — only health remains. - assert "/dashboard" not in paths + assert not any(p.startswith("/dashboard") for p in paths) async def test_module_static_mounts_become_app_routes( self, @@ -203,7 +205,9 @@ async def test_empty_env_var_uses_fallback(self, monkeypatch, tmp_path): class TestRouteRegistration: async def test_expected_routes_registered(self, app: FastAPI): """All modules should have their routes registered in the app.""" - route_paths = [r.path for r in app.routes if hasattr(r, "path")] + from simple_module_test import effective_route_paths + + route_paths = effective_route_paths(app) assert "/health" in route_paths assert "/health/live" in route_paths @@ -216,8 +220,9 @@ async def test_expected_routes_registered(self, app: FastAPI): # landing page at "/" is owned by the host and added in host/main.py, # which the create_app fixture doesn't run. assert "/dashboard/" in route_paths - # Bare-prefix alias — see wire_module_routes for the X-Inertia rationale. - assert "/dashboard" in route_paths + # The bare-prefix Inertia alias ("/dashboard" without the slash) is + # registered with include_in_schema=False, so it isn't enumerable here; + # TestProtectedPages::test_dashboard_redirects_unauthenticated covers it. class TestProtectedPages: diff --git a/framework/testing/simple_module_test/__init__.py b/framework/testing/simple_module_test/__init__.py index a658960c..a23fe2c6 100644 --- a/framework/testing/simple_module_test/__init__.py +++ b/framework/testing/simple_module_test/__init__.py @@ -22,11 +22,13 @@ from simple_module_test.app_factory import build_test_app from simple_module_test.fake_events import FakeEventBus, RecordedEvent +from simple_module_test.routes import effective_route_paths from simple_module_test.session_cookie import forge_session_cookie __all__ = [ "FakeEventBus", "RecordedEvent", "build_test_app", + "effective_route_paths", "forge_session_cookie", ] diff --git a/framework/testing/simple_module_test/routes.py b/framework/testing/simple_module_test/routes.py new file mode 100644 index 00000000..b4e7b5d1 --- /dev/null +++ b/framework/testing/simple_module_test/routes.py @@ -0,0 +1,34 @@ +"""Enumerate an app's effective route paths, robust to lazy router inclusion. + +FastAPI 0.137 / Starlette 1.3 made ``include_router()`` lazy: an included router +now appears in ``app.routes`` as a ``_IncludedRouter`` wrapper that carries no +``.path`` and resolves its routes only at request time. Code that introspects +``app.routes`` by ``.path`` therefore no longer sees routes contributed via +``include_router`` — including every module's API/view routes and the health +router. (Routes still resolve correctly at request time; only static +introspection broke.) + +:func:`effective_route_paths` reads the OpenAPI schema instead — a stable public +API that lists every schema-included route with its fully-resolved prefix — and +unions it with any top-level routes/mounts that still carry a ``.path`` directly +(e.g. ``StaticFiles`` mounts). This works across FastAPI versions and is the +supported way to assert on registered routes. +""" + +from __future__ import annotations + +from fastapi import FastAPI + + +def effective_route_paths(app: FastAPI) -> set[str]: + """Return the set of route paths registered on ``app``. + + Includes schema routes contributed via ``include_router`` (resolved through + the OpenAPI schema, so FastAPI's lazy ``_IncludedRouter`` wrappers don't hide + them) plus any top-level mounts. Routes registered with + ``include_in_schema=False`` — e.g. the bare-prefix Inertia aliases — are not + listed here; assert those with a request instead. + """ + paths = set(app.openapi().get("paths", {}).keys()) + paths |= {r.path for r in app.routes if getattr(r, "path", None) is not None} + return paths diff --git a/framework/testing/tests/test_app_factory.py b/framework/testing/tests/test_app_factory.py index 53e16f3e..c93ca625 100644 --- a/framework/testing/tests/test_app_factory.py +++ b/framework/testing/tests/test_app_factory.py @@ -46,11 +46,10 @@ async def test_returns_fastapi_instance_from_instance(self): async def test_registers_module_routes_with_prefix(self): """The module's register_routes() runs and its api routes appear under the prefix.""" - from simple_module_test import build_test_app + from simple_module_test import build_test_app, effective_route_paths app = build_test_app(_EchoModule) - paths = {getattr(r, "path", None) for r in app.routes} - assert "/api/echo/ping" in paths + assert "/api/echo/ping" in effective_route_paths(app) async def test_module_accessible_on_app_state(self): """The instance is stored on app.state.module so tests can poke at it.""" @@ -61,12 +60,12 @@ async def test_module_accessible_on_app_state(self): async def test_isolates_from_other_installed_modules(self): """build_test_app only mounts the given module — Products/Auth routes are absent.""" - from simple_module_test import build_test_app + from simple_module_test import build_test_app, effective_route_paths app = build_test_app(_EchoModule) - paths = {getattr(r, "path", None) for r in app.routes} - assert not any(p and p.startswith("/api/products") for p in paths) - assert not any(p and p.startswith("/auth") for p in paths) + paths = effective_route_paths(app) + assert not any(p.startswith("/api/products") for p in paths) + assert not any(p.startswith("/auth") for p in paths) # ── pytest plugin fixtures ──────────────────────────────────────────