Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/cbm/service_patterns.c
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};

Expand Down
51 changes: 51 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading