99from pytest_mock import MockerFixture
1010from starlette .types import Message , Receive , Scope , Send
1111
12- from app .main import GlobalExceptionMiddleware , RestApiMetricsMiddleware
12+ from app .main import (
13+ GlobalExceptionMiddleware ,
14+ RestApiMetricsMiddleware ,
15+ app_routes_paths ,
16+ )
17+ from app .main import (
18+ app as fastapi_app ,
19+ )
1320from models .api .responses .error import InternalServerErrorResponse
1421
1522
@@ -189,6 +196,7 @@ async def test_rest_api_metrics_strips_root_path(
189196) -> None :
190197 """Middleware must strip root_path so prefixed requests still match routes."""
191198 mocker .patch ("app.main.app_routes_paths" , ["/v1/infer" ])
199+ mocker .patch .object (fastapi_app , "root_path" , "/api/lightspeed" )
192200 mock_measure_duration = mocker .patch (
193201 "app.main.recording.measure_response_duration" , return_value = nullcontext ()
194202 )
@@ -201,9 +209,9 @@ async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
201209 middleware = RestApiMetricsMiddleware (ok_app )
202210 collector = _ResponseCollector ()
203211
204- # Simulate 3scale forwarding /api/lightspeed/v1/infer with root_path set .
212+ # Simulate 3scale forwarding /api/lightspeed/v1/infer — scope carries no root_path .
205213 await middleware (
206- _make_scope ("/api/lightspeed/v1/infer" , root_path = "/api/lightspeed" ),
214+ _make_scope ("/api/lightspeed/v1/infer" ),
207215 _noop_receive ,
208216 collector ,
209217 )
@@ -241,3 +249,57 @@ async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
241249 assert collector .status_code == 200
242250 mock_measure_duration .assert_called_once_with ("/v1/infer" )
243251 mock_record_call .assert_called_once_with ("/v1/infer" , 200 )
252+
253+
254+ @pytest .mark .asyncio
255+ async def test_rest_api_metrics_uses_app_root_path_not_scope (
256+ mocker : MockerFixture ,
257+ ) -> None :
258+ """Middleware must read root_path from app.root_path, not scope["root_path"].
259+
260+ The scope carries an empty root_path while app.root_path holds the real prefix.
261+ If the middleware reads from the scope it will not strip the prefix, the path
262+ will not match any route, and no metric will be recorded — causing both
263+ mock_measure_duration and mock_record_call assertions to fail.
264+ """
265+ mocker .patch ("app.main.app_routes_paths" , ["/v1/infer" ])
266+ mocker .patch .object (fastapi_app , "root_path" , "/api/lightspeed" )
267+ mock_measure_duration = mocker .patch (
268+ "app.main.recording.measure_response_duration" , return_value = nullcontext ()
269+ )
270+ mock_record_call = mocker .patch ("app.main.recording.record_rest_api_call" )
271+
272+ async def ok_app (_scope : Scope , _receive : Receive , send : Send ) -> None :
273+ await send ({"type" : "http.response.start" , "status" : 200 , "headers" : []})
274+ await send ({"type" : "http.response.body" , "body" : b"ok" })
275+
276+ middleware = RestApiMetricsMiddleware (ok_app )
277+ collector = _ResponseCollector ()
278+
279+ # scope["root_path"] is explicitly empty while app.root_path is "/api/lightspeed".
280+ # The middleware must use app.root_path to strip the prefix correctly.
281+ scope = _make_scope ("/api/lightspeed/v1/infer" )
282+ scope ["root_path" ] = ""
283+ await middleware (scope , _noop_receive , collector )
284+
285+ assert collector .status_code == 200
286+ mock_measure_duration .assert_called_once_with ("/v1/infer" )
287+ mock_record_call .assert_called_once_with ("/v1/infer" , 200 )
288+
289+
290+ # ---------------------------------------------------------------------------
291+ # app_routes_paths population
292+ # ---------------------------------------------------------------------------
293+
294+
295+ def test_app_routes_paths_contains_application_routes () -> None :
296+ """app_routes_paths must include routes registered via include_router.
297+
298+ FastAPI >= 0.137 stores included routers as _IncludedRouter objects that
299+ the old isinstance(route, (Mount, Route, WebSocketRoute)) filter silently
300+ drops. iter_route_contexts() resolves them correctly. If this test fails
301+ with only 4 entries (the FastAPI built-ins), the fix has been reverted.
302+ """
303+ assert "/liveness" in app_routes_paths
304+ assert "/readiness" in app_routes_paths
305+ assert len (app_routes_paths ) > 4
0 commit comments