Files
deusdata--codebase-memory-mcp/tests/test_integration.c
T
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

683 lines
23 KiB
C

/*
* 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 <mcp/mcp.h>
#include <store/store.h>
#include <pipeline/pipeline.h>
#include <foundation/log.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
/* ── 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/.../<project>.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, &params, &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();
}