Files
mattermost-mcp-server/operation/integration_test.go
T
karti 35a0b2b715 feat: initial release with 48 Mattermost MCP tools
- 27 base tools: channels, messaging, users, reactions, files, DMs
- Team management: invite/remove users, get stats, list members
- Slash commands: execute /remind, /poll, etc.
- Webhook management: incoming and outgoing webhooks
- System tools: server config, logs, bulk status updates
- Channel admin: create, invite, leave, delete channels
- Read-only mode for safe exploration
- Dual token support (bot + PAT) for enhanced security
- Apache 2.0 licensed
2026-04-15 21:26:19 -07:00

1434 lines
42 KiB
Go

package operation
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/karti-ai/mattermost-mcp-server/operation/channel"
"github.com/karti-ai/mattermost-mcp-server/operation/dm"
"github.com/karti-ai/mattermost-mcp-server/operation/file"
"github.com/karti-ai/mattermost-mcp-server/operation/messaging"
"github.com/karti-ai/mattermost-mcp-server/operation/reaction"
"github.com/karti-ai/mattermost-mcp-server/operation/team"
"github.com/karti-ai/mattermost-mcp-server/operation/user"
mmerrors "github.com/karti-ai/mattermost-mcp-server/pkg/errors"
"github.com/karti-ai/mattermost-mcp-server/pkg/flag"
"github.com/karti-ai/mattermost-mcp-server/pkg/mattermost"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockMattermostServer creates a mock Mattermost server for testing
type mockMattermostServer struct {
server *httptest.Server
users map[string]*model.User
channels map[string]*model.Channel
posts map[string]*model.Post
reactions map[string]*model.Reaction
callHistory []mockCall
}
type mockCall struct {
Method string
Path string
Body interface{}
}
func newMockMattermostServer() *mockMattermostServer {
m := &mockMattermostServer{
users: make(map[string]*model.User),
channels: make(map[string]*model.Channel),
posts: make(map[string]*model.Post),
reactions: make(map[string]*model.Reaction),
}
m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.handleRequest(w, r)
}))
return m
}
func (m *mockMattermostServer) handleRequest(w http.ResponseWriter, r *http.Request) {
m.callHistory = append(m.callHistory, mockCall{
Method: r.Method,
Path: r.URL.Path,
})
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/v4/users/me":
m.handleGetMe(w, r)
case r.URL.Path == "/api/v4/users/search":
m.handleSearchUsers(w, r)
case r.URL.Path == "/api/v4/channels/direct":
m.handleCreateDirectChannel(w, r)
case r.URL.Path == "/api/v4/posts":
m.handleCreatePost(w, r)
case r.Method == "PUT" && len(r.URL.Path) > 14 && strings.HasPrefix(r.URL.Path, "/api/v4/posts/"):
m.handleUpdatePost(w, r)
case r.Method == "DELETE" && len(r.URL.Path) > 14 && strings.HasPrefix(r.URL.Path, "/api/v4/posts/"):
m.handleDeletePost(w, r)
case r.URL.Path == "/api/v4/reactions":
m.handleReaction(w, r)
case r.Method == "GET" && len(r.URL.Path) > 17 && strings.HasPrefix(r.URL.Path, "/api/v4/channels/"):
m.handleGetChannel(w, r)
case len(r.URL.Path) > 13 && strings.HasPrefix(r.URL.Path, "/api/v4/users/"):
m.handleGetChannelsForTeamForUser(w, r)
default:
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"message": "Not found"})
}
}
func (m *mockMattermostServer) handleGetMe(w http.ResponseWriter, r *http.Request) {
user := &model.User{
Id: "user-current",
Username: "currentuser",
Email: "current@example.com",
FirstName: "Current",
LastName: "User",
Roles: "system_user",
}
json.NewEncoder(w).Encode(user)
}
func (m *mockMattermostServer) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
var search model.UserSearch
json.NewDecoder(r.Body).Decode(&search)
users := []*model.User{}
for _, u := range m.users {
if search.Term == "" ||
u.Username == search.Term ||
u.Email == search.Term ||
u.FirstName+" "+u.LastName == search.Term {
users = append(users, u)
}
}
// Add default user if no results
if len(users) == 0 && search.Term != "" {
users = append(users, &model.User{
Id: "user-" + search.Term,
Username: search.Term,
Email: search.Term + "@example.com",
FirstName: "Test",
LastName: "User",
Roles: "system_user",
})
}
json.NewEncoder(w).Encode(users)
}
func (m *mockMattermostServer) handleCreateDirectChannel(w http.ResponseWriter, r *http.Request) {
var userIds []string
json.NewDecoder(r.Body).Decode(&userIds)
if len(userIds) != 2 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"message": "Need exactly 2 user IDs"})
return
}
channel := &model.Channel{
Id: "dm-" + userIds[0] + "-" + userIds[1],
Name: userIds[0] + "__" + userIds[1],
DisplayName: "Direct Message",
Type: model.ChannelTypeDirect,
TeamId: "",
}
m.channels[channel.Id] = channel
json.NewEncoder(w).Encode(channel)
}
func (m *mockMattermostServer) handleCreatePost(w http.ResponseWriter, r *http.Request) {
var post model.Post
json.NewDecoder(r.Body).Decode(&post)
post.Id = "post-" + fmt.Sprintf("%d", time.Now().UnixNano())
post.CreateAt = time.Now().UnixMilli()
post.UpdateAt = post.CreateAt
m.posts[post.Id] = &post
json.NewEncoder(w).Encode(post)
}
func (m *mockMattermostServer) handleUpdatePost(w http.ResponseWriter, r *http.Request) {
postID := r.URL.Path[14:] // Remove "/api/v4/posts/"
var post model.Post
json.NewDecoder(r.Body).Decode(&post)
if existing, ok := m.posts[postID]; ok {
existing.Message = post.Message
existing.UpdateAt = time.Now().UnixMilli()
json.NewEncoder(w).Encode(existing)
return
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"message": "Post not found"})
}
func (m *mockMattermostServer) handleDeletePost(w http.ResponseWriter, r *http.Request) {
postID := r.URL.Path[14:] // Remove "/api/v4/posts/"
if _, ok := m.posts[postID]; ok {
delete(m.posts, postID)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "OK"})
return
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"message": "Post not found"})
}
func (m *mockMattermostServer) handleReaction(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
var reaction model.Reaction
json.NewDecoder(r.Body).Decode(&reaction)
reaction.CreateAt = time.Now().UnixMilli()
m.reactions[reaction.PostId+"_"+reaction.EmojiName] = &reaction
json.NewEncoder(w).Encode(reaction)
case "DELETE":
var reaction model.Reaction
json.NewDecoder(r.Body).Decode(&reaction)
delete(m.reactions, reaction.PostId+"_"+reaction.EmojiName)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "OK"})
}
}
func (m *mockMattermostServer) handleGetChannel(w http.ResponseWriter, r *http.Request) {
channelID := r.URL.Path[17:] // Remove "/api/v4/channels/"
// Check if it's a channels endpoint
if len(channelID) > 8 && channelID[len(channelID)-8:] == "/posts" {
// This is a posts request, handle separately
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{"posts": []map[string]interface{}{}})
return
}
if channel, ok := m.channels[channelID]; ok {
json.NewEncoder(w).Encode(channel)
return
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"message": "Channel not found"})
}
func (m *mockMattermostServer) handleGetChannelsForTeamForUser(w http.ResponseWriter, r *http.Request) {
// Path format: /api/v4/users/{userID}/teams/{teamID}/channels
channels := []*model.Channel{
{
Id: "channel-1",
Name: "town-square",
DisplayName: "Town Square",
Type: model.ChannelTypeOpen,
TeamId: "team-1",
},
{
Id: "channel-2",
Name: "off-topic",
DisplayName: "Off-Topic",
Type: model.ChannelTypeOpen,
TeamId: "team-1",
},
{
Id: "channel-3",
Name: "private-channel",
DisplayName: "Private Channel",
Type: model.ChannelTypePrivate,
TeamId: "team-1",
},
}
json.NewEncoder(w).Encode(channels)
}
func (m *mockMattermostServer) URL() string {
return m.server.URL
}
func (m *mockMattermostServer) Close() {
m.server.Close()
}
func (m *mockMattermostServer) GetCallHistory() []mockCall {
return m.callHistory
}
func (m *mockMattermostServer) ClearHistory() {
m.callHistory = nil
}
func setupMockClient(mockServer *mockMattermostServer) *mattermost.Client {
client := mattermost.NewClient(mockServer.URL(), "test-bot-token", "test-pat")
mattermost.SetGlobalClient(client)
return client
}
// ==================== Cross-Tool Workflow Tests ====================
func TestIntegration_DMWorkflow_CreateDMSendMessageAddReaction(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
setupMockClient(mock)
// Step 1: Search for a user
searchReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: user.SearchUsersToolName,
Arguments: map[string]interface{}{
"term": "testuser",
},
},
}
searchResult, err := user.SearchUsersFn(context.Background(), searchReq)
require.NoError(t, err)
require.NotNil(t, searchResult)
require.False(t, searchResult.IsError, "Search should succeed")
// Parse search result to get user ID
var searchData map[string]interface{}
err = json.Unmarshal([]byte(searchResult.Content[0].(mcp.TextContent).Text), &searchData)
require.NoError(t, err)
users := searchData["users"].([]interface{})
require.Greater(t, len(users), 0, "Should find at least one user")
userData := users[0].(map[string]interface{})
userID := userData["id"].(string)
// Step 2: Create DM channel with the user
dmReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": userID,
},
},
}
dmResult, err := dm.CreateDMFn(context.Background(), dmReq)
require.NoError(t, err)
require.NotNil(t, dmResult)
require.False(t, dmResult.IsError, "DM creation should succeed")
// Parse DM result to get channel ID
var dmData map[string]interface{}
err = json.Unmarshal([]byte(dmResult.Content[0].(mcp.TextContent).Text), &dmData)
require.NoError(t, err)
channelID := dmData["id"].(string)
require.NotEmpty(t, channelID)
// Step 3: Send a message to the DM channel
msgReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": channelID,
"message": "Hello from integration test!",
},
},
}
msgResult, err := messaging.SendMessageFn(context.Background(), msgReq)
require.NoError(t, err)
require.NotNil(t, msgResult)
require.False(t, msgResult.IsError, "Message send should succeed")
// Parse message result to get post ID
var msgData map[string]interface{}
err = json.Unmarshal([]byte(msgResult.Content[0].(mcp.TextContent).Text), &msgData)
require.NoError(t, err)
postID := msgData["id"].(string)
require.NotEmpty(t, postID)
// Step 4: Add a reaction to the message
reactionReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.AddReactionToolName,
Arguments: map[string]interface{}{
"post_id": postID,
"emoji_name": "thumbsup",
},
},
}
reactionResult, err := reaction.AddReactionFn(context.Background(), reactionReq)
require.NoError(t, err)
require.NotNil(t, reactionResult)
require.False(t, reactionResult.IsError, "Reaction add should succeed")
// Verify the reaction result
var reactionData map[string]interface{}
err = json.Unmarshal([]byte(reactionResult.Content[0].(mcp.TextContent).Text), &reactionData)
require.NoError(t, err)
assert.Equal(t, postID, reactionData["post_id"])
assert.Equal(t, "thumbsup", reactionData["emoji_name"])
// Verify call history shows the workflow sequence
history := mock.GetCallHistory()
require.GreaterOrEqual(t, len(history), 4, "Should have at least 4 API calls")
}
func TestIntegration_ChannelWorkflow_ListSendEditDelete(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
setupMockClient(mock)
// Step 1: List channels for a team
listReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: channel.ListChannelsToolName,
Arguments: map[string]interface{}{
"team_id": "team-1",
"limit": 10,
},
},
}
listResult, err := channel.ListChannelsFn(context.Background(), listReq)
require.NoError(t, err)
require.NotNil(t, listResult)
require.False(t, listResult.IsError, "List channels should succeed")
// Parse channel list result
var listData map[string]interface{}
err = json.Unmarshal([]byte(listResult.Content[0].(mcp.TextContent).Text), &listData)
require.NoError(t, err)
channels := listData["channels"].([]interface{})
require.GreaterOrEqual(t, len(channels), 1, "Should have at least one channel")
channelData := channels[0].(map[string]interface{})
channelID := channelData["id"].(string)
// Step 2: Send a message to the channel
msgReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": channelID,
"message": "Original message",
},
},
}
msgResult, err := messaging.SendMessageFn(context.Background(), msgReq)
require.NoError(t, err)
require.NotNil(t, msgResult)
require.False(t, msgResult.IsError)
var msgData map[string]interface{}
err = json.Unmarshal([]byte(msgResult.Content[0].(mcp.TextContent).Text), &msgData)
require.NoError(t, err)
postID := msgData["id"].(string)
// Step 3: Edit the message
editReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.EditMessageToolName,
Arguments: map[string]interface{}{
"post_id": postID,
"message": "Edited message",
},
},
}
editResult, err := messaging.EditMessageFn(context.Background(), editReq)
require.NoError(t, err)
require.NotNil(t, editResult)
require.False(t, editResult.IsError, "Edit message should succeed")
// Verify edit result
var editData map[string]interface{}
err = json.Unmarshal([]byte(editResult.Content[0].(mcp.TextContent).Text), &editData)
require.NoError(t, err)
assert.Equal(t, "Edited message", editData["message"])
// Step 4: Delete the message
deleteReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.DeleteMessageToolName,
Arguments: map[string]interface{}{
"post_id": postID,
},
},
}
deleteResult, err := messaging.DeleteMessageFn(context.Background(), deleteReq)
require.NoError(t, err)
require.NotNil(t, deleteResult)
require.False(t, deleteResult.IsError, "Delete message should succeed")
// Verify delete result
var deleteData map[string]interface{}
err = json.Unmarshal([]byte(deleteResult.Content[0].(mcp.TextContent).Text), &deleteData)
require.NoError(t, err)
assert.True(t, deleteData["success"].(bool))
assert.Equal(t, postID, deleteData["post_id"])
}
func TestIntegration_SearchUserCreateDMSendMessage(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
setupMockClient(mock)
// Add a specific user to search for
targetUser := &model.User{
Id: "user-target-123",
Username: "targetuser",
Email: "target@example.com",
FirstName: "Target",
LastName: "User",
Roles: "system_user",
}
mock.users[targetUser.Id] = targetUser
// Step 1: Search for the user
searchReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: user.SearchUsersToolName,
Arguments: map[string]interface{}{
"term": "targetuser",
"team_id": "team-1",
"limit": 5,
},
},
}
searchResult, err := user.SearchUsersFn(context.Background(), searchReq)
require.NoError(t, err)
require.NotNil(t, searchResult)
require.False(t, searchResult.IsError)
var searchData map[string]interface{}
err = json.Unmarshal([]byte(searchResult.Content[0].(mcp.TextContent).Text), &searchData)
require.NoError(t, err)
// Step 2: Create DM with found user
foundUsers := searchData["users"].([]interface{})
require.GreaterOrEqual(t, len(foundUsers), 1)
foundUser := foundUsers[0].(map[string]interface{})
userID := foundUser["id"].(string)
dmReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": userID,
},
},
}
dmResult, err := dm.CreateDMFn(context.Background(), dmReq)
require.NoError(t, err)
require.NotNil(t, dmResult)
require.False(t, dmResult.IsError)
var dmData map[string]interface{}
err = json.Unmarshal([]byte(dmResult.Content[0].(mcp.TextContent).Text), &dmData)
require.NoError(t, err)
channelID := dmData["id"].(string)
// Step 3: Send message in DM
msgReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": channelID,
"message": "Message to target user",
},
},
}
msgResult, err := messaging.SendMessageFn(context.Background(), msgReq)
require.NoError(t, err)
require.NotNil(t, msgResult)
require.False(t, msgResult.IsError)
var msgData map[string]interface{}
err = json.Unmarshal([]byte(msgResult.Content[0].(mcp.TextContent).Text), &msgData)
require.NoError(t, err)
assert.Equal(t, channelID, msgData["channel_id"])
assert.Equal(t, "Message to target user", msgData["message"])
}
// ==================== Read-Only Mode Tests ====================
func TestIntegration_ReadOnlyMode_WriteToolsExcluded(t *testing.T) {
// Save original read-only setting
originalReadOnly := flag.ReadOnly
defer func() {
flag.ReadOnly = originalReadOnly
}()
// Test with read-only = false (full access)
flag.ReadOnly = false
allTools := Register()
// Count tools
writeToolCount := 0
readToolCount := 0
for _, tool := range allTools {
switch tool.Tool.Name {
case messaging.SendMessageToolName, messaging.EditMessageToolName, messaging.DeleteMessageToolName,
dm.CreateDMToolName, reaction.AddReactionToolName, reaction.RemoveReactionToolName,
file.UploadFileToolName, file.DownloadFileToolName:
writeToolCount++
case channel.ListChannelsToolName, user.SearchUsersToolName,
team.ListTeamsToolName, messaging.GetChannelMessagesToolName, messaging.GetThreadToolName,
messaging.SearchPostsToolName, user.GetUserStatusToolName,
channel.GetChannelByNameToolName, channel.GetUnreadCountToolName, channel.MarkChannelReadToolName:
readToolCount++
}
}
assert.Greater(t, writeToolCount, 0, "Should have write tools in non-read-only mode")
assert.Greater(t, readToolCount, 0, "Should have read tools in non-read-only mode")
// Test with read-only = true (read-only access)
flag.ReadOnly = true
readOnlyTools := Register()
// In read-only mode, only read tools should be present
for _, tool := range readOnlyTools {
switch tool.Tool.Name {
case messaging.SendMessageToolName, messaging.EditMessageToolName, messaging.DeleteMessageToolName,
dm.CreateDMToolName, reaction.AddReactionToolName, reaction.RemoveReactionToolName:
t.Errorf("Write tool %s should not be present in read-only mode", tool.Tool.Name)
}
}
// Verify read tools are still present
foundReadTools := 0
for _, tool := range readOnlyTools {
switch tool.Tool.Name {
case channel.ListChannelsToolName, user.SearchUsersToolName,
team.ListTeamsToolName, messaging.GetChannelMessagesToolName, messaging.GetThreadToolName,
messaging.SearchPostsToolName, user.GetUserStatusToolName,
channel.GetChannelByNameToolName, channel.GetUnreadCountToolName, channel.MarkChannelReadToolName:
foundReadTools++
}
}
assert.Greater(t, foundReadTools, 0, "Read tools should be present in read-only mode")
}
func TestIntegration_ReadOnlyMode_ToolCountVerification(t *testing.T) {
originalReadOnly := flag.ReadOnly
defer func() {
flag.ReadOnly = originalReadOnly
}()
// Full mode - should have all tools
flag.ReadOnly = false
fullTools := Register()
fullCount := len(fullTools)
// Read-only mode - should have only read tools
flag.ReadOnly = true
readOnlyTools := Register()
readOnlyCount := len(readOnlyTools)
// Read-only should have fewer tools
assert.Less(t, readOnlyCount, fullCount, "Read-only mode should have fewer tools than full mode")
// Verify the difference is the count of write tools
flag.ReadOnly = false
tools := Register()
writeCount := 0
readCount := 0
for _, tool := range tools {
switch tool.Tool.Name {
case messaging.SendMessageToolName, messaging.EditMessageToolName, messaging.DeleteMessageToolName,
dm.CreateDMToolName, reaction.AddReactionToolName, reaction.RemoveReactionToolName,
file.UploadFileToolName, file.DownloadFileToolName,
channel.CreateChannelToolName, channel.InviteToChannelToolName, channel.DeleteChannelToolName, channel.LeaveChannelToolName:
writeCount++
case channel.ListChannelsToolName, user.SearchUsersToolName,
team.ListTeamsToolName, messaging.GetChannelMessagesToolName, messaging.GetThreadToolName,
messaging.SearchPostsToolName, user.GetUserStatusToolName,
channel.GetChannelByNameToolName, channel.GetUnreadCountToolName, channel.MarkChannelReadToolName,
channel.GetChannelInfoToolName:
readCount++
}
}
assert.Equal(t, fullCount, writeCount+readCount, "Total tools should equal write + read tools")
assert.Equal(t, readCount, readOnlyCount, "Read-only mode should only expose read tools")
}
// ==================== Error Propagation Tests ====================
func TestIntegration_ErrorPropagation_ClientNotInitialized(t *testing.T) {
// Ensure no client is set
mattermost.SetGlobalClient(nil)
testCases := []struct {
name string
toolName string
handler func(interface{}, mcp.CallToolRequest) (*mcp.CallToolResult, error)
request mcp.CallToolRequest
}{
{
name: "SendMessage",
toolName: messaging.SendMessageToolName,
handler: func(_ interface{}, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return messaging.SendMessageFn(context.Background(), req)
},
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"message": "test",
},
},
},
},
{
name: "EditMessage",
toolName: messaging.EditMessageToolName,
handler: func(_ interface{}, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return messaging.EditMessageFn(context.Background(), req)
},
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.EditMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
"message": "test",
},
},
},
},
{
name: "DeleteMessage",
toolName: messaging.DeleteMessageToolName,
handler: func(_ interface{}, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return messaging.DeleteMessageFn(context.Background(), req)
},
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.DeleteMessageToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
},
},
},
},
{
name: "CreateDM",
toolName: dm.CreateDMToolName,
handler: func(_ interface{}, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return dm.CreateDMFn(context.Background(), req)
},
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": "user123",
},
},
},
},
{
name: "AddReaction",
toolName: reaction.AddReactionToolName,
handler: func(_ interface{}, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return reaction.AddReactionFn(context.Background(), req)
},
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.AddReactionToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
"emoji_name": "thumbsup",
},
},
},
},
{
name: "RemoveReaction",
toolName: reaction.RemoveReactionToolName,
handler: func(_ interface{}, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return reaction.RemoveReactionFn(context.Background(), req)
},
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.RemoveReactionToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
"emoji_name": "thumbsup",
},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := tc.handler(nil, tc.request)
assert.NoError(t, err) // Handler should not return error, just error result
assert.NotNil(t, result)
assert.True(t, result.IsError, "Should return error when client not initialized")
// Verify error message contains expected text
errText := result.Content[0].(mcp.TextContent).Text
assert.Contains(t, errText, "client not initialized")
})
}
}
func TestIntegration_ErrorPropagation_InvalidParams(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
setupMockClient(mock)
testCases := []struct {
name string
toolName string
handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)
request mcp.CallToolRequest
expectedErr string
}{
{
name: "SendMessage_MissingChannelId",
toolName: messaging.SendMessageToolName,
handler: messaging.SendMessageFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"message": "test message",
},
},
},
expectedErr: "channel_id",
},
{
name: "SendMessage_MissingMessage",
toolName: messaging.SendMessageToolName,
handler: messaging.SendMessageFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
},
},
},
expectedErr: "message",
},
{
name: "EditMessage_MissingPostId",
toolName: messaging.EditMessageToolName,
handler: messaging.EditMessageFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.EditMessageToolName,
Arguments: map[string]interface{}{
"message": "updated",
},
},
},
expectedErr: "post_id",
},
{
name: "DeleteMessage_MissingPostId",
toolName: messaging.DeleteMessageToolName,
handler: messaging.DeleteMessageFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.DeleteMessageToolName,
Arguments: map[string]interface{}{},
},
},
expectedErr: "post_id",
},
{
name: "CreateDM_MissingUserId",
toolName: dm.CreateDMToolName,
handler: dm.CreateDMFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{},
},
},
expectedErr: "user_id",
},
{
name: "AddReaction_MissingPostId",
toolName: reaction.AddReactionToolName,
handler: reaction.AddReactionFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.AddReactionToolName,
Arguments: map[string]interface{}{
"emoji_name": "thumbsup",
},
},
},
expectedErr: "post_id",
},
{
name: "AddReaction_MissingEmojiName",
toolName: reaction.AddReactionToolName,
handler: reaction.AddReactionFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.AddReactionToolName,
Arguments: map[string]interface{}{
"post_id": "post123",
},
},
},
expectedErr: "emoji_name",
},
{
name: "ListChannels_MissingTeamId",
toolName: channel.ListChannelsToolName,
handler: channel.ListChannelsFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: channel.ListChannelsToolName,
Arguments: map[string]interface{}{},
},
},
expectedErr: "team_id",
},
{
name: "SearchUsers_MissingTerm",
toolName: user.SearchUsersToolName,
handler: user.SearchUsersFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: user.SearchUsersToolName,
Arguments: map[string]interface{}{},
},
},
expectedErr: "term",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := tc.handler(nil, tc.request)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.True(t, result.IsError, "Should return error for missing required parameter")
errText := result.Content[0].(mcp.TextContent).Text
assert.Contains(t, errText, tc.expectedErr)
})
}
}
// ==================== Enhanced Error Tests ====================
func TestIntegration_ErrorEnhancedError_Translation(t *testing.T) {
testCases := []struct {
name string
err error
context map[string]string
expectedCategory mmerrors.ErrorCategory
isNotFound bool
isAuthError bool
}{
{
name: "404 not found error",
err: fmt.Errorf("request failed with status 404: channel not found"),
context: map[string]string{"operation": "GetChannel", "channel_id": "abc123"},
expectedCategory: mmerrors.CategoryUnknown,
isNotFound: true,
},
{
name: "401 authentication error",
err: fmt.Errorf("request failed with status 401: unauthorized"),
context: map[string]string{"operation": "GetUser"},
expectedCategory: mmerrors.CategoryAuth,
isAuthError: true,
},
{
name: "403 permission denied",
err: fmt.Errorf("request failed with status 403: forbidden"),
context: map[string]string{"operation": "CreatePost"},
expectedCategory: mmerrors.CategoryAuth,
isAuthError: true,
},
{
name: "500 server error",
err: fmt.Errorf("request failed with status 500: internal server error"),
context: map[string]string{"operation": "SearchUsers"},
expectedCategory: mmerrors.CategoryUnknown,
},
{
name: "Network timeout error",
err: fmt.Errorf("request timeout: connection to server timed out"),
context: map[string]string{"operation": "SendMessage"},
expectedCategory: mmerrors.CategoryNetwork,
},
{
name: "Connection refused error",
err: fmt.Errorf("connection refused to mattermost.example.com:443"),
context: map[string]string{"operation": "GetMe"},
expectedCategory: mmerrors.CategoryNetwork,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
translated := mmerrors.TranslateError(tc.err, tc.context)
require.NotNil(t, translated)
var enhanced *mmerrors.EnhancedError
require.True(t, errors.As(translated, &enhanced), "Should be an EnhancedError")
assert.Equal(t, tc.expectedCategory, enhanced.Category)
assert.Equal(t, tc.isNotFound, mmerrors.IsNotFound(translated))
assert.Equal(t, tc.isAuthError, mmerrors.IsAuthError(translated))
// Verify context is preserved
for key, value := range tc.context {
assert.Equal(t, value, enhanced.Context[key])
}
// Verify formatting works
formatted := enhanced.Format()
assert.NotEmpty(t, formatted)
assert.Contains(t, formatted, tc.context["operation"])
detailed := enhanced.FormatDetailed()
assert.NotEmpty(t, detailed)
})
}
}
func TestIntegration_ErrorEnhancedError_NilHandling(t *testing.T) {
// Test that nil errors are handled properly
assert.Nil(t, mmerrors.TranslateError(nil, nil))
assert.Nil(t, mmerrors.Wrap(nil, "operation"))
// Test that nil error checking functions return false
assert.False(t, mmerrors.IsNotFound(nil))
assert.False(t, mmerrors.IsAuthError(nil))
assert.False(t, mmerrors.IsTimeout(nil))
assert.False(t, mmerrors.IsNetworkError(nil))
assert.False(t, mmerrors.IsServerError(nil))
}
func TestIntegration_ErrorEnhancedError_FluentAPI(t *testing.T) {
original := fmt.Errorf("original error")
enhanced := mmerrors.TranslateError(original, nil).(*mmerrors.EnhancedError)
// Test fluent API chaining
result := enhanced.
WithOperation("TestOperation").
WithParam("param1", "value1").
WithParam("param2", "value2").
WithContext("extra", "context")
// Should return same instance for chaining
assert.Equal(t, enhanced, result)
// Verify all values set
assert.Equal(t, "TestOperation", enhanced.Operation)
assert.Equal(t, "value1", enhanced.Context["param1"])
assert.Equal(t, "value2", enhanced.Context["param2"])
assert.Equal(t, "context", enhanced.Context["extra"])
// Verify original is preserved
assert.Equal(t, original, enhanced.Unwrap())
}
func TestIntegration_ErrorHTTPStatusCodes(t *testing.T) {
testCases := []struct {
name string
err error
isUnauthorized bool
isForbidden bool
isNotFound bool
isServerError bool
}{
{
name: "401 Unauthorized",
err: &testHTTPError{status: 401, message: "unauthorized"},
isUnauthorized: true,
isForbidden: false,
isNotFound: false,
isServerError: false,
},
{
name: "403 Forbidden",
err: &testHTTPError{status: 403, message: "forbidden"},
isUnauthorized: false,
isForbidden: true,
isNotFound: false,
isServerError: false,
},
{
name: "404 Not Found",
err: &testHTTPError{status: 404, message: "not found"},
isUnauthorized: false,
isForbidden: false,
isNotFound: true,
isServerError: false,
},
{
name: "500 Internal Server Error",
err: &testHTTPError{status: 500, message: "internal error"},
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "502 Bad Gateway",
err: &testHTTPError{status: 502, message: "bad gateway"},
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "503 Service Unavailable",
err: &testHTTPError{status: 503, message: "service unavailable"},
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: true,
},
{
name: "200 OK",
err: &testHTTPError{status: 200, message: "ok"},
isUnauthorized: false,
isForbidden: false,
isNotFound: false,
isServerError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.isUnauthorized, mmerrors.IsUnauthorized(tc.err))
assert.Equal(t, tc.isForbidden, mmerrors.IsForbidden(tc.err))
assert.Equal(t, tc.isNotFound, mmerrors.IsNotFoundHTTP(tc.err))
assert.Equal(t, tc.isServerError, mmerrors.IsServerError(tc.err))
})
}
}
// testHTTPError implements HTTPError interface for testing
type testHTTPError struct {
status int
message string
}
func (e *testHTTPError) Error() string { return e.message }
func (e *testHTTPError) Status() int { return e.status }
// ==================== Cross-Tool Error Consistency Tests ====================
func TestIntegration_CrossToolErrorConsistency(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
// Start server but return errors to test error handling
originalHandler := mock.server.Config.Handler
mock.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate various error conditions
switch r.URL.Path {
case "/api/v4/users/me":
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"message": "authentication failed"})
case "/api/v4/posts":
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{"message": "permission denied"})
case "/api/v4/channels/direct":
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"message": "user not found"})
default:
originalHandler.ServeHTTP(w, r)
}
})
setupMockClient(mock)
testCases := []struct {
name string
toolName string
handler func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)
request mcp.CallToolRequest
expectedError bool
errorCheck func(error) bool
}{
{
name: "DM Creation - Auth Error",
toolName: dm.CreateDMToolName,
handler: dm.CreateDMFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": "user123",
},
},
},
expectedError: true,
},
{
name: "Send Message - Forbidden",
toolName: messaging.SendMessageToolName,
handler: messaging.SendMessageFn,
request: mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": "channel123",
"message": "test",
},
},
},
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := tc.handler(context.Background(), tc.request)
assert.NoError(t, err) // Handler shouldn't return error
assert.NotNil(t, result)
assert.True(t, result.IsError, "Should return error result")
})
}
}
// ==================== End-to-End Complex Workflow Tests ====================
func TestIntegration_ComplexWorkflow_MultiStepWithRollback(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
setupMockClient(mock)
// Complex workflow: Search user → Create DM → Send message → Add reaction → Remove reaction → Delete message
// Step 1: Search for user
searchReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: user.SearchUsersToolName,
Arguments: map[string]interface{}{
"term": "workflowuser",
"limit": 1,
},
},
}
searchResult, err := user.SearchUsersFn(context.Background(), searchReq)
require.NoError(t, err)
require.False(t, searchResult.IsError)
var searchData map[string]interface{}
json.Unmarshal([]byte(searchResult.Content[0].(mcp.TextContent).Text), &searchData)
users := searchData["users"].([]interface{})
require.GreaterOrEqual(t, len(users), 1)
userID := users[0].(map[string]interface{})["id"].(string)
// Step 2: Create DM
dmReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": userID,
},
},
}
dmResult, err := dm.CreateDMFn(context.Background(), dmReq)
require.NoError(t, err)
require.False(t, dmResult.IsError)
var dmData map[string]interface{}
json.Unmarshal([]byte(dmResult.Content[0].(mcp.TextContent).Text), &dmData)
channelID := dmData["id"].(string)
// Step 3: Send message
msgReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.SendMessageToolName,
Arguments: map[string]interface{}{
"channel_id": channelID,
"message": "Complex workflow test",
},
},
}
msgResult, err := messaging.SendMessageFn(context.Background(), msgReq)
require.NoError(t, err)
require.False(t, msgResult.IsError)
var msgData map[string]interface{}
json.Unmarshal([]byte(msgResult.Content[0].(mcp.TextContent).Text), &msgData)
postID := msgData["id"].(string)
// Step 4: Add reaction
addReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.AddReactionToolName,
Arguments: map[string]interface{}{
"post_id": postID,
"emoji_name": "heart",
},
},
}
addResult, err := reaction.AddReactionFn(context.Background(), addReq)
require.NoError(t, err)
require.False(t, addResult.IsError)
// Step 5: Remove reaction
removeReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: reaction.RemoveReactionToolName,
Arguments: map[string]interface{}{
"post_id": postID,
"emoji_name": "heart",
},
},
}
removeResult, err := reaction.RemoveReactionFn(context.Background(), removeReq)
require.NoError(t, err)
require.False(t, removeResult.IsError)
var removeData map[string]interface{}
json.Unmarshal([]byte(removeResult.Content[0].(mcp.TextContent).Text), &removeData)
assert.True(t, removeData["success"].(bool))
assert.Equal(t, postID, removeData["post_id"])
// Step 6: Delete message (cleanup)
deleteReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: messaging.DeleteMessageToolName,
Arguments: map[string]interface{}{
"post_id": postID,
},
},
}
deleteResult, err := messaging.DeleteMessageFn(context.Background(), deleteReq)
require.NoError(t, err)
require.False(t, deleteResult.IsError)
var deleteData map[string]interface{}
json.Unmarshal([]byte(deleteResult.Content[0].(mcp.TextContent).Text), &deleteData)
assert.True(t, deleteData["success"].(bool))
}
func TestIntegration_WorkflowErrorChaining_FirstFailureStops(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
// Setup mock to fail on DM creation
mock.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/users/me":
user := &model.User{
Id: "user-current",
Username: "currentuser",
Email: "current@example.com",
FirstName: "Current",
LastName: "User",
Roles: "system_user",
}
json.NewEncoder(w).Encode(user)
case "/api/v4/channels/direct":
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"message": "cannot create DM with yourself"})
default:
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"message": "not found"})
}
})
setupMockClient(mock)
// Try to create DM with ourselves (which will fail)
dmReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: dm.CreateDMToolName,
Arguments: map[string]interface{}{
"user_id": "user-current", // Same as current user
},
},
}
// This should fail
result, err := dm.CreateDMFn(context.Background(), dmReq)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.IsError, "DM creation should fail")
// Verify the error message is descriptive
errText := result.Content[0].(mcp.TextContent).Text
assert.NotEmpty(t, errText)
}
func TestDebugChannelList(t *testing.T) {
mock := newMockMattermostServer()
defer mock.Close()
setupMockClient(mock)
listReq := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: channel.ListChannelsToolName,
Arguments: map[string]interface{}{
"team_id": "team-1",
"limit": 10,
},
},
}
listResult, err := channel.ListChannelsFn(context.Background(), listReq)
require.NoError(t, err)
require.NotNil(t, listResult)
if listResult.IsError {
t.Fatalf("Error result: %s", listResult.Content[0].(mcp.TextContent).Text)
}
}