/* * test_integration.c — End-to-end integration tests for the pure C pipeline. * * Creates a temporary project with real source files, indexes it through * the full pipeline, then queries the result through MCP tool handlers. * * This exercises the complete flow: discover → extract → registry → graph * buffer → SQLite dump → query. No mocking — real files, real parsing. */ #include "../src/foundation/compat.h" #include "test_framework.h" #include "test_helpers.h" #include #include #include #include #include #include #include #include #include /* ── Test fixture: temp project with Python + Go files ─────────── */ static char g_tmpdir[256]; static char g_dbpath[512]; static cbm_mcp_server_t *g_srv = NULL; static char *g_project = NULL; /* Create source files in temp directory */ static int create_test_project(void) { snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_integ_XXXXXX"); if (!cbm_mkdtemp(g_tmpdir)) return -1; char path[512]; FILE *f; /* Python file with function calls */ snprintf(path, sizeof(path), "%s/main.py", g_tmpdir); f = fopen(path, "w"); if (!f) return -1; fprintf(f, "def greet(name):\n" " return 'Hello ' + name\n" "\n" "def farewell(name):\n" " return 'Goodbye ' + name\n" "\n" "def main():\n" " msg = greet('World')\n" " msg2 = farewell('World')\n" " print(msg, msg2)\n"); fclose(f); /* Go file with function calls */ snprintf(path, sizeof(path), "%s/utils.go", g_tmpdir); f = fopen(path, "w"); if (!f) return -1; fprintf(f, "package utils\n" "\n" "func Add(a, b int) int {\n" " return a + b\n" "}\n" "\n" "func Multiply(a, b int) int {\n" " sum := Add(a, b)\n" " return sum * 2\n" "}\n" "\n" "func Compute(x int) int {\n" " return Multiply(x, Add(x, 1))\n" "}\n"); fclose(f); /* JavaScript file */ snprintf(path, sizeof(path), "%s/app.js", g_tmpdir); f = fopen(path, "w"); if (!f) return -1; fprintf(f, "function validate(input) {\n" " return input != null;\n" "}\n" "\n" "function process(data) {\n" " if (validate(data)) {\n" " return data.toUpperCase();\n" " }\n" " return null;\n" "}\n"); fclose(f); return 0; } /* Set up: create project, index it through MCP (production flow) */ static int integration_setup(void) { if (create_test_project() != 0) return -1; /* Derive project name (same logic the pipeline uses) */ g_project = cbm_project_name_from_path(g_tmpdir); if (!g_project) return -1; /* Build db path for direct store queries (pipeline writes here) */ const char *home = getenv("HOME"); if (!home) home = "/tmp"; snprintf(g_dbpath, sizeof(g_dbpath), "%s/.cache/codebase-memory-mcp/%s.db", home, g_project); /* Ensure cache dir exists */ char cache_dir[512]; snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); cbm_mkdir(cache_dir); /* Remove stale db from previous test runs */ unlink(g_dbpath); /* Create MCP server, then index through it (production flow): * 1. Server starts with in-memory store * 2. index_repository closes in-memory store * 3. Pipeline runs → dumps to ~/.cache/.../.db * 4. Server reopens from that db * This exercises the exact same path as real usage. */ g_srv = cbm_mcp_server_new(NULL); if (!g_srv) return -1; /* Index our temp project via MCP tool handler */ char args[512]; snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", g_tmpdir); char *resp = cbm_mcp_handle_tool(g_srv, "index_repository", args); if (!resp) return -1; /* Verify indexing succeeded */ bool ok = strstr(resp, "indexed") != NULL; free(resp); return ok ? 0 : -1; } static void integration_teardown(void) { if (g_srv) { cbm_mcp_server_free(g_srv); g_srv = NULL; } free(g_project); g_project = NULL; /* Clean up temp project */ th_rmtree(g_tmpdir); /* Clean up cache db */ unlink(g_dbpath); char wal[520], shm[520]; snprintf(wal, sizeof(wal), "%s-wal", g_dbpath); snprintf(shm, sizeof(shm), "%s-shm", g_dbpath); unlink(wal); unlink(shm); } /* ══════════════════════════════════════════════════════════════════ * PIPELINE INTEGRATION TESTS * ══════════════════════════════════════════════════════════════════ */ /* Helper: call a tool and return response JSON. Caller must free(). */ static char *call_tool(const char *tool, const char *args) { if (!g_srv) return NULL; return cbm_mcp_handle_tool(g_srv, tool, args); } TEST(integ_index_has_nodes) { /* Open the indexed db directly and check node counts */ cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); int nodes = cbm_store_count_nodes(store, g_project); /* We expect: 3 File nodes + 3+ Function/Method nodes per file + * Folder/Package/Module nodes. Should be at least 8. */ ASSERT_TRUE(nodes >= 8); cbm_store_close(store); PASS(); } TEST(integ_index_has_edges) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); int edges = cbm_store_count_edges(store, g_project); /* We expect CONTAINS_FILE edges + CALLS edges + others */ ASSERT_TRUE(edges >= 3); cbm_store_close(store); PASS(); } TEST(integ_index_has_functions) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *funcs = NULL; int count = 0; int rc = cbm_store_find_nodes_by_label(store, g_project, "Function", &funcs, &count); ASSERT_EQ(rc, CBM_STORE_OK); /* Python: greet, farewell, main. Go: Add, Multiply, Compute. JS: validate, process */ ASSERT_TRUE(count >= 6); /* Verify some function names exist */ bool found_greet = false, found_add = false, found_validate = false; for (int i = 0; i < count; i++) { if (funcs[i].name && strcmp(funcs[i].name, "greet") == 0) found_greet = true; if (funcs[i].name && strcmp(funcs[i].name, "Add") == 0) found_add = true; if (funcs[i].name && strcmp(funcs[i].name, "validate") == 0) found_validate = true; } ASSERT_TRUE(found_greet); ASSERT_TRUE(found_add); ASSERT_TRUE(found_validate); cbm_store_free_nodes(funcs, count); cbm_store_close(store); PASS(); } TEST(integ_index_has_files) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *files = NULL; int count = 0; int rc = cbm_store_find_nodes_by_label(store, g_project, "File", &files, &count); ASSERT_EQ(rc, CBM_STORE_OK); ASSERT_EQ(count, 3); /* main.py, utils.go, app.js */ bool found_py = false, found_go = false, found_js = false; for (int i = 0; i < count; i++) { if (files[i].file_path && strstr(files[i].file_path, "main.py")) found_py = true; if (files[i].file_path && strstr(files[i].file_path, "utils.go")) found_go = true; if (files[i].file_path && strstr(files[i].file_path, "app.js")) found_js = true; } ASSERT_TRUE(found_py); ASSERT_TRUE(found_go); ASSERT_TRUE(found_js); cbm_store_free_nodes(files, count); cbm_store_close(store); PASS(); } TEST(integ_index_has_calls) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); int call_count = cbm_store_count_edges_by_type(store, g_project, "CALLS"); /* Python: main→greet, main→farewell, main→print * Go: Multiply→Add, Compute→Multiply, Compute→Add * JS: process→validate */ ASSERT_TRUE(call_count >= 4); cbm_store_close(store); PASS(); } /* ══════════════════════════════════════════════════════════════════ * MCP TOOL HANDLER INTEGRATION * ══════════════════════════════════════════════════════════════════ */ TEST(integ_mcp_list_projects) { char *resp = call_tool("list_projects", "{}"); ASSERT_NOT_NULL(resp); /* Should contain the project name derived from temp path */ ASSERT_NOT_NULL(strstr(resp, "project")); free(resp); PASS(); } TEST(integ_mcp_search_graph_by_label) { char args[256]; snprintf(args, sizeof(args), "{\"label\":\"Function\",\"project\":\"%s\",\"limit\":20}", g_project); char *resp = call_tool("search_graph", args); ASSERT_NOT_NULL(resp); /* Should return function nodes */ ASSERT_NOT_NULL(strstr(resp, "Function")); /* Should contain our known functions */ ASSERT_NOT_NULL(strstr(resp, "greet")); free(resp); PASS(); } TEST(integ_mcp_search_graph_by_name) { char args[256]; snprintf(args, sizeof(args), "{\"name_pattern\":\".*Add.*\",\"project\":\"%s\"}", g_project); char *resp = call_tool("search_graph", args); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "Add")); free(resp); PASS(); } TEST(integ_mcp_query_graph_functions) { char args[512]; snprintf(args, sizeof(args), "{\"project\":\"%s\",\"query\":\"MATCH (f:Function) WHERE f.project = '%s' " "RETURN f.name LIMIT 20\"}", g_project, g_project); char *resp = call_tool("query_graph", args); ASSERT_NOT_NULL(resp); /* Should return results (may be in various formats depending on Cypher output). * At minimum, should not be an error. */ ASSERT_TRUE(strstr(resp, "row") || strstr(resp, "greet") || strstr(resp, "Add") || strstr(resp, "result") || strstr(resp, "f.name")); free(resp); PASS(); } TEST(integ_mcp_query_graph_calls) { char args[512]; snprintf(args, sizeof(args), "{\"project\":\"%s\",\"query\":\"MATCH (a)-[r:CALLS]->(b) WHERE a.project = '%s' " "RETURN a.name, b.name LIMIT 20\"}", g_project, g_project); char *resp = call_tool("query_graph", args); ASSERT_NOT_NULL(resp); /* Should have some call relationships */ ASSERT_NOT_NULL(strstr(resp, "name")); free(resp); PASS(); } TEST(integ_mcp_get_graph_schema) { char args[128]; snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project); char *resp = call_tool("get_graph_schema", args); ASSERT_NOT_NULL(resp); /* Schema should include node labels and edge types */ ASSERT_NOT_NULL(strstr(resp, "Function")); ASSERT_NOT_NULL(strstr(resp, "File")); free(resp); PASS(); } TEST(integ_mcp_get_architecture) { char args[128]; snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project); char *resp = call_tool("get_architecture", args); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "total_nodes")); free(resp); PASS(); } TEST(integ_mcp_trace_path) { /* Trace outbound calls from Compute → should reach Add and Multiply */ char args[256]; snprintf(args, sizeof(args), "{\"function_name\":\"Compute\",\"project\":\"%s\"," "\"direction\":\"outbound\",\"max_depth\":3}", g_project); char *resp = call_tool("trace_path", args); ASSERT_NOT_NULL(resp); /* Should find the function and show some path */ /* Either finds the function, or returns not found if name doesn't match exactly */ ASSERT_TRUE(strstr(resp, "Compute") || strstr(resp, "Multiply") || strstr(resp, "not found")); free(resp); PASS(); } /* #522: trace_path mode=cross_service must follow CROSS_* cross-repo edges. * Seed a CROSS_HTTP_CALLS edge between two indexed functions that have no CALLS * relationship, then confirm cross_service surfaces the hop while the default * calls mode does not (proving the cross edge specifically is what's followed). * * The trace goes through a fresh server so it opens the db after the edge is * committed — exactly what a new MCP session sees after a cross-repo pass writes * CROSS_* edges (g_srv's cached connection predates this write). */ TEST(integ_mcp_trace_path_cross_service) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *src = NULL; cbm_node_t *dst = NULL; int src_count = 0; int dst_count = 0; cbm_store_find_nodes_by_name(store, g_project, "greet", &src, &src_count); cbm_store_find_nodes_by_name(store, g_project, "farewell", &dst, &dst_count); ASSERT_TRUE(src_count > 0 && dst_count > 0); cbm_edge_t edge = {.project = g_project, .source_id = src[0].id, .target_id = dst[0].id, .type = "CROSS_HTTP_CALLS"}; ASSERT_TRUE(cbm_store_insert_edge(store, &edge) > 0); cbm_store_free_nodes(src, src_count); cbm_store_free_nodes(dst, dst_count); cbm_store_close(store); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); char args[256]; snprintf(args, sizeof(args), "{\"function_name\":\"greet\",\"project\":\"%s\"," "\"direction\":\"outbound\",\"mode\":\"cross_service\"}", g_project); char *resp = cbm_mcp_handle_tool(srv, "trace_path", args); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "farewell")); free(resp); snprintf(args, sizeof(args), "{\"function_name\":\"greet\",\"project\":\"%s\"," "\"direction\":\"outbound\",\"mode\":\"calls\"}", g_project); resp = cbm_mcp_handle_tool(srv, "trace_path", args); ASSERT_NOT_NULL(resp); ASSERT_TRUE(strstr(resp, "farewell") == NULL); free(resp); cbm_mcp_server_free(srv); PASS(); } TEST(integ_mcp_index_status) { char args[128]; snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project); char *resp = call_tool("index_status", args); ASSERT_NOT_NULL(resp); /* Should show indexed status with node/edge counts */ ASSERT_NOT_NULL(strstr(resp, g_project)); free(resp); PASS(); } TEST(integ_mcp_delete_project) { /* Delete the project and verify it's gone */ char args[256]; snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project); char *resp = call_tool("delete_project", args); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "deleted")); free(resp); /* Note: querying after delete on Linux re-opens the unlinked .db inode * (unlink defers removal until all fds close). SQLite's WAL mode connection * on an unlinked file leaks internal allocations that sqlite3_close cannot * reclaim. Guard behavior for deleted/missing projects is tested separately * in tests/smoke_guard.sh using non-existent project names. */ PASS(); } /* ══════════════════════════════════════════════════════════════════ * PIPELINE DIRECT API TESTS * ══════════════════════════════════════════════════════════════════ */ TEST(integ_pipeline_fqn_compute) { char *fqn = cbm_pipeline_fqn_compute("myproject", "src/utils.go", "Add"); ASSERT_NOT_NULL(fqn); ASSERT_STR_EQ(fqn, "myproject.src.utils.Add"); free(fqn); PASS(); } TEST(integ_pipeline_fqn_module) { char *fqn = cbm_pipeline_fqn_module("myproject", "src/utils.go"); ASSERT_NOT_NULL(fqn); ASSERT_STR_EQ(fqn, "myproject.src.utils"); free(fqn); PASS(); } TEST(integ_pipeline_project_name) { char *name = cbm_project_name_from_path("/home/user/my-project"); ASSERT_NOT_NULL(name); /* Should contain "my-project" or a sanitized version */ ASSERT_NOT_NULL(strstr(name, "my-project")); free(name); PASS(); } TEST(integ_pipeline_cancel) { /* Create and immediately cancel a pipeline */ cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, NULL, CBM_MODE_FULL); ASSERT_NOT_NULL(p); cbm_pipeline_cancel(p); int rc = cbm_pipeline_run(p); /* Should return -1 (cancelled) or complete with partial results */ /* Either way, it shouldn't crash */ (void)rc; cbm_pipeline_free(p); PASS(); } /* ══════════════════════════════════════════════════════════════════ * STORE QUERY INTEGRATION * ══════════════════════════════════════════════════════════════════ */ TEST(integ_store_search_by_degree) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); /* Find functions with at least 1 outbound call */ cbm_search_params_t params = {0}; params.project = g_project; params.label = "Function"; params.min_degree = 1; params.max_degree = -1; params.limit = 10; cbm_search_output_t out = {0}; int rc = cbm_store_search(store, ¶ms, &out); ASSERT_EQ(rc, CBM_STORE_OK); /* main, Multiply, Compute, process should all have outbound calls */ ASSERT_TRUE(out.count >= 1); cbm_store_search_free(&out); cbm_store_close(store); PASS(); } TEST(integ_store_find_by_file) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *nodes = NULL; int count = 0; int rc = cbm_store_find_nodes_by_file(store, g_project, "main.py", &nodes, &count); ASSERT_EQ(rc, CBM_STORE_OK); /* main.py should have: greet, farewell, main functions + Module node */ ASSERT_TRUE(count >= 3); cbm_store_free_nodes(nodes, count); cbm_store_close(store); PASS(); } TEST(integ_store_bfs_traversal) { cbm_store_t *store = cbm_store_open_path(g_dbpath); ASSERT_NOT_NULL(store); /* Find a function node to start BFS from */ cbm_node_t *results = NULL; int count = 0; cbm_store_find_nodes_by_name(store, g_project, "Multiply", &results, &count); if (count > 0) { /* BFS outbound from Multiply */ cbm_traverse_result_t trav = {0}; int rc = cbm_store_bfs(store, results[0].id, "outbound", NULL, 0, 3, 20, &trav); ASSERT_EQ(rc, CBM_STORE_OK); /* Should visit at least Add */ ASSERT_TRUE(trav.visited_count >= 0); /* might be 0 if no edges */ cbm_store_traverse_free(&trav); } cbm_store_free_nodes(results, count); cbm_store_close(store); PASS(); } /* #411: index_repository silently drops entire subtrees with no record. * Moderate/fast mode applies FAST_SKIP_DIRS (tools/scripts/bin/docs/...) and ALL * modes apply ALWAYS_SKIP_DIRS (node_modules/...), so files are excluded from the * graph — but the response only reports nodes/edges/status, giving the user no * way to know which subtrees were dropped (the reporter lost a 47-file tools/). * Desired (maintainer): a COMPACT per-directory summary of excluded files (dir + * count, not a verbose per-file list) surfaced in the index result for any mode. * RED until the index response reports excluded subtrees. Self-contained. */ TEST(index_reports_excluded_subtrees_issue411) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_excl_XXXXXX"); ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); char path[512]; /* one real source file ... */ snprintf(path, sizeof(path), "%s/app.py", tmp); FILE *f = fopen(path, "wb"); ASSERT_NOT_NULL(f); fputs("def app():\n return 1\n", f); fclose(f); /* ... and a node_modules subtree that is excluded in EVERY mode. */ snprintf(path, sizeof(path), "%s/node_modules", tmp); cbm_mkdir_p(path, 0755); snprintf(path, sizeof(path), "%s/node_modules/dep.js", tmp); f = fopen(path, "wb"); ASSERT_NOT_NULL(f); fputs("export function dep() { return 2; }\n", f); fclose(f); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); char args[600]; snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp); char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); ASSERT_NOT_NULL(resp); /* The dropped node_modules/dep.js must be reported somewhere compact in the * response so the user knows it wasn't indexed. Today the response carries no * excluded/skipped summary at all → this fails (reproduces the silent drop). */ bool reports_excluded = strstr(resp, "excluded") != NULL || strstr(resp, "skipped") != NULL; free(resp); cbm_mcp_server_free(srv); th_rmtree(tmp); ASSERT_TRUE(reports_excluded); PASS(); } /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ SUITE(integration) { RUN_TEST(index_reports_excluded_subtrees_issue411); /* Set up: create temp project and index it */ if (integration_setup() != 0) { /* A suite that cannot establish its preconditions has FAILED, not * "skipped" — surface it as a single red failure (no-skips policy). */ printf(" %sFAIL%s %s:%d: %s\n", tf_red(), tf_reset(), __FILE__, __LINE__, "integration_setup failed"); tf_fail_count++; integration_teardown(); return; } /* Pipeline result validation */ RUN_TEST(integ_index_has_nodes); RUN_TEST(integ_index_has_edges); RUN_TEST(integ_index_has_functions); RUN_TEST(integ_index_has_files); RUN_TEST(integ_index_has_calls); /* MCP tool handler validation */ RUN_TEST(integ_mcp_list_projects); RUN_TEST(integ_mcp_search_graph_by_label); RUN_TEST(integ_mcp_search_graph_by_name); RUN_TEST(integ_mcp_query_graph_functions); RUN_TEST(integ_mcp_query_graph_calls); RUN_TEST(integ_mcp_get_graph_schema); RUN_TEST(integ_mcp_get_architecture); RUN_TEST(integ_mcp_trace_path); RUN_TEST(integ_mcp_trace_path_cross_service); RUN_TEST(integ_mcp_index_status); /* Store query validation */ RUN_TEST(integ_store_search_by_degree); RUN_TEST(integ_store_find_by_file); RUN_TEST(integ_store_bfs_traversal); /* Pipeline API tests (no db needed) */ RUN_TEST(integ_pipeline_fqn_compute); RUN_TEST(integ_pipeline_fqn_module); RUN_TEST(integ_pipeline_project_name); RUN_TEST(integ_pipeline_cancel); /* Destructive tests (run last!) */ RUN_TEST(integ_mcp_delete_project); /* Teardown */ integration_teardown(); }