mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 04:16:06 +00:00
Resolve the PR #8/#9/#10 stack on current dev, including Bitrix24 install callback hardening, migration renumbering, duplicate-domain fail-closed routing, UI textarea/mobile cleanup, and review hardening.
521 lines
13 KiB
Go
521 lines
13 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// --- mapToEnvSlice ---
|
|
|
|
func TestMapToEnvSlice_Empty(t *testing.T) {
|
|
got := mapToEnvSlice(nil)
|
|
if got != nil {
|
|
t.Errorf("nil map should return nil, got %v", got)
|
|
}
|
|
got = mapToEnvSlice(map[string]string{})
|
|
if got != nil {
|
|
t.Errorf("empty map should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestMapToEnvSlice_Singles(t *testing.T) {
|
|
got := mapToEnvSlice(map[string]string{"FOO": "bar"})
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(got))
|
|
}
|
|
if got[0] != "FOO=bar" {
|
|
t.Errorf("expected FOO=bar, got %q", got[0])
|
|
}
|
|
}
|
|
|
|
func TestMapToEnvSlice_MultipleEntries(t *testing.T) {
|
|
in := map[string]string{"A": "1", "B": "2", "C": "3"}
|
|
got := mapToEnvSlice(in)
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", len(got))
|
|
}
|
|
// All should be in KEY=VALUE format
|
|
set := make(map[string]bool)
|
|
for _, e := range got {
|
|
set[e] = true
|
|
}
|
|
for _, want := range []string{"A=1", "B=2", "C=3"} {
|
|
if !set[want] {
|
|
t.Errorf("missing %q in output %v", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- toSet ---
|
|
|
|
func TestToSet_Nil(t *testing.T) {
|
|
got := toSet(nil)
|
|
if got != nil {
|
|
t.Errorf("nil slice should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestToSet_Empty(t *testing.T) {
|
|
got := toSet([]string{})
|
|
if got != nil {
|
|
t.Errorf("empty slice should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestToSet_Values(t *testing.T) {
|
|
got := toSet([]string{"a", "b", "c"})
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", len(got))
|
|
}
|
|
for _, v := range []string{"a", "b", "c"} {
|
|
if _, ok := got[v]; !ok {
|
|
t.Errorf("missing %q in set", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToSet_Deduplication(t *testing.T) {
|
|
got := toSet([]string{"a", "a", "b"})
|
|
if len(got) != 2 {
|
|
t.Errorf("expected 2 unique entries, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
// --- joinErrors ---
|
|
|
|
func TestJoinErrors_Empty(t *testing.T) {
|
|
got := joinErrors(nil)
|
|
if got != "" {
|
|
t.Errorf("empty slice should return empty string, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestJoinErrors_Single(t *testing.T) {
|
|
got := joinErrors([]string{"error one"})
|
|
if got != "error one" {
|
|
t.Errorf("single error: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestJoinErrors_Multiple(t *testing.T) {
|
|
got := joinErrors([]string{"err1", "err2", "err3"})
|
|
want := "err1; err2; err3"
|
|
if got != want {
|
|
t.Errorf("joinErrors: got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// --- jsonBytesToStringSlice ---
|
|
|
|
func TestJSONBytesToStringSlice_Valid(t *testing.T) {
|
|
data, _ := json.Marshal([]string{"a", "b", "c"})
|
|
got := jsonBytesToStringSlice(data)
|
|
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
|
t.Errorf("got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestJSONBytesToStringSlice_Empty(t *testing.T) {
|
|
got := jsonBytesToStringSlice(nil)
|
|
if got != nil {
|
|
t.Errorf("nil input should return nil, got %v", got)
|
|
}
|
|
got = jsonBytesToStringSlice([]byte{})
|
|
if got != nil {
|
|
t.Errorf("empty input should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestJSONBytesToStringSlice_Invalid(t *testing.T) {
|
|
got := jsonBytesToStringSlice([]byte(`{not valid json`))
|
|
if got != nil {
|
|
t.Errorf("invalid JSON should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
// --- jsonBytesToStringMap ---
|
|
|
|
func TestJSONBytesToStringMap_Valid(t *testing.T) {
|
|
data, _ := json.Marshal(map[string]string{"key": "val", "foo": "bar"})
|
|
got := jsonBytesToStringMap(data)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(got))
|
|
}
|
|
if got["key"] != "val" {
|
|
t.Errorf("key: got %q", got["key"])
|
|
}
|
|
if got["foo"] != "bar" {
|
|
t.Errorf("foo: got %q", got["foo"])
|
|
}
|
|
}
|
|
|
|
func TestJSONBytesToStringMap_Empty(t *testing.T) {
|
|
got := jsonBytesToStringMap(nil)
|
|
if got != nil {
|
|
t.Errorf("nil input should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestJSONBytesToStringMap_Invalid(t *testing.T) {
|
|
got := jsonBytesToStringMap([]byte(`[not a map]`))
|
|
if got != nil {
|
|
t.Errorf("invalid JSON should return nil, got %v", got)
|
|
}
|
|
}
|
|
|
|
// resolveEnvVars tests are in manager_env_test.go (covers nil, env:, missing cases).
|
|
|
|
// --- requireUserCreds ---
|
|
|
|
func TestRequireUserCreds_Nil(t *testing.T) {
|
|
if requireUserCreds(nil) {
|
|
t.Error("nil settings should not require user creds")
|
|
}
|
|
}
|
|
|
|
func TestRequireUserCreds_Empty(t *testing.T) {
|
|
if requireUserCreds(json.RawMessage(`{}`)) {
|
|
t.Error("empty settings should not require user creds")
|
|
}
|
|
}
|
|
|
|
func TestRequireUserCreds_True(t *testing.T) {
|
|
settings := json.RawMessage(`{"require_user_credentials": true}`)
|
|
if !requireUserCreds(settings) {
|
|
t.Error("should require user credentials")
|
|
}
|
|
}
|
|
|
|
func TestRequireUserCreds_False(t *testing.T) {
|
|
settings := json.RawMessage(`{"require_user_credentials": false}`)
|
|
if requireUserCreds(settings) {
|
|
t.Error("should not require user credentials when false")
|
|
}
|
|
}
|
|
|
|
func TestRequireUserCreds_InvalidJSON(t *testing.T) {
|
|
// Invalid JSON → returns false (safe default)
|
|
if requireUserCreds(json.RawMessage(`{invalid}`)) {
|
|
t.Error("invalid JSON should return false")
|
|
}
|
|
}
|
|
|
|
// --- ParseToolHints ---
|
|
|
|
func TestParseToolHints_Nil(t *testing.T) {
|
|
h := ParseToolHints(nil)
|
|
if h.Global != "" || len(h.Tools) != 0 {
|
|
t.Errorf("nil settings should yield empty hints, got %+v", h)
|
|
}
|
|
}
|
|
|
|
func TestParseToolHints_Empty(t *testing.T) {
|
|
h := ParseToolHints(json.RawMessage(`{}`))
|
|
if h.Global != "" || len(h.Tools) != 0 {
|
|
t.Errorf("empty settings should yield empty hints, got %+v", h)
|
|
}
|
|
}
|
|
|
|
func TestParseToolHints_Full(t *testing.T) {
|
|
settings := json.RawMessage(`{
|
|
"require_user_credentials": true,
|
|
"tool_hints": {
|
|
"global": "No trailing semicolons.",
|
|
"tools": {
|
|
"search": "Use arrow func.",
|
|
"update": "entityId must be int."
|
|
}
|
|
}
|
|
}`)
|
|
h := ParseToolHints(settings)
|
|
if h.Global != "No trailing semicolons." {
|
|
t.Errorf("global mismatch: %q", h.Global)
|
|
}
|
|
if h.HintFor("search") != "Use arrow func." {
|
|
t.Errorf("search hint mismatch: %q", h.HintFor("search"))
|
|
}
|
|
if h.HintFor("update") != "entityId must be int." {
|
|
t.Errorf("update hint mismatch: %q", h.HintFor("update"))
|
|
}
|
|
if h.HintFor("nonexistent") != "" {
|
|
t.Errorf("unknown tool should return empty string")
|
|
}
|
|
}
|
|
|
|
func TestParseToolHints_InvalidJSON(t *testing.T) {
|
|
// Invalid JSON → zero-value hints (safe default)
|
|
h := ParseToolHints(json.RawMessage(`{invalid`))
|
|
if h.Global != "" || len(h.Tools) != 0 {
|
|
t.Errorf("invalid JSON should yield empty hints, got %+v", h)
|
|
}
|
|
}
|
|
|
|
func TestParseToolHints_NilHintsMap(t *testing.T) {
|
|
// HintFor must not panic when Tools map is nil
|
|
h := ToolHints{Global: "global only"}
|
|
if h.HintFor("anything") != "" {
|
|
t.Error("nil Tools map should return empty string, not panic")
|
|
}
|
|
}
|
|
|
|
// --- mcpBM25Index ---
|
|
|
|
func TestMCPBM25Index_EmptyIndex(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
results := idx.search("anything", 5)
|
|
if len(results) != 0 {
|
|
t.Errorf("empty index should return no results, got %d", len(results))
|
|
}
|
|
if idx.docCount() != 0 {
|
|
t.Errorf("empty index docCount should be 0, got %d", idx.docCount())
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Build_SingleTool(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
bt := makeBridgeToolWithDesc("myserver", "search_web", "Search the web for information")
|
|
idx.build([]*BridgeTool{bt})
|
|
|
|
if idx.docCount() != 1 {
|
|
t.Errorf("expected 1 doc, got %d", idx.docCount())
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Search_ExactMatch(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
tools := []*BridgeTool{
|
|
makeBridgeToolWithDesc("server1", "search_web", "Search the web using DuckDuckGo"),
|
|
makeBridgeToolWithDesc("server2", "run_code", "Execute Python code in sandbox"),
|
|
makeBridgeToolWithDesc("server3", "read_file", "Read file from filesystem"),
|
|
}
|
|
idx.build(tools)
|
|
|
|
results := idx.search("web search", 5)
|
|
if len(results) == 0 {
|
|
t.Fatal("expected results for 'web search'")
|
|
}
|
|
if results[0].ServerName != "server1" {
|
|
t.Errorf("expected server1 first, got %q", results[0].ServerName)
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Search_ZeroResults(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
idx.build([]*BridgeTool{
|
|
makeBridgeToolWithDesc("s", "tool", "does something"),
|
|
})
|
|
results := idx.search("xyzzy_unknown_query_abc", 5)
|
|
if len(results) != 0 {
|
|
t.Errorf("unknown query should return 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Search_EmptyQuery(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
idx.build([]*BridgeTool{
|
|
makeBridgeToolWithDesc("s", "tool", "description"),
|
|
})
|
|
results := idx.search("", 5)
|
|
if len(results) != 0 {
|
|
t.Errorf("empty query should return 0 results, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Search_MaxResultsRespected(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
tools := make([]*BridgeTool, 10)
|
|
for i := range tools {
|
|
tools[i] = makeBridgeToolWithDesc("server", "search_tool", "search web data tool")
|
|
}
|
|
idx.build(tools)
|
|
|
|
results := idx.search("search", 3)
|
|
if len(results) > 3 {
|
|
t.Errorf("maxResults=3 exceeded, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Search_ResultFields(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
bt := makeBridgeToolWithDesc("my-server", "query_db", "Query the database")
|
|
bt.registeredName = "mcp_my_server__query_db"
|
|
idx.build([]*BridgeTool{bt})
|
|
|
|
results := idx.search("query database", 5)
|
|
if len(results) == 0 {
|
|
t.Fatal("expected results")
|
|
}
|
|
r := results[0]
|
|
if r.OriginalName != "query_db" {
|
|
t.Errorf("OriginalName: got %q", r.OriginalName)
|
|
}
|
|
if r.ServerName != "my-server" {
|
|
t.Errorf("ServerName: got %q", r.ServerName)
|
|
}
|
|
if r.RegisteredName != "mcp_my_server__query_db" {
|
|
t.Errorf("RegisteredName: got %q", r.RegisteredName)
|
|
}
|
|
}
|
|
|
|
func TestMCPBM25Index_Rebuild(t *testing.T) {
|
|
idx := newMCPBM25Index()
|
|
idx.build([]*BridgeTool{
|
|
makeBridgeToolWithDesc("s", "old_tool", "old description search"),
|
|
})
|
|
|
|
// Rebuild with new tools
|
|
idx.build([]*BridgeTool{
|
|
makeBridgeToolWithDesc("s", "new_tool", "new description query"),
|
|
})
|
|
|
|
results := idx.search("old", 5)
|
|
if len(results) != 0 {
|
|
t.Errorf("after rebuild, old tools should be gone, got %d", len(results))
|
|
}
|
|
results = idx.search("new", 5)
|
|
if len(results) == 0 {
|
|
t.Error("after rebuild, new tools should be searchable")
|
|
}
|
|
}
|
|
|
|
// --- tokenizeMCP ---
|
|
|
|
func TestTokenizeMCP_BasicTokenization(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expect []string
|
|
}{
|
|
{"search web", []string{"search", "web"}},
|
|
{"Search Web", []string{"search", "web"}}, // lowercased
|
|
{"query_db tool", []string{"query", "db", "tool"}}, // underscore as separator
|
|
{"", nil},
|
|
{"a b c", nil}, // single-char tokens filtered
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := tokenizeMCP(tt.input)
|
|
if len(got) != len(tt.expect) {
|
|
t.Errorf("tokenizeMCP(%q) = %v, want %v", tt.input, got, tt.expect)
|
|
return
|
|
}
|
|
for i, tok := range got {
|
|
if tok != tt.expect[i] {
|
|
t.Errorf("tokenizeMCP(%q)[%d] = %q, want %q", tt.input, i, tok, tt.expect[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Pool key helpers ---
|
|
|
|
func TestPoolKey(t *testing.T) {
|
|
id := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
key := poolKey(id, "my-server")
|
|
if key != "00000000-0000-0000-0000-000000000001/my-server" {
|
|
t.Errorf("poolKey: got %q", key)
|
|
}
|
|
}
|
|
|
|
func TestUserPoolKey(t *testing.T) {
|
|
id := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
key := UserPoolKey(id, "server", "user123")
|
|
if key != "00000000-0000-0000-0000-000000000001/server/user:user123" {
|
|
t.Errorf("UserPoolKey: got %q", key)
|
|
}
|
|
}
|
|
|
|
// --- Pool: NewPool / Stop ---
|
|
|
|
func TestNewPool_Defaults(t *testing.T) {
|
|
p := NewPool(PoolConfig{})
|
|
// Should start without panic and stop cleanly
|
|
t.Cleanup(func() {
|
|
p.Stop()
|
|
})
|
|
// Default config applied
|
|
if p.cfg.MaxSize != 200 {
|
|
t.Errorf("default MaxSize: got %d", p.cfg.MaxSize)
|
|
}
|
|
if p.cfg.MaxIdle != 20 {
|
|
t.Errorf("default MaxIdle: got %d", p.cfg.MaxIdle)
|
|
}
|
|
}
|
|
|
|
func TestPool_Release_NonExistent(t *testing.T) {
|
|
p := NewPool(PoolConfig{MaxSize: 5})
|
|
t.Cleanup(func() { p.Stop() })
|
|
// Release on non-existent key should not panic
|
|
p.Release("nonexistent/key")
|
|
}
|
|
|
|
func TestPool_ReleaseUser_NonExistent(t *testing.T) {
|
|
p := NewPool(PoolConfig{MaxSize: 5})
|
|
t.Cleanup(func() { p.Stop() })
|
|
p.ReleaseUser("nonexistent/key")
|
|
}
|
|
|
|
func TestPool_Stop_EmptyPool(t *testing.T) {
|
|
p := NewPool(PoolConfig{MaxSize: 5})
|
|
// Stop on empty pool should not panic
|
|
p.Stop()
|
|
}
|
|
|
|
// --- Manager: NewManager / Stop ---
|
|
|
|
func TestNewManager_Empty(t *testing.T) {
|
|
m := NewManager(nil)
|
|
if m == nil {
|
|
t.Fatal("NewManager should not return nil")
|
|
}
|
|
if m.servers == nil {
|
|
t.Error("servers map should be initialized")
|
|
}
|
|
}
|
|
|
|
func TestManager_Stop_Empty(t *testing.T) {
|
|
m := NewManager(nil)
|
|
// Stop on empty manager should not panic
|
|
m.Stop()
|
|
}
|
|
|
|
func TestManager_ServerStatus_Empty(t *testing.T) {
|
|
m := NewManager(nil)
|
|
statuses := m.ServerStatus()
|
|
if len(statuses) != 0 {
|
|
t.Errorf("empty manager should have 0 server statuses, got %d", len(statuses))
|
|
}
|
|
}
|
|
|
|
func TestManager_IsSearchMode_Default(t *testing.T) {
|
|
m := NewManager(nil)
|
|
if m.IsSearchMode() {
|
|
t.Error("new manager should not be in search mode")
|
|
}
|
|
}
|
|
|
|
func TestManager_DeferredToolInfos_Empty(t *testing.T) {
|
|
m := NewManager(nil)
|
|
tools := m.DeferredToolInfos()
|
|
if len(tools) != 0 {
|
|
t.Errorf("empty manager should have 0 deferred tools, got %d", len(tools))
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
// makeBridgeTool builds a minimal BridgeTool for testing.
|
|
func makeBridgeToolWithDesc(serverName, toolName, description string) *BridgeTool {
|
|
return &BridgeTool{
|
|
serverName: serverName,
|
|
toolName: toolName,
|
|
registeredName: "mcp_" + serverName + "__" + toolName,
|
|
description: description,
|
|
inputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
|
|
requiredSet: map[string]bool{},
|
|
}
|
|
}
|
|
|