diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 04bd9b3a10..426bdafc00 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -577,6 +577,11 @@ static const method_suffix_t route_reg_suffixes[] = { {".add_route", "ANY"}, {".add_api_route", "ANY"}, {".add_api_websocket_route", "ANY"}, + /* FastAPI/Starlette WebSocket decorators (`@router.websocket('/ws')`, + * `@app.websocket_route('/events')`): not an HTTP verb, so ANY — mirrors + * .add_api_websocket_route above (#1245). */ + {".websocket", "ANY"}, + {".websocket_route", "ANY"}, {NULL, NULL}, }; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 929fbd7604..481738fe85 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8230,6 +8230,56 @@ TEST(pipeline_capitalized_axios_import_is_http_client_sequential) { return capitalized_axios_case(0); } +/* FastAPI/Starlette WebSocket route registration (distilled from #1245). The + * route-registration suffix table knew `.add_api_websocket_route` but not the + * decorator forms `.websocket` / `.websocket_route`, so a WebSocket endpoint + * registered via `@router.websocket('/ws')` produced no Route node at all — + * the endpoint was invisible to the graph. `@app.get('/health')` is the + * positive control (already a route suffix). Fewer than 50 files exercises + * pass_calls.c; the same suffix table drives the parallel resolver. */ +TEST(pipeline_python_websocket_decorators_register_routes) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_ws_routes_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "app.py", + "from fastapi import APIRouter, FastAPI, WebSocket\n\n" + "app = FastAPI()\n" + "router = APIRouter()\n\n" + "@app.get('/health')\n" + "def health():\n" + " return {'ok': True}\n\n" + "@router.websocket('/ws')\n" + "async def ws_endpoint(websocket: WebSocket):\n" + " await websocket.accept()\n\n" + "@app.websocket_route('/events')\n" + "async def events_endpoint(websocket: WebSocket):\n" + " await websocket.accept()\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/py_ws.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Positive control: the HTTP decorator mints its Route node. */ + ASSERT_GTE(count_nodes_named(s, project, "/health"), 1); + /* The two WebSocket decorator forms must mint Route nodes too. */ + ASSERT_GTE(count_nodes_named(s, project, "/ws"), 1); + ASSERT_GTE(count_nodes_named(s, project, "/events"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Python bare-call local-binding suppression, sequential path. The bare-call * counterpart of the receiver guard above: `run` is a PARAMETER, so `run()` * cannot be the module-level `run` and must not bind SatoriLive.run. @@ -18410,6 +18460,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); RUN_TEST(pipeline_capitalized_axios_import_is_http_client); RUN_TEST(pipeline_capitalized_axios_import_is_http_client_sequential); + RUN_TEST(pipeline_python_websocket_decorators_register_routes); RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge);