Files
zzet--gortex/internal/mcp/tools_graph_query_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

190 lines
6.6 KiB
Go

package mcp
import (
"encoding/json"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/query"
)
// runGraphQuery executes a graph_query call and unmarshals the JSON
// SubGraph result. It fails the test on a tool error.
func runGraphQuery(t *testing.T, srv *Server, q string, limit int) query.SubGraph {
t.Helper()
args := map[string]any{"query": q}
if limit > 0 {
args["limit"] = limit
}
result := callTool(t, srv, "graph_query", args)
require.False(t, result.IsError, "graph_query errored: %s", toolResultText(result))
var sg query.SubGraph
require.NoError(t, json.Unmarshal([]byte(result.Content[0].(mcplib.TextContent).Text), &sg))
return sg
}
func gqNodeIDs(sg query.SubGraph) map[string]bool {
ids := make(map[string]bool, len(sg.Nodes))
for _, n := range sg.Nodes {
ids[n.ID] = true
}
return ids
}
// TestGraphQuery_NodesSeed covers the 'nodes' stage with each filter.
func TestGraphQuery_NodesSeed(t *testing.T) {
srv, _ := setupTestServer(t)
t.Run("kind filter", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes kind=function", 0)
require.NotEmpty(t, sg.Nodes)
for _, n := range sg.Nodes {
assert.Equal(t, "function", string(n.Kind))
}
assert.True(t, gqNodeIDs(sg)["main.go::main"])
assert.True(t, gqNodeIDs(sg)["main.go::helper"])
})
t.Run("name regex filter", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes name~^help", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::helper"])
assert.False(t, ids["main.go::main"])
})
t.Run("kind type filter", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes kind=type", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::Config"], "Config struct must be found")
})
t.Run("lang filter", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes kind=function lang=go", 0)
require.NotEmpty(t, sg.Nodes)
for _, n := range sg.Nodes {
assert.Equal(t, "go", n.Language)
}
})
t.Run("multiple filters are ANDed", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes kind=function name~^main$", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::main"])
assert.False(t, ids["main.go::helper"])
})
}
// TestGraphQuery_Traverse covers the 'traverse' stage.
func TestGraphQuery_Traverse(t *testing.T) {
srv, _ := setupTestServer(t)
t.Run("out reaches callee", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes name~^main$ kind=function | traverse calls out", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::helper"], "traverse calls out from main must reach helper")
})
t.Run("in reaches caller", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes name~^helper$ kind=function | traverse calls in", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::main"], "traverse calls in from helper must reach main")
})
t.Run("default direction is out", func(t *testing.T) {
sg := runGraphQuery(t, srv, "nodes name~^main$ kind=function | traverse calls", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::helper"])
})
t.Run("traverse replaces working set", func(t *testing.T) {
// After traverse, the result is the expanded set (helper), not main.
sg := runGraphQuery(t, srv, "nodes name~^main$ kind=function | traverse calls out", 0)
ids := gqNodeIDs(sg)
assert.False(t, ids["main.go::main"], "traverse must replace the working set, not union it")
})
}
// TestGraphQuery_FilterStage covers the 'filter' stage narrowing.
func TestGraphQuery_FilterStage(t *testing.T) {
srv, _ := setupTestServer(t)
sg := runGraphQuery(t, srv, "nodes kind=function | filter name~^main$", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::main"])
assert.False(t, ids["main.go::helper"], "filter must drop non-matching nodes")
}
// TestGraphQuery_MultiStage runs a full three-stage pipeline.
func TestGraphQuery_MultiStage(t *testing.T) {
srv, _ := setupTestServer(t)
sg := runGraphQuery(t, srv,
"nodes kind=function | filter name~^main$ | traverse calls out", 0)
ids := gqNodeIDs(sg)
assert.True(t, ids["main.go::helper"], "pipeline must end at helper")
}
// TestGraphQuery_Limit enforces the result cap.
func TestGraphQuery_Limit(t *testing.T) {
srv, _ := setupTestServer(t)
sg := runGraphQuery(t, srv, "nodes kind=function", 1)
assert.LessOrEqual(t, len(sg.Nodes), 1, "limit must cap the node set")
}
// TestGraphQuery_Malformed verifies that bad queries return clear errors.
func TestGraphQuery_Malformed(t *testing.T) {
srv, _ := setupTestServer(t)
cases := []struct {
name string
query string
wantInErr string
}{
{"empty string", "", "empty query"},
{"blank", " ", "empty query"},
{"unknown verb", "fetch kind=function", "unknown stage verb"},
{"traverse first", "traverse calls out", "cannot be the first stage"},
{"filter first", "filter kind=function", "cannot be the first stage"},
{"nodes not first", "nodes kind=function | nodes kind=type", "must be the first stage"},
{"malformed filter", "nodes kindx=function", "malformed filter"},
{"empty filter value", "nodes kind=", "empty value"},
{"bad name regex", "nodes name~[unclosed", "invalid name~ regex"},
{"traverse bad edge kind", "nodes kind=function | traverse bogus out", "unknown edge kind"},
{"traverse bad direction", "nodes kind=function | traverse calls sideways", "direction must be"},
{"traverse no edges", "nodes kind=function | traverse", "needs an edge-kind list"},
{"too many stages", "nodes kind=function | filter kind=function | filter kind=function | filter kind=function | filter kind=function | filter kind=function", "too many stages"},
{"empty stage", "nodes kind=function | ", "is empty"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := callTool(t, srv, "graph_query", map[string]any{"query": tc.query})
require.True(t, result.IsError, "expected an error for query %q", tc.query)
assert.Contains(t, toolResultText(result), tc.wantInErr)
})
}
}
// TestGraphQuery_MissingArg rejects a call with no query argument.
func TestGraphQuery_MissingArg(t *testing.T) {
srv, _ := setupTestServer(t)
result := callTool(t, srv, "graph_query", map[string]any{})
require.True(t, result.IsError)
assert.Contains(t, toolResultText(result), "query is required")
}
// TestGraphQuery_GCXFormat verifies the GCX wire format is produced.
func TestGraphQuery_GCXFormat(t *testing.T) {
srv, _ := setupTestServer(t)
result := callTool(t, srv, "graph_query", map[string]any{
"query": "nodes kind=function",
"format": "gcx",
})
require.False(t, result.IsError)
assert.Contains(t, toolResultText(result), "graph_query.nodes")
}