Files
Duy /zuey/ ef829c1ab4 fix(bitrix24): resolve PR 8-10 stack findings
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.
2026-05-22 20:53:01 +07:00

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{},
}
}