package tools import ( "context" "encoding/json" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "isphere-ai-bridge/internal/isphere" "isphere-ai-bridge/internal/msglib" ) func TestISphereSearchContactsToolReturnsJIDContacts(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ ID: "msg-1", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "bob@imopenfire1-lanzhou", }}}, } session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereContactTools(server, fake) }) defer cleanup() callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameSearchContacts, Arguments: map[string]any{"query": "alice", "limit": 10}, }) if err != nil { t.Fatalf("call %s: %v", ToolNameSearchContacts, err) } if callResult.IsError { t.Fatalf("call result is error: %+v", callResult) } var decoded struct { OK bool `json:"ok"` Contacts []struct { ContactID string `json:"contact_id"` DisplayName string `json:"display_name"` Account string `json:"account"` Source string `json:"source"` Confidence float64 `json:"confidence"` RawRef string `json:"raw_ref"` } `json:"contacts"` NextCursor any `json:"next_cursor"` Audit map[string]any `json:"audit"` } payload, err := json.Marshal(callResult.StructuredContent) if err != nil { t.Fatalf("marshal structured content: %v", err) } if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode structured content %s: %v", payload, err) } if !decoded.OK { t.Fatalf("ok = false in %s", payload) } if len(decoded.Contacts) != 1 { t.Fatalf("contacts = %+v, want one", decoded.Contacts) } contact := decoded.Contacts[0] if contact.ContactID != "alice@imopenfire1-lanzhou" || contact.DisplayName != "alice@imopenfire1-lanzhou" || contact.Account != "alice@imopenfire1-lanzhou" { t.Fatalf("unexpected contact identity: %+v", contact) } if contact.Source != "local_readonly" || contact.Confidence != 0.6 || contact.RawRef != "message_jid" { t.Fatalf("unexpected contact metadata: %+v", contact) } if decoded.Audit["tool"] != ToolNameSearchContacts || decoded.Audit["result"] != "ok" { t.Fatalf("audit = %+v", decoded.Audit) } if len(fake.queries) != 1 || fake.queries[0].Limit != 0 { t.Fatalf("source queries = %+v, want one unbounded receive query", fake.queries) } } func TestISphereSearchContactsToolUsesInjectedDisplayEntities(t *testing.T) { fakeMessages := &fakeReceiveMessagesSource{} fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{{ EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "alice@example", DisplayName: "Alice Zhang", Confidence: 0.9, MatchedColumns: []string{"PersonJid", "PersonName"}, }}} session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay) }) defer cleanup() callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameSearchContacts, Arguments: map[string]any{"query": "alice", "limit": 5}, }) if err != nil { t.Fatalf("call %s: %v", ToolNameSearchContacts, err) } if callResult.IsError { t.Fatalf("call result is error: %+v", callResult) } var decoded struct { Contacts []struct { ContactID string `json:"contact_id"` DisplayName string `json:"display_name"` Account string `json:"account"` Source string `json:"source"` Confidence float64 `json:"confidence"` RawRef string `json:"raw_ref"` } `json:"contacts"` } payload, _ := json.Marshal(callResult.StructuredContent) if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode structured content %s: %v", payload, err) } if len(decoded.Contacts) != 1 { t.Fatalf("contacts = %+v, want one", decoded.Contacts) } contact := decoded.Contacts[0] if contact.ContactID != "alice@example" || contact.DisplayName != "Alice Zhang" || contact.Account != "alice@example" { t.Fatalf("unexpected MsgLib contact identity: %+v", contact) } if contact.Source != "msglib_readonly" || contact.Confidence != 0.9 || contact.RawRef != "msglib:TD_CustomEffigy" { t.Fatalf("unexpected MsgLib contact metadata: %+v", contact) } if len(fakeDisplay.queries) != 1 || fakeDisplay.queries[0].EntityType != msglib.EntityTypeContacts || fakeDisplay.queries[0].Query != "alice" || fakeDisplay.queries[0].Limit != 5 { t.Fatalf("display queries = %+v", fakeDisplay.queries) } } func TestISphereSearchContactsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) { fakeMessages := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ ID: "msg-1", SenderID: "target@imopenfire1-lanzhou", ReceiverID: "other@imopenfire1-lanzhou", }}}, } fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{ {EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "zzz-target@imopenfire1-lanzhou", DisplayName: "ZZZ Target", Confidence: 0.8}, {EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "target@imopenfire1-lanzhou", DisplayName: "Target", Confidence: 0.9}, }} session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay) }) defer cleanup() callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameSearchContacts, Arguments: map[string]any{"query": "target@imopenfire1-lanzhou", "limit": 10}, }) if err != nil { t.Fatalf("call %s: %v", ToolNameSearchContacts, err) } if callResult.IsError { t.Fatalf("call result is error: %+v", callResult) } var decoded struct { Contacts []struct { ContactID string `json:"contact_id"` Source string `json:"source"` RawRef string `json:"raw_ref"` } `json:"contacts"` } payload, _ := json.Marshal(callResult.StructuredContent) if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("decode structured content %s: %v", payload, err) } if len(decoded.Contacts) != 2 { t.Fatalf("contacts = %+v, want exact and substring only", decoded.Contacts) } if decoded.Contacts[0].ContactID != "target@imopenfire1-lanzhou" || decoded.Contacts[0].Source != "msglib_readonly" || decoded.Contacts[0].RawRef != "msglib:TD_CustomEffigy" { t.Fatalf("first contact should be exact MsgLib match with source metadata, got %+v", decoded.Contacts[0]) } if decoded.Contacts[1].ContactID != "zzz-target@imopenfire1-lanzhou" { t.Fatalf("second contact should be substring match, got %+v", decoded.Contacts[1]) } } func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ ID: "msg-1", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "bob@imopenfire1-lanzhou", }}}, } session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { RegisterISphereContactTools(server, fake) }) defer cleanup() callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameSearchContacts, Arguments: map[string]any{ "query": "alice", "source_preference": "local_readonly", "include_inactive": true, "cursor": "", "limit": 10, }, }) if err != nil { t.Fatalf("call with safe contract args: %v", err) } if callResult.IsError { t.Fatalf("call result is error: %+v", callResult) } bridgeResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameSearchContacts, Arguments: map[string]any{"query": "alice", "source_preference": "bridge"}, }) if err == nil && !bridgeResult.IsError { t.Fatalf("bridge source_preference was accepted before connector exists") } cursorResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: ToolNameSearchContacts, Arguments: map[string]any{"query": "alice", "cursor": "next-page"}, }) if err == nil && !cursorResult.IsError { t.Fatalf("non-empty cursor was accepted before pagination exists") } } type fakeDisplayEntitySource struct { queries []msglib.DisplayEntitiesOptions entities []msglib.DisplayEntity } func (f *fakeDisplayEntitySource) DisplayEntities(_ context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error) { f.queries = append(f.queries, opts) return f.entities, nil }