#
tokens: 48053/50000 14/115 files (page 3/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 of 5. Use http://codebase.md/portainer/portainer-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .cursor
│   └── rules
│       └── integration-test.mdc
├── .github
│   └── workflows
│       ├── ci.yml
│       └── release.yml
├── .gitignore
├── CLAUDE.md
├── cloc.sh
├── cmd
│   ├── portainer-mcp
│   │   └── mcp.go
│   └── token-count
│       └── token.go
├── docs
│   ├── clients_and_models.md
│   ├── design
│   │   ├── 202503-1-external-tools-file.md
│   │   ├── 202503-2-tools-vs-mcp-resources.md
│   │   ├── 202503-3-specific-update-tools.md
│   │   ├── 202504-1-embedded-tools-yaml.md
│   │   ├── 202504-2-tools-yaml-versioning.md
│   │   ├── 202504-3-portainer-version-compatibility.md
│   │   └── 202504-4-read-only-mode.md
│   └── design_summary.md
├── go.mod
├── go.sum
├── internal
│   ├── k8sutil
│   │   ├── stripper_test.go
│   │   └── stripper.go
│   ├── mcp
│   │   ├── access_group_test.go
│   │   ├── access_group.go
│   │   ├── docker_test.go
│   │   ├── docker.go
│   │   ├── environment_test.go
│   │   ├── environment.go
│   │   ├── group_test.go
│   │   ├── group.go
│   │   ├── kubernetes_test.go
│   │   ├── kubernetes.go
│   │   ├── mocks_test.go
│   │   ├── schema_test.go
│   │   ├── schema.go
│   │   ├── server_test.go
│   │   ├── server.go
│   │   ├── settings_test.go
│   │   ├── settings.go
│   │   ├── stack_test.go
│   │   ├── stack.go
│   │   ├── tag_test.go
│   │   ├── tag.go
│   │   ├── team_test.go
│   │   ├── team.go
│   │   ├── testdata
│   │   │   ├── invalid_tools.yaml
│   │   │   └── valid_tools.yaml
│   │   ├── user_test.go
│   │   ├── user.go
│   │   ├── utils_test.go
│   │   └── utils.go
│   └── tooldef
│       ├── tooldef_test.go
│       ├── tooldef.go
│       └── tools.yaml
├── LICENSE
├── Makefile
├── pkg
│   ├── portainer
│   │   ├── client
│   │   │   ├── access_group_test.go
│   │   │   ├── access_group.go
│   │   │   ├── client_test.go
│   │   │   ├── client.go
│   │   │   ├── docker_test.go
│   │   │   ├── docker.go
│   │   │   ├── environment_test.go
│   │   │   ├── environment.go
│   │   │   ├── group_test.go
│   │   │   ├── group.go
│   │   │   ├── kubernetes_test.go
│   │   │   ├── kubernetes.go
│   │   │   ├── mocks_test.go
│   │   │   ├── settings_test.go
│   │   │   ├── settings.go
│   │   │   ├── stack_test.go
│   │   │   ├── stack.go
│   │   │   ├── tag_test.go
│   │   │   ├── tag.go
│   │   │   ├── team_test.go
│   │   │   ├── team.go
│   │   │   ├── user_test.go
│   │   │   ├── user.go
│   │   │   ├── version_test.go
│   │   │   └── version.go
│   │   ├── models
│   │   │   ├── access_group_test.go
│   │   │   ├── access_group.go
│   │   │   ├── access_policy_test.go
│   │   │   ├── access_policy.go
│   │   │   ├── docker.go
│   │   │   ├── environment_test.go
│   │   │   ├── environment.go
│   │   │   ├── group_test.go
│   │   │   ├── group.go
│   │   │   ├── kubernetes.go
│   │   │   ├── settings_test.go
│   │   │   ├── settings.go
│   │   │   ├── stack_test.go
│   │   │   ├── stack.go
│   │   │   ├── tag_test.go
│   │   │   ├── tag.go
│   │   │   ├── team_test.go
│   │   │   ├── team.go
│   │   │   ├── user_test.go
│   │   │   └── user.go
│   │   └── utils
│   │       ├── utils_test.go
│   │       └── utils.go
│   └── toolgen
│       ├── param_test.go
│       ├── param.go
│       ├── yaml_test.go
│       └── yaml.go
├── README.md
├── tests
│   └── integration
│       ├── access_group_test.go
│       ├── containers
│       │   └── portainer.go
│       ├── docker_test.go
│       ├── environment_test.go
│       ├── group_test.go
│       ├── helpers
│       │   └── test_env.go
│       ├── server_test.go
│       ├── settings_test.go
│       ├── stack_test.go
│       ├── tag_test.go
│       ├── team_test.go
│       └── user_test.go
└── token.sh
```

# Files

--------------------------------------------------------------------------------
/pkg/portainer/client/environment_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | 	"github.com/stretchr/testify/mock"
 11 | )
 12 | 
 13 | func TestGetEnvironments(t *testing.T) {
 14 | 	tests := []struct {
 15 | 		name          string
 16 | 		mockEndpoints []*apimodels.PortainereeEndpoint
 17 | 		mockError     error
 18 | 		expected      []models.Environment
 19 | 		expectedError bool
 20 | 	}{
 21 | 		{
 22 | 			name: "successful retrieval",
 23 | 			mockEndpoints: []*apimodels.PortainereeEndpoint{
 24 | 				{
 25 | 					ID:      1,
 26 | 					Name:    "env1",
 27 | 					GroupID: 1,
 28 | 					Status:  1, // active
 29 | 					Type:    1, // docker-local
 30 | 					TagIds:  []int64{1, 2},
 31 | 					UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
 32 | 						"1": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
 33 | 						"2": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
 34 | 						"3": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
 35 | 						"4": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
 36 | 						"5": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
 37 | 					},
 38 | 					TeamAccessPolicies: apimodels.PortainerTeamAccessPolicies{
 39 | 						"6":  apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
 40 | 						"7":  apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
 41 | 						"8":  apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
 42 | 						"9":  apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
 43 | 						"10": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
 44 | 					},
 45 | 				},
 46 | 				{
 47 | 					ID:      2,
 48 | 					Name:    "env2",
 49 | 					GroupID: 1,
 50 | 					Status:  2, // inactive
 51 | 					Type:    2, // docker-agent
 52 | 					TagIds:  []int64{3},
 53 | 				},
 54 | 				{
 55 | 					ID:     3,
 56 | 					Name:   "env3",
 57 | 					Status: 0, // unknown
 58 | 					Type:   0, // unknown
 59 | 				},
 60 | 			},
 61 | 			expected: []models.Environment{
 62 | 				{
 63 | 					ID:     1,
 64 | 					Name:   "env1",
 65 | 					Status: "active",
 66 | 					Type:   "docker-local",
 67 | 					TagIds: []int{1, 2},
 68 | 					UserAccesses: map[int]string{
 69 | 						1: "environment_administrator",
 70 | 						2: "helpdesk_user",
 71 | 						3: "standard_user",
 72 | 						4: "readonly_user",
 73 | 						5: "operator_user",
 74 | 					},
 75 | 					TeamAccesses: map[int]string{
 76 | 						6:  "environment_administrator",
 77 | 						7:  "helpdesk_user",
 78 | 						8:  "standard_user",
 79 | 						9:  "readonly_user",
 80 | 						10: "operator_user",
 81 | 					},
 82 | 				},
 83 | 				{
 84 | 					ID:           2,
 85 | 					Name:         "env2",
 86 | 					Status:       "inactive",
 87 | 					Type:         "docker-agent",
 88 | 					TagIds:       []int{3},
 89 | 					UserAccesses: map[int]string{},
 90 | 					TeamAccesses: map[int]string{},
 91 | 				},
 92 | 				{
 93 | 					ID:           3,
 94 | 					Name:         "env3",
 95 | 					Status:       "unknown",
 96 | 					Type:         "unknown",
 97 | 					TagIds:       []int{},
 98 | 					UserAccesses: map[int]string{},
 99 | 					TeamAccesses: map[int]string{},
100 | 				},
101 | 			},
102 | 		},
103 | 		{
104 | 			name:          "empty environments",
105 | 			mockEndpoints: []*apimodels.PortainereeEndpoint{},
106 | 			expected:      []models.Environment{},
107 | 		},
108 | 		{
109 | 			name:          "list error",
110 | 			mockError:     errors.New("failed to list endpoints"),
111 | 			expectedError: true,
112 | 		},
113 | 	}
114 | 
115 | 	for _, tt := range tests {
116 | 		t.Run(tt.name, func(t *testing.T) {
117 | 			mockAPI := new(MockPortainerAPI)
118 | 			mockAPI.On("ListEndpoints").Return(tt.mockEndpoints, tt.mockError)
119 | 
120 | 			client := &PortainerClient{cli: mockAPI}
121 | 
122 | 			environments, err := client.GetEnvironments()
123 | 
124 | 			if tt.expectedError {
125 | 				assert.Error(t, err)
126 | 				return
127 | 			}
128 | 			assert.NoError(t, err)
129 | 			assert.Equal(t, tt.expected, environments)
130 | 			mockAPI.AssertExpectations(t)
131 | 		})
132 | 	}
133 | }
134 | 
135 | func TestUpdateEnvironmentTags(t *testing.T) {
136 | 	tests := []struct {
137 | 		name          string
138 | 		envID         int
139 | 		tagIds        []int
140 | 		mockError     error
141 | 		expectedError bool
142 | 	}{
143 | 		{
144 | 			name:   "successful update",
145 | 			envID:  1,
146 | 			tagIds: []int{1, 2, 3},
147 | 		},
148 | 		{
149 | 			name:          "update error",
150 | 			envID:         1,
151 | 			tagIds:        []int{1},
152 | 			mockError:     errors.New("failed to update tags"),
153 | 			expectedError: true,
154 | 		},
155 | 		{
156 | 			name:   "empty tags",
157 | 			envID:  1,
158 | 			tagIds: []int{},
159 | 		},
160 | 	}
161 | 
162 | 	for _, tt := range tests {
163 | 		t.Run(tt.name, func(t *testing.T) {
164 | 			mockAPI := new(MockPortainerAPI)
165 | 			mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
166 | 
167 | 			client := &PortainerClient{cli: mockAPI}
168 | 
169 | 			err := client.UpdateEnvironmentTags(tt.envID, tt.tagIds)
170 | 
171 | 			if tt.expectedError {
172 | 				assert.Error(t, err)
173 | 				return
174 | 			}
175 | 			assert.NoError(t, err)
176 | 			mockAPI.AssertExpectations(t)
177 | 		})
178 | 	}
179 | }
180 | 
181 | func TestUpdateEnvironmentUserAccesses(t *testing.T) {
182 | 	tests := []struct {
183 | 		name          string
184 | 		envID         int
185 | 		userAccesses  map[int]string
186 | 		mockError     error
187 | 		expectedError bool
188 | 	}{
189 | 		{
190 | 			name:  "successful update",
191 | 			envID: 1,
192 | 			userAccesses: map[int]string{
193 | 				1: "environment_administrator",
194 | 				2: "helpdesk_user",
195 | 				3: "standard_user",
196 | 				4: "readonly_user",
197 | 				5: "operator_user",
198 | 			},
199 | 		},
200 | 		{
201 | 			name:  "update error",
202 | 			envID: 1,
203 | 			userAccesses: map[int]string{
204 | 				1: "environment_administrator",
205 | 			},
206 | 			mockError:     errors.New("failed to update user accesses"),
207 | 			expectedError: true,
208 | 		},
209 | 		{
210 | 			name:         "empty accesses",
211 | 			envID:        1,
212 | 			userAccesses: map[int]string{},
213 | 		},
214 | 	}
215 | 
216 | 	for _, tt := range tests {
217 | 		t.Run(tt.name, func(t *testing.T) {
218 | 			mockAPI := new(MockPortainerAPI)
219 | 			mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
220 | 
221 | 			client := &PortainerClient{cli: mockAPI}
222 | 
223 | 			err := client.UpdateEnvironmentUserAccesses(tt.envID, tt.userAccesses)
224 | 
225 | 			if tt.expectedError {
226 | 				assert.Error(t, err)
227 | 				return
228 | 			}
229 | 			assert.NoError(t, err)
230 | 			mockAPI.AssertExpectations(t)
231 | 		})
232 | 	}
233 | }
234 | 
235 | func TestUpdateEnvironmentTeamAccesses(t *testing.T) {
236 | 	tests := []struct {
237 | 		name          string
238 | 		envID         int
239 | 		teamAccesses  map[int]string
240 | 		mockError     error
241 | 		expectedError bool
242 | 	}{
243 | 		{
244 | 			name:  "successful update",
245 | 			envID: 1,
246 | 			teamAccesses: map[int]string{
247 | 				1: "environment_administrator",
248 | 				2: "helpdesk_user",
249 | 				3: "standard_user",
250 | 				4: "readonly_user",
251 | 				5: "operator_user",
252 | 			},
253 | 		},
254 | 		{
255 | 			name:  "update error",
256 | 			envID: 1,
257 | 			teamAccesses: map[int]string{
258 | 				1: "environment_administrator",
259 | 			},
260 | 			mockError:     errors.New("failed to update team accesses"),
261 | 			expectedError: true,
262 | 		},
263 | 		{
264 | 			name:         "empty accesses",
265 | 			envID:        1,
266 | 			teamAccesses: map[int]string{},
267 | 		},
268 | 	}
269 | 
270 | 	for _, tt := range tests {
271 | 		t.Run(tt.name, func(t *testing.T) {
272 | 			mockAPI := new(MockPortainerAPI)
273 | 			mockAPI.On("UpdateEndpoint", int64(tt.envID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
274 | 
275 | 			client := &PortainerClient{cli: mockAPI}
276 | 
277 | 			err := client.UpdateEnvironmentTeamAccesses(tt.envID, tt.teamAccesses)
278 | 
279 | 			if tt.expectedError {
280 | 				assert.Error(t, err)
281 | 				return
282 | 			}
283 | 			assert.NoError(t, err)
284 | 			mockAPI.AssertExpectations(t)
285 | 		})
286 | 	}
287 | }
288 | 
```

--------------------------------------------------------------------------------
/tests/integration/team_test.go:
--------------------------------------------------------------------------------

```go
  1 | package integration
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"testing"
  6 | 
  7 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
  8 | 	"github.com/portainer/portainer-mcp/internal/mcp"
  9 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 10 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
 11 | 
 12 | 	"github.com/stretchr/testify/assert"
 13 | 	"github.com/stretchr/testify/require"
 14 | )
 15 | 
 16 | const (
 17 | 	testTeamName         = "test-mcp-team"
 18 | 	testTeamNewName      = "test-mcp-team-updated"
 19 | 	testUser1Name        = "test-team-user1"
 20 | 	testUser2Name        = "test-team-user2"
 21 | 	testTeamUserPassword = "testpassword"
 22 | 	teamUserRoleStandard = 2 // Portainer API role ID for Standard User
 23 | )
 24 | 
 25 | // prepareTeamManagementTestEnvironment creates test users that can be assigned to teams
 26 | func prepareTeamManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int) {
 27 | 	testUser1ID, err := env.RawClient.CreateUser(testUser1Name, testTeamUserPassword, teamUserRoleStandard)
 28 | 	require.NoError(t, err, "Failed to create first test user via raw client")
 29 | 
 30 | 	testUser2ID, err := env.RawClient.CreateUser(testUser2Name, testTeamUserPassword, teamUserRoleStandard)
 31 | 	require.NoError(t, err, "Failed to create second test user via raw client")
 32 | 
 33 | 	return int(testUser1ID), int(testUser2ID)
 34 | }
 35 | 
 36 | // TestTeamManagement is an integration test suite that verifies the complete
 37 | // lifecycle of team management in Portainer MCP. It tests team creation,
 38 | // listing, name updates, and member management.
 39 | func TestTeamManagement(t *testing.T) {
 40 | 	env := helpers.NewTestEnv(t)
 41 | 	defer env.Cleanup(t)
 42 | 
 43 | 	// Prepare the test environment
 44 | 	testUser1ID, testUser2ID := prepareTeamManagementTestEnvironment(t, env)
 45 | 
 46 | 	var testTeamID int
 47 | 
 48 | 	// Subtest: Team Creation
 49 | 	// Verifies that:
 50 | 	// - A new team can be created via the MCP handler.
 51 | 	// - The handler response indicates success with an ID.
 52 | 	// - The created team exists in Portainer when checked directly via the Raw Client.
 53 | 	t.Run("Team Creation", func(t *testing.T) {
 54 | 		handler := env.MCPServer.HandleCreateTeam()
 55 | 		request := mcp.CreateMCPRequest(map[string]any{
 56 | 			"name": testTeamName,
 57 | 		})
 58 | 
 59 | 		result, err := handler(env.Ctx, request)
 60 | 		require.NoError(t, err, "Failed to create team via MCP handler")
 61 | 
 62 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 63 | 		require.True(t, ok, "Expected text content in MCP response")
 64 | 
 65 | 		// Check for success message and extract ID for later tests
 66 | 		assert.Contains(t, textContent.Text, "Team created successfully with ID:", "Success message prefix mismatch")
 67 | 
 68 | 		// Verify by fetching teams directly via client and finding the created team by name
 69 | 		team, err := env.RawClient.GetTeamByName(testTeamName)
 70 | 		require.NoError(t, err, "Failed to get team directly via client after creation")
 71 | 		assert.Equal(t, testTeamName, team.Name, "Team name mismatch")
 72 | 
 73 | 		// Extract team ID for subsequent tests
 74 | 		testTeamID = int(team.ID)
 75 | 	})
 76 | 
 77 | 	// Subtest: Team Listing
 78 | 	// Verifies that:
 79 | 	// - The team list can be retrieved via the MCP handler
 80 | 	// - The list contains the expected number of teams (one, the test team)
 81 | 	// - The team has the correct name property
 82 | 	// - The team data matches the team obtained directly via Raw Client when converted to the same model
 83 | 	t.Run("Team Listing", func(t *testing.T) {
 84 | 		handler := env.MCPServer.HandleGetTeams()
 85 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
 86 | 		require.NoError(t, err, "Failed to get teams via MCP handler")
 87 | 
 88 | 		assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
 89 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 90 | 		assert.True(t, ok, "Expected text content in MCP response")
 91 | 
 92 | 		var retrievedTeams []models.Team
 93 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedTeams)
 94 | 		require.NoError(t, err, "Failed to unmarshal retrieved teams")
 95 | 		require.Len(t, retrievedTeams, 1, "Expected exactly one team after unmarshalling")
 96 | 
 97 | 		team := retrievedTeams[0]
 98 | 		assert.Equal(t, testTeamName, team.Name, "Team name mismatch")
 99 | 
100 | 		// Fetch the same team directly via the client
101 | 		rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
102 | 		require.NoError(t, err, "Failed to get team directly via client")
103 | 
104 | 		// Convert the raw team to the expected Team model
105 | 		rawMemberships, err := env.RawClient.ListTeamMemberships()
106 | 		require.NoError(t, err, "Failed to get team memberships directly via client")
107 | 		expectedTeam := models.ConvertToTeam(rawTeam, rawMemberships)
108 | 		assert.Equal(t, expectedTeam, team, "Team mismatch between MCP handler and direct client call")
109 | 	})
110 | 
111 | 	// Subtest: Team Name Update
112 | 	// Verifies that:
113 | 	// - A team's name can be updated via the MCP handler
114 | 	// - The handler response indicates success
115 | 	// - The team name is actually updated when checked directly via Raw Client
116 | 	t.Run("Team Name Update", func(t *testing.T) {
117 | 		handler := env.MCPServer.HandleUpdateTeamName()
118 | 		request := mcp.CreateMCPRequest(map[string]any{
119 | 			"id":   float64(testTeamID),
120 | 			"name": testTeamNewName,
121 | 		})
122 | 
123 | 		result, err := handler(env.Ctx, request)
124 | 		require.NoError(t, err, "Failed to update team name via MCP handler")
125 | 
126 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
127 | 		require.True(t, ok, "Expected text content in MCP response for team name update")
128 | 		assert.Contains(t, textContent.Text, "Team name updated successfully", "Success message mismatch for team name update")
129 | 
130 | 		// Verify by fetching team directly via raw client
131 | 		rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
132 | 		require.NoError(t, err, "Failed to get team directly via client after name update")
133 | 		assert.Equal(t, testTeamNewName, rawTeam.Name, "Team name was not updated")
134 | 	})
135 | 
136 | 	// Subtest: Team Members Update
137 | 	// Verifies that:
138 | 	// - Team members can be updated via the MCP handler
139 | 	// - The handler response indicates success
140 | 	// - The team memberships are correctly updated when checked directly via Raw Client
141 | 	// - Both test users are properly assigned to the team
142 | 	t.Run("Team Members Update", func(t *testing.T) {
143 | 		handler := env.MCPServer.HandleUpdateTeamMembers()
144 | 		request := mcp.CreateMCPRequest(map[string]any{
145 | 			"id":      float64(testTeamID),
146 | 			"userIds": []any{float64(testUser1ID), float64(testUser2ID)},
147 | 		})
148 | 
149 | 		result, err := handler(env.Ctx, request)
150 | 		require.NoError(t, err, "Failed to update team members via MCP handler")
151 | 
152 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
153 | 		require.True(t, ok, "Expected text content in MCP response for team members update")
154 | 		assert.Contains(t, textContent.Text, "Team members updated successfully", "Success message mismatch for team members update")
155 | 
156 | 		// Verify by fetching team directly via raw client
157 | 		rawTeam, err := env.RawClient.GetTeam(int64(testTeamID))
158 | 		require.NoError(t, err, "Failed to get team directly via client after member update")
159 | 		rawMemberships, err := env.RawClient.ListTeamMemberships()
160 | 		require.NoError(t, err, "Failed to get team memberships directly via client")
161 | 		expectedTeam := models.ConvertToTeam(rawTeam, rawMemberships)
162 | 		assert.ElementsMatch(t, []int{testUser1ID, testUser2ID}, expectedTeam.MemberIDs, "Team memberships mismatch")
163 | 	})
164 | }
165 | 
```

--------------------------------------------------------------------------------
/internal/mcp/mocks_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"net/http"
  5 | 
  6 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  7 | 	"github.com/stretchr/testify/mock"
  8 | )
  9 | 
 10 | // Mock Implementation Patterns:
 11 | //
 12 | // This file contains mock implementations of the PortainerClient interface.
 13 | // The following patterns are used throughout the mocks:
 14 | //
 15 | // 1. Methods returning (T, error):
 16 | //    - Uses m.Called() to record the method call and get mock behavior
 17 | //    - Includes nil check on first return value to avoid type assertion panics
 18 | //    - Example:
 19 | //      func (m *Mock) Method() (T, error) {
 20 | //          args := m.Called()
 21 | //          if args.Get(0) == nil {
 22 | //              return nil, args.Error(1)
 23 | //          }
 24 | //          return args.Get(0).(T), args.Error(1)
 25 | //      }
 26 | //
 27 | // 2. Methods returning only error:
 28 | //    - Uses m.Called() with any parameters
 29 | //    - Returns only the error value
 30 | //    - Example:
 31 | //      func (m *Mock) Method(param string) error {
 32 | //          args := m.Called(param)
 33 | //          return args.Error(0)
 34 | //      }
 35 | //
 36 | // Usage in Tests:
 37 | //   mock := new(MockPortainerClient)
 38 | //   mock.On("MethodName").Return(expectedValue, nil)
 39 | //   result, err := mock.MethodName()
 40 | //   mock.AssertExpectations(t)
 41 | 
 42 | // MockPortainerClient is a mock implementation of the PortainerClient interface
 43 | type MockPortainerClient struct {
 44 | 	mock.Mock
 45 | }
 46 | 
 47 | // Tag methods
 48 | 
 49 | func (m *MockPortainerClient) GetEnvironmentTags() ([]models.EnvironmentTag, error) {
 50 | 	args := m.Called()
 51 | 	if args.Get(0) == nil {
 52 | 		return nil, args.Error(1)
 53 | 	}
 54 | 	return args.Get(0).([]models.EnvironmentTag), args.Error(1)
 55 | }
 56 | 
 57 | func (m *MockPortainerClient) CreateEnvironmentTag(name string) (int, error) {
 58 | 	args := m.Called(name)
 59 | 	return args.Int(0), args.Error(1)
 60 | }
 61 | 
 62 | // Environment methods
 63 | 
 64 | func (m *MockPortainerClient) GetEnvironments() ([]models.Environment, error) {
 65 | 	args := m.Called()
 66 | 	if args.Get(0) == nil {
 67 | 		return nil, args.Error(1)
 68 | 	}
 69 | 	return args.Get(0).([]models.Environment), args.Error(1)
 70 | }
 71 | 
 72 | func (m *MockPortainerClient) UpdateEnvironmentTags(id int, tagIds []int) error {
 73 | 	args := m.Called(id, tagIds)
 74 | 	return args.Error(0)
 75 | }
 76 | 
 77 | func (m *MockPortainerClient) UpdateEnvironmentUserAccesses(id int, userAccesses map[int]string) error {
 78 | 	args := m.Called(id, userAccesses)
 79 | 	return args.Error(0)
 80 | }
 81 | 
 82 | func (m *MockPortainerClient) UpdateEnvironmentTeamAccesses(id int, teamAccesses map[int]string) error {
 83 | 	args := m.Called(id, teamAccesses)
 84 | 	return args.Error(0)
 85 | }
 86 | 
 87 | // Environment Group methods
 88 | 
 89 | func (m *MockPortainerClient) GetEnvironmentGroups() ([]models.Group, error) {
 90 | 	args := m.Called()
 91 | 	if args.Get(0) == nil {
 92 | 		return nil, args.Error(1)
 93 | 	}
 94 | 	return args.Get(0).([]models.Group), args.Error(1)
 95 | }
 96 | 
 97 | func (m *MockPortainerClient) CreateEnvironmentGroup(name string, environmentIds []int) (int, error) {
 98 | 	args := m.Called(name, environmentIds)
 99 | 	return args.Int(0), args.Error(1)
100 | }
101 | 
102 | func (m *MockPortainerClient) UpdateEnvironmentGroupName(id int, name string) error {
103 | 	args := m.Called(id, name)
104 | 	return args.Error(0)
105 | }
106 | 
107 | func (m *MockPortainerClient) UpdateEnvironmentGroupEnvironments(id int, environmentIds []int) error {
108 | 	args := m.Called(id, environmentIds)
109 | 	return args.Error(0)
110 | }
111 | 
112 | func (m *MockPortainerClient) UpdateEnvironmentGroupTags(id int, tagIds []int) error {
113 | 	args := m.Called(id, tagIds)
114 | 	return args.Error(0)
115 | }
116 | 
117 | // Access Group methods
118 | 
119 | func (m *MockPortainerClient) GetAccessGroups() ([]models.AccessGroup, error) {
120 | 	args := m.Called()
121 | 	if args.Get(0) == nil {
122 | 		return nil, args.Error(1)
123 | 	}
124 | 	return args.Get(0).([]models.AccessGroup), args.Error(1)
125 | }
126 | 
127 | func (m *MockPortainerClient) CreateAccessGroup(name string, environmentIds []int) (int, error) {
128 | 	args := m.Called(name, environmentIds)
129 | 	return args.Int(0), args.Error(1)
130 | }
131 | 
132 | func (m *MockPortainerClient) UpdateAccessGroupName(id int, name string) error {
133 | 	args := m.Called(id, name)
134 | 	return args.Error(0)
135 | }
136 | 
137 | func (m *MockPortainerClient) UpdateAccessGroupUserAccesses(id int, userAccesses map[int]string) error {
138 | 	args := m.Called(id, userAccesses)
139 | 	return args.Error(0)
140 | }
141 | 
142 | func (m *MockPortainerClient) UpdateAccessGroupTeamAccesses(id int, teamAccesses map[int]string) error {
143 | 	args := m.Called(id, teamAccesses)
144 | 	return args.Error(0)
145 | }
146 | 
147 | func (m *MockPortainerClient) AddEnvironmentToAccessGroup(id int, environmentId int) error {
148 | 	args := m.Called(id, environmentId)
149 | 	return args.Error(0)
150 | }
151 | 
152 | func (m *MockPortainerClient) RemoveEnvironmentFromAccessGroup(id int, environmentId int) error {
153 | 	args := m.Called(id, environmentId)
154 | 	return args.Error(0)
155 | }
156 | 
157 | // Stack methods
158 | 
159 | func (m *MockPortainerClient) GetStacks() ([]models.Stack, error) {
160 | 	args := m.Called()
161 | 	if args.Get(0) == nil {
162 | 		return nil, args.Error(1)
163 | 	}
164 | 	return args.Get(0).([]models.Stack), args.Error(1)
165 | }
166 | 
167 | func (m *MockPortainerClient) GetStackFile(id int) (string, error) {
168 | 	args := m.Called(id)
169 | 	return args.String(0), args.Error(1)
170 | }
171 | 
172 | func (m *MockPortainerClient) CreateStack(name string, file string, environmentGroupIds []int) (int, error) {
173 | 	args := m.Called(name, file, environmentGroupIds)
174 | 	return args.Int(0), args.Error(1)
175 | }
176 | 
177 | func (m *MockPortainerClient) UpdateStack(id int, file string, environmentGroupIds []int) error {
178 | 	args := m.Called(id, file, environmentGroupIds)
179 | 	return args.Error(0)
180 | }
181 | 
182 | // Team methods
183 | 
184 | func (m *MockPortainerClient) CreateTeam(name string) (int, error) {
185 | 	args := m.Called(name)
186 | 	return args.Int(0), args.Error(1)
187 | }
188 | 
189 | func (m *MockPortainerClient) GetTeams() ([]models.Team, error) {
190 | 	args := m.Called()
191 | 	if args.Get(0) == nil {
192 | 		return nil, args.Error(1)
193 | 	}
194 | 	return args.Get(0).([]models.Team), args.Error(1)
195 | }
196 | 
197 | func (m *MockPortainerClient) UpdateTeamName(id int, name string) error {
198 | 	args := m.Called(id, name)
199 | 	return args.Error(0)
200 | }
201 | 
202 | func (m *MockPortainerClient) UpdateTeamMembers(id int, userIds []int) error {
203 | 	args := m.Called(id, userIds)
204 | 	return args.Error(0)
205 | }
206 | 
207 | // User methods
208 | 
209 | func (m *MockPortainerClient) GetUsers() ([]models.User, error) {
210 | 	args := m.Called()
211 | 	if args.Get(0) == nil {
212 | 		return nil, args.Error(1)
213 | 	}
214 | 	return args.Get(0).([]models.User), args.Error(1)
215 | }
216 | 
217 | func (m *MockPortainerClient) UpdateUserRole(id int, role string) error {
218 | 	args := m.Called(id, role)
219 | 	return args.Error(0)
220 | }
221 | 
222 | // Settings methods
223 | 
224 | func (m *MockPortainerClient) GetSettings() (models.PortainerSettings, error) {
225 | 	args := m.Called()
226 | 	if args.Get(0) == nil {
227 | 		return models.PortainerSettings{}, args.Error(1)
228 | 	}
229 | 	return args.Get(0).(models.PortainerSettings), args.Error(1)
230 | }
231 | 
232 | func (m *MockPortainerClient) GetVersion() (string, error) {
233 | 	args := m.Called()
234 | 	if args.Get(0) == nil {
235 | 		return "", args.Error(1)
236 | 	}
237 | 	return args.Get(0).(string), args.Error(1)
238 | }
239 | 
240 | // Docker Proxy methods
241 | func (m *MockPortainerClient) ProxyDockerRequest(opts models.DockerProxyRequestOptions) (*http.Response, error) {
242 | 	args := m.Called(opts)
243 | 	if args.Get(0) == nil {
244 | 		return nil, args.Error(1)
245 | 	}
246 | 	return args.Get(0).(*http.Response), args.Error(1)
247 | }
248 | 
249 | // Kubernetes Proxy methods
250 | func (m *MockPortainerClient) ProxyKubernetesRequest(opts models.KubernetesProxyRequestOptions) (*http.Response, error) {
251 | 	args := m.Called(opts)
252 | 	if args.Get(0) == nil {
253 | 		return nil, args.Error(1)
254 | 	}
255 | 	return args.Get(0).(*http.Response), args.Error(1)
256 | }
257 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/team_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | )
 11 | 
 12 | func TestGetTeams(t *testing.T) {
 13 | 	tests := []struct {
 14 | 		name            string
 15 | 		mockTeams       []*apimodels.PortainerTeam
 16 | 		mockMemberships []*apimodels.PortainerTeamMembership
 17 | 		mockTeamError   error
 18 | 		mockMemberError error
 19 | 		expected        []models.Team
 20 | 		expectedError   bool
 21 | 	}{
 22 | 		{
 23 | 			name: "successful retrieval",
 24 | 			mockTeams: []*apimodels.PortainerTeam{
 25 | 				{
 26 | 					ID:   1,
 27 | 					Name: "team1",
 28 | 				},
 29 | 				{
 30 | 					ID:   2,
 31 | 					Name: "team2",
 32 | 				},
 33 | 			},
 34 | 			mockMemberships: []*apimodels.PortainerTeamMembership{
 35 | 				{
 36 | 					ID:     1,
 37 | 					UserID: 100,
 38 | 					TeamID: 1,
 39 | 				},
 40 | 				{
 41 | 					ID:     2,
 42 | 					UserID: 101,
 43 | 					TeamID: 1,
 44 | 				},
 45 | 				{
 46 | 					ID:     3,
 47 | 					UserID: 102,
 48 | 					TeamID: 2,
 49 | 				},
 50 | 			},
 51 | 			expected: []models.Team{
 52 | 				{
 53 | 					ID:        1,
 54 | 					Name:      "team1",
 55 | 					MemberIDs: []int{100, 101},
 56 | 				},
 57 | 				{
 58 | 					ID:        2,
 59 | 					Name:      "team2",
 60 | 					MemberIDs: []int{102},
 61 | 				},
 62 | 			},
 63 | 		},
 64 | 		{
 65 | 			name: "teams with empty memberships",
 66 | 			mockTeams: []*apimodels.PortainerTeam{
 67 | 				{
 68 | 					ID:   1,
 69 | 					Name: "team1",
 70 | 				},
 71 | 				{
 72 | 					ID:   2,
 73 | 					Name: "team2",
 74 | 				},
 75 | 			},
 76 | 			mockMemberships: []*apimodels.PortainerTeamMembership{},
 77 | 			expected: []models.Team{
 78 | 				{
 79 | 					ID:        1,
 80 | 					Name:      "team1",
 81 | 					MemberIDs: []int{},
 82 | 				},
 83 | 				{
 84 | 					ID:        2,
 85 | 					Name:      "team2",
 86 | 					MemberIDs: []int{},
 87 | 				},
 88 | 			},
 89 | 		},
 90 | 		{
 91 | 			name:            "empty teams",
 92 | 			mockTeams:       []*apimodels.PortainerTeam{},
 93 | 			mockMemberships: []*apimodels.PortainerTeamMembership{},
 94 | 			expected:        []models.Team{},
 95 | 		},
 96 | 		{
 97 | 			name:          "list teams error",
 98 | 			mockTeamError: errors.New("failed to list teams"),
 99 | 			expectedError: true,
100 | 		},
101 | 		{
102 | 			name: "list memberships error",
103 | 			mockTeams: []*apimodels.PortainerTeam{
104 | 				{
105 | 					ID:   1,
106 | 					Name: "team1",
107 | 				},
108 | 			},
109 | 			mockMemberError: errors.New("failed to list memberships"),
110 | 			expectedError:   true,
111 | 		},
112 | 	}
113 | 
114 | 	for _, tt := range tests {
115 | 		t.Run(tt.name, func(t *testing.T) {
116 | 			mockAPI := new(MockPortainerAPI)
117 | 			mockAPI.On("ListTeams").Return(tt.mockTeams, tt.mockTeamError)
118 | 			mockAPI.On("ListTeamMemberships").Return(tt.mockMemberships, tt.mockMemberError)
119 | 
120 | 			client := &PortainerClient{cli: mockAPI}
121 | 
122 | 			teams, err := client.GetTeams()
123 | 
124 | 			if tt.expectedError {
125 | 				assert.Error(t, err)
126 | 				return
127 | 			}
128 | 			assert.NoError(t, err)
129 | 			assert.Equal(t, tt.expected, teams)
130 | 			mockAPI.AssertExpectations(t)
131 | 		})
132 | 	}
133 | }
134 | 
135 | func TestUpdateTeamName(t *testing.T) {
136 | 	tests := []struct {
137 | 		name          string
138 | 		teamID        int
139 | 		teamName      string
140 | 		mockError     error
141 | 		expectedError bool
142 | 	}{
143 | 		{
144 | 			name:     "successful update",
145 | 			teamID:   1,
146 | 			teamName: "new-team-name",
147 | 		},
148 | 		{
149 | 			name:          "update error",
150 | 			teamID:        2,
151 | 			teamName:      "new-team-name",
152 | 			mockError:     errors.New("failed to update team name"),
153 | 			expectedError: true,
154 | 		},
155 | 	}
156 | 
157 | 	for _, tt := range tests {
158 | 		t.Run(tt.name, func(t *testing.T) {
159 | 			mockAPI := new(MockPortainerAPI)
160 | 			mockAPI.On("UpdateTeamName", tt.teamID, tt.teamName).Return(tt.mockError)
161 | 
162 | 			client := &PortainerClient{cli: mockAPI}
163 | 
164 | 			err := client.UpdateTeamName(tt.teamID, tt.teamName)
165 | 
166 | 			if tt.expectedError {
167 | 				assert.Error(t, err)
168 | 				return
169 | 			}
170 | 			assert.NoError(t, err)
171 | 			mockAPI.AssertExpectations(t)
172 | 		})
173 | 	}
174 | }
175 | 
176 | func TestCreateTeam(t *testing.T) {
177 | 	tests := []struct {
178 | 		name          string
179 | 		teamName      string
180 | 		mockID        int64
181 | 		mockError     error
182 | 		expected      int
183 | 		expectedError bool
184 | 	}{
185 | 		{
186 | 			name:     "successful creation",
187 | 			teamName: "new-team",
188 | 			mockID:   1,
189 | 			expected: 1,
190 | 		},
191 | 		{
192 | 			name:          "create error",
193 | 			teamName:      "new-team",
194 | 			mockError:     errors.New("failed to create team"),
195 | 			expectedError: true,
196 | 		},
197 | 	}
198 | 
199 | 	for _, tt := range tests {
200 | 		t.Run(tt.name, func(t *testing.T) {
201 | 			mockAPI := new(MockPortainerAPI)
202 | 			mockAPI.On("CreateTeam", tt.teamName).Return(tt.mockID, tt.mockError)
203 | 
204 | 			client := &PortainerClient{cli: mockAPI}
205 | 
206 | 			id, err := client.CreateTeam(tt.teamName)
207 | 
208 | 			if tt.expectedError {
209 | 				assert.Error(t, err)
210 | 				return
211 | 			}
212 | 			assert.NoError(t, err)
213 | 			assert.Equal(t, tt.expected, id)
214 | 			mockAPI.AssertExpectations(t)
215 | 		})
216 | 	}
217 | }
218 | 
219 | func TestUpdateTeamMembers(t *testing.T) {
220 | 	tests := []struct {
221 | 		name            string
222 | 		teamID          int
223 | 		userIDs         []int
224 | 		mockMemberships []*apimodels.PortainerTeamMembership
225 | 		mockListError   error
226 | 		mockDeleteError error
227 | 		mockCreateError error
228 | 		expectedError   bool
229 | 	}{
230 | 		{
231 | 			name:    "successful update - add and remove members",
232 | 			teamID:  1,
233 | 			userIDs: []int{101, 102}, // Want to keep 101 and add 102
234 | 			mockMemberships: []*apimodels.PortainerTeamMembership{
235 | 				{
236 | 					ID:     1,
237 | 					UserID: 100, // Should be removed
238 | 					TeamID: 1,
239 | 				},
240 | 				{
241 | 					ID:     2,
242 | 					UserID: 101, // Should be kept
243 | 					TeamID: 1,
244 | 				},
245 | 			},
246 | 		},
247 | 		{
248 | 			name:    "successful update - no changes needed",
249 | 			teamID:  1,
250 | 			userIDs: []int{100, 101},
251 | 			mockMemberships: []*apimodels.PortainerTeamMembership{
252 | 				{
253 | 					ID:     1,
254 | 					UserID: 100,
255 | 					TeamID: 1,
256 | 				},
257 | 				{
258 | 					ID:     2,
259 | 					UserID: 101,
260 | 					TeamID: 1,
261 | 				},
262 | 			},
263 | 		},
264 | 		{
265 | 			name:          "list memberships error",
266 | 			teamID:        1,
267 | 			userIDs:       []int{100},
268 | 			mockListError: errors.New("failed to list memberships"),
269 | 			expectedError: true,
270 | 		},
271 | 		{
272 | 			name:    "delete membership error",
273 | 			teamID:  1,
274 | 			userIDs: []int{101}, // Want to remove 100
275 | 			mockMemberships: []*apimodels.PortainerTeamMembership{
276 | 				{
277 | 					ID:     1,
278 | 					UserID: 100,
279 | 					TeamID: 1,
280 | 				},
281 | 			},
282 | 			mockDeleteError: errors.New("failed to delete membership"),
283 | 			expectedError:   true,
284 | 		},
285 | 		{
286 | 			name:            "create membership error",
287 | 			teamID:          1,
288 | 			userIDs:         []int{100}, // Want to add 100
289 | 			mockMemberships: []*apimodels.PortainerTeamMembership{},
290 | 			mockCreateError: errors.New("failed to create membership"),
291 | 			expectedError:   true,
292 | 		},
293 | 	}
294 | 
295 | 	for _, tt := range tests {
296 | 		t.Run(tt.name, func(t *testing.T) {
297 | 			mockAPI := new(MockPortainerAPI)
298 | 			mockAPI.On("ListTeamMemberships").Return(tt.mockMemberships, tt.mockListError)
299 | 
300 | 			// Set up delete expectations for memberships that should be removed
301 | 			for _, membership := range tt.mockMemberships {
302 | 				shouldDelete := true
303 | 				for _, keepID := range tt.userIDs {
304 | 					if int(membership.UserID) == keepID {
305 | 						shouldDelete = false
306 | 						break
307 | 					}
308 | 				}
309 | 				if shouldDelete {
310 | 					mockAPI.On("DeleteTeamMembership", int(membership.ID)).Return(tt.mockDeleteError)
311 | 				}
312 | 			}
313 | 
314 | 			// Set up create expectations for new members
315 | 			for _, userID := range tt.userIDs {
316 | 				exists := false
317 | 				for _, membership := range tt.mockMemberships {
318 | 					if int(membership.UserID) == userID && int(membership.TeamID) == tt.teamID {
319 | 						exists = true
320 | 						break
321 | 					}
322 | 				}
323 | 				if !exists {
324 | 					mockAPI.On("CreateTeamMembership", tt.teamID, userID).Return(tt.mockCreateError)
325 | 				}
326 | 			}
327 | 
328 | 			client := &PortainerClient{cli: mockAPI}
329 | 
330 | 			err := client.UpdateTeamMembers(tt.teamID, tt.userIDs)
331 | 
332 | 			if tt.expectedError {
333 | 				assert.Error(t, err)
334 | 				return
335 | 			}
336 | 			assert.NoError(t, err)
337 | 			mockAPI.AssertExpectations(t)
338 | 		})
339 | 	}
340 | }
341 | 
```

--------------------------------------------------------------------------------
/tests/integration/environment_test.go:
--------------------------------------------------------------------------------

```go
  1 | package integration
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"fmt"
  6 | 	"testing"
  7 | 
  8 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/portainer/client-api-go/v2/client/utils"
 10 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
 11 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 12 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 13 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
 14 | 	"github.com/stretchr/testify/assert"
 15 | 	"github.com/stretchr/testify/require"
 16 | )
 17 | 
 18 | const (
 19 | 	// Test data constants
 20 | 	testEndpointName = "test-endpoint"
 21 | 	testTag1Name     = "tag1"
 22 | 	testTag2Name     = "tag2"
 23 | )
 24 | 
 25 | // prepareTestEnvironment prepares the test environment for the tests
 26 | // It enables Edge Compute settings and creates an Edge Docker endpoint
 27 | func prepareEnvironmentManagementTestEnvironment(t *testing.T, env *helpers.TestEnv) {
 28 | 	host, port := env.Portainer.GetHostAndPort()
 29 | 	serverAddr := fmt.Sprintf("%s:%s", host, port)
 30 | 	tunnelAddr := fmt.Sprintf("%s:8000", host)
 31 | 
 32 | 	err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
 33 | 	require.NoError(t, err, "Failed to update settings")
 34 | 
 35 | 	_, err = env.RawClient.CreateEdgeDockerEndpoint(testEndpointName)
 36 | 	require.NoError(t, err, "Failed to create Edge Docker endpoint")
 37 | }
 38 | 
 39 | // TestEnvironmentManagement is an integration test suite that verifies the complete
 40 | // lifecycle of environment management in Portainer MCP. It tests the retrieval and
 41 | // configuration of environments, including tag management, user access controls,
 42 | // and team access policies.
 43 | func TestEnvironmentManagement(t *testing.T) {
 44 | 	env := helpers.NewTestEnv(t)
 45 | 	defer env.Cleanup(t)
 46 | 
 47 | 	// Prepare the test environment
 48 | 	prepareEnvironmentManagementTestEnvironment(t, env)
 49 | 
 50 | 	var environment models.Environment
 51 | 
 52 | 	// Subtest: Environment Retrieval
 53 | 	// Verifies that:
 54 | 	// - The environment is correctly retrieved from the system
 55 | 	// - The environment has the expected default properties (type, status)
 56 | 	// - No tags, user accesses, or team accesses are initially assigned
 57 | 	// - Compares MCP handler output with direct client API call result
 58 | 	t.Run("Environment Retrieval", func(t *testing.T) {
 59 | 		handler := env.MCPServer.HandleGetEnvironments()
 60 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
 61 | 		require.NoError(t, err, "Failed to get environments via MCP handler")
 62 | 
 63 | 		assert.Len(t, result.Content, 1, "Expected exactly one environment from MCP handler")
 64 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 65 | 		assert.True(t, ok, "Expected text content in MCP response")
 66 | 
 67 | 		var environments []models.Environment
 68 | 		err = json.Unmarshal([]byte(textContent.Text), &environments)
 69 | 		require.NoError(t, err, "Failed to unmarshal environments from MCP response")
 70 | 		require.Len(t, environments, 1, "Expected exactly one environment after unmarshalling")
 71 | 
 72 | 		// Extract the environment for subsequent tests
 73 | 		environment = environments[0]
 74 | 
 75 | 		// Fetch the same endpoint directly via the client
 76 | 		rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
 77 | 		require.NoError(t, err, "Failed to get endpoint directly via client")
 78 | 
 79 | 		// Convert the raw endpoint to the expected Environment model using the package's converter
 80 | 		expectedEnvironment := models.ConvertEndpointToEnvironment(rawEndpoint)
 81 | 
 82 | 		// Compare the Environment struct from MCP handler with the one converted from the direct client call
 83 | 		assert.Equal(t, expectedEnvironment, environment, "Mismatch between MCP handler environment and converted client environment")
 84 | 	})
 85 | 
 86 | 	// Subtest: Tag Management
 87 | 	// Verifies that:
 88 | 	// - New tags can be created in the system
 89 | 	// - Multiple tags can be assigned to an environment simultaneously
 90 | 	// - The environment correctly reflects the assigned tag IDs
 91 | 	// - The tags are properly persisted in the endpoint configuration
 92 | 	t.Run("Tag Management", func(t *testing.T) {
 93 | 		tagId1, err := env.RawClient.CreateTag(testTag1Name)
 94 | 		require.NoError(t, err, "Failed to create first tag")
 95 | 		tagId2, err := env.RawClient.CreateTag(testTag2Name)
 96 | 		require.NoError(t, err, "Failed to create second tag")
 97 | 
 98 | 		request := mcp.CreateMCPRequest(map[string]any{
 99 | 			"id":     float64(environment.ID),
100 | 			"tagIds": []any{float64(tagId1), float64(tagId2)},
101 | 		})
102 | 
103 | 		handler := env.MCPServer.HandleUpdateEnvironmentTags()
104 | 		_, err = handler(env.Ctx, request)
105 | 		require.NoError(t, err, "Failed to update environment tags via MCP handler")
106 | 
107 | 		// Verify by fetching endpoint directly via client
108 | 		rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
109 | 		require.NoError(t, err, "Failed to get endpoint via client after tag update")
110 | 		assert.ElementsMatch(t, []int64{tagId1, tagId2}, rawEndpoint.TagIds, "Tag IDs mismatch (Client check)") // Use ElementsMatch for unordered comparison
111 | 	})
112 | 
113 | 	// Subtest: User Access Management
114 | 	// Verifies that:
115 | 	// - User access policies can be assigned to an environment
116 | 	// - Multiple users with different access levels can be configured
117 | 	// - Access levels are correctly mapped to appropriate role IDs
118 | 	// - The environment's user access policies are properly updated and persisted
119 | 	t.Run("User Access Management", func(t *testing.T) {
120 | 		request := mcp.CreateMCPRequest(map[string]any{
121 | 			"id": float64(environment.ID),
122 | 			"userAccesses": []any{
123 | 				map[string]any{"id": float64(1), "access": "environment_administrator"},
124 | 				map[string]any{"id": float64(2), "access": "standard_user"},
125 | 			},
126 | 		})
127 | 
128 | 		handler := env.MCPServer.HandleUpdateEnvironmentUserAccesses()
129 | 		_, err := handler(env.Ctx, request)
130 | 		require.NoError(t, err, "Failed to update environment user accesses via MCP handler")
131 | 
132 | 		// Verify by fetching endpoint directly via client
133 | 		rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
134 | 		require.NoError(t, err, "Failed to get endpoint via client after user access update")
135 | 
136 | 		expectedRawUserAccesses := utils.BuildAccessPolicies[apimodels.PortainerUserAccessPolicies](map[int64]string{
137 | 			1: "environment_administrator",
138 | 			2: "standard_user",
139 | 		})
140 | 		assert.Equal(t, expectedRawUserAccesses, rawEndpoint.UserAccessPolicies, "User access policies mismatch (Client check)")
141 | 	})
142 | 
143 | 	// Subtest: Team Access Management
144 | 	// Verifies that:
145 | 	// - Team access policies can be assigned to an environment
146 | 	// - Multiple teams with different access levels can be configured
147 | 	// - Access levels are correctly mapped to appropriate role IDs
148 | 	// - The environment's team access policies are properly updated and persisted
149 | 	t.Run("Team Access Management", func(t *testing.T) {
150 | 		request := mcp.CreateMCPRequest(map[string]any{
151 | 			"id": float64(environment.ID),
152 | 			"teamAccesses": []any{
153 | 				map[string]any{"id": float64(1), "access": "environment_administrator"},
154 | 				map[string]any{"id": float64(2), "access": "standard_user"},
155 | 			},
156 | 		})
157 | 
158 | 		handler := env.MCPServer.HandleUpdateEnvironmentTeamAccesses()
159 | 		_, err := handler(env.Ctx, request)
160 | 		require.NoError(t, err, "Failed to update environment team accesses via MCP handler")
161 | 
162 | 		// Verify by fetching endpoint directly via client
163 | 		rawEndpoint, err := env.RawClient.GetEndpoint(int64(environment.ID))
164 | 		require.NoError(t, err, "Failed to get endpoint via client after team access update")
165 | 
166 | 		expectedRawTeamAccesses := utils.BuildAccessPolicies[apimodels.PortainerTeamAccessPolicies](map[int64]string{
167 | 			1: "environment_administrator",
168 | 			2: "standard_user",
169 | 		})
170 | 		assert.Equal(t, expectedRawTeamAccesses, rawEndpoint.TeamAccessPolicies, "Team access policies mismatch (Client check)")
171 | 	})
172 | }
173 | 
```

--------------------------------------------------------------------------------
/tests/integration/docker_test.go:
--------------------------------------------------------------------------------

```go
  1 | package integration
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"fmt"
  6 | 	"testing"
  7 | 
  8 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
  9 | 
 10 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 11 | 	"github.com/portainer/portainer-mcp/tests/integration/containers"
 12 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
 13 | 	"github.com/stretchr/testify/assert"
 14 | 	"github.com/stretchr/testify/require"
 15 | )
 16 | 
 17 | const (
 18 | 	// Test data constants
 19 | 	testLocalEndpointName = "test-local-endpoint"
 20 | 	testLocalEndpointID   = 1
 21 | 	testVolumeName        = "test-proxy-volume"
 22 | )
 23 | 
 24 | // prepareDockerProxyTestEnvironment prepares the test environment for the tests
 25 | // It creates a local Docker endpoint
 26 | func prepareDockerProxyTestEnvironment(t *testing.T, env *helpers.TestEnv) {
 27 | 	_, err := env.RawClient.CreateLocalDockerEndpoint(testLocalEndpointName)
 28 | 	require.NoError(t, err, "Failed to create Local Docker endpoint")
 29 | }
 30 | 
 31 | // TestDockerProxy is an integration test suite that verifies the Docker proxy functionality
 32 | // provided by the Portainer MCP server. It tests the ability to proxy various Docker API requests
 33 | // to a specified environment, including:
 34 | // - Retrieving Docker version information (GET /version)
 35 | // - Creating a volume (POST /volumes/create)
 36 | // - Listing volumes with filters (GET /volumes?filters=...)
 37 | // - Removing a volume (DELETE /volumes/{name})
 38 | // It primarily tests against volumes, as testing container operations would require
 39 | // pulling images beforehand, potentially leading to rate limiting issues in CI/CD
 40 | // or rapid testing scenarios.
 41 | func TestDockerProxy(t *testing.T) {
 42 | 	env := helpers.NewTestEnv(t, containers.WithDockerSocketBind(true))
 43 | 	defer env.Cleanup(t)
 44 | 
 45 | 	// Prepare the test environment
 46 | 	prepareDockerProxyTestEnvironment(t, env)
 47 | 
 48 | 	// Subtest: GET /version
 49 | 	// Verifies that:
 50 | 	// - A simple GET request to the Docker /version endpoint can be successfully proxied.
 51 | 	// - The handler returns a non-empty response without errors.
 52 | 	// - The response content contains expected fields like ApiVersion and Version.
 53 | 	t.Run("GET /version", func(t *testing.T) {
 54 | 		request := mcp.CreateMCPRequest(map[string]any{
 55 | 			"environmentId": float64(testLocalEndpointID),
 56 | 			"method":        "GET",
 57 | 			"dockerAPIPath": "/version",
 58 | 			"queryParams":   nil, // No query params for /version
 59 | 			"headers":       nil, // No specific headers needed
 60 | 			"body":          "",  // No body for GET request
 61 | 		})
 62 | 
 63 | 		handler := env.MCPServer.HandleDockerProxy()
 64 | 		result, err := handler(env.Ctx, request)
 65 | 
 66 | 		require.NoError(t, err, "Handler execution failed")
 67 | 		require.NotNil(t, result, "Handler returned nil result")
 68 | 		require.Len(t, result.Content, 1, "Expected exactly one content item in result")
 69 | 
 70 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 71 | 		require.True(t, ok, "Expected text content in result")
 72 | 		require.NotEmpty(t, textContent.Text, "Result text content should not be empty")
 73 | 
 74 | 		// Unmarshal and check specific fields
 75 | 		var versionInfo map[string]any // Using map[string]any for flexibility
 76 | 		err = json.Unmarshal([]byte(textContent.Text), &versionInfo)
 77 | 		require.NoError(t, err, "Failed to unmarshal version JSON")
 78 | 		assert.Contains(t, versionInfo, "ApiVersion", "Version info should contain ApiVersion")
 79 | 		assert.NotEmpty(t, versionInfo["ApiVersion"], "ApiVersion should not be empty")
 80 | 		assert.Contains(t, versionInfo, "Version", "Version info should contain Version")
 81 | 		assert.NotEmpty(t, versionInfo["Version"], "Version should not be empty")
 82 | 	})
 83 | 
 84 | 	// Subtest: Create Volume
 85 | 	// Verifies that:
 86 | 	// - A POST request to /volumes/create proxies correctly.
 87 | 	// - A volume with the specified name is created.
 88 | 	// - The handler response reflects the created volume details.
 89 | 	t.Run("Create Volume", func(t *testing.T) {
 90 | 		createBody := fmt.Sprintf(`{"Name": "%s"}`, testVolumeName)
 91 | 		request := mcp.CreateMCPRequest(map[string]any{
 92 | 			"environmentId": float64(testLocalEndpointID),
 93 | 			"method":        "POST",
 94 | 			"dockerAPIPath": "/volumes/create",
 95 | 			"headers": []any{
 96 | 				map[string]any{"key": "Content-Type", "value": "application/json"},
 97 | 			},
 98 | 			"body": createBody,
 99 | 		})
100 | 
101 | 		handler := env.MCPServer.HandleDockerProxy()
102 | 		result, err := handler(env.Ctx, request)
103 | 
104 | 		require.NoError(t, err, "Create Volume handler execution failed")
105 | 		require.NotNil(t, result, "Create Volume handler returned nil result")
106 | 		require.Len(t, result.Content, 1, "Expected one content item for Create Volume")
107 | 
108 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
109 | 		require.True(t, ok, "Expected text content for Create Volume")
110 | 		require.NotEmpty(t, textContent.Text, "Create Volume response text should not be empty")
111 | 
112 | 		var volumeInfo map[string]any
113 | 		err = json.Unmarshal([]byte(textContent.Text), &volumeInfo)
114 | 		require.NoError(t, err, "Failed to unmarshal Create Volume response")
115 | 		assert.Equal(t, testVolumeName, volumeInfo["Name"], "Volume name in response mismatch")
116 | 	})
117 | 
118 | 	// Subtest: List Volumes with Filter
119 | 	// Verifies that:
120 | 	// - A GET request to /volumes with a name filter proxies correctly.
121 | 	// - The response contains only the volume created earlier.
122 | 	t.Run("List Volumes with Filter", func(t *testing.T) {
123 | 		filterJSON := fmt.Sprintf(`{"name":["%s"]}`, testVolumeName)
124 | 		request := mcp.CreateMCPRequest(map[string]any{
125 | 			"environmentId": float64(testLocalEndpointID),
126 | 			"method":        "GET",
127 | 			"dockerAPIPath": "/volumes",
128 | 			"queryParams": []any{
129 | 				map[string]any{"key": "filters", "value": filterJSON},
130 | 			},
131 | 		})
132 | 
133 | 		handler := env.MCPServer.HandleDockerProxy()
134 | 		result, err := handler(env.Ctx, request)
135 | 
136 | 		require.NoError(t, err, "List Volumes handler execution failed")
137 | 		require.NotNil(t, result, "List Volumes handler returned nil result")
138 | 		require.Len(t, result.Content, 1, "Expected one content item for List Volumes")
139 | 
140 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
141 | 		require.True(t, ok, "Expected text content for List Volumes")
142 | 		require.NotEmpty(t, textContent.Text, "List Volumes response text should not be empty")
143 | 
144 | 		var listResponse map[string][]map[string]any
145 | 		err = json.Unmarshal([]byte(textContent.Text), &listResponse)
146 | 		require.NoError(t, err, "Failed to unmarshal List Volumes response")
147 | 		require.Contains(t, listResponse, "Volumes", "List response missing 'Volumes' key")
148 | 		require.Len(t, listResponse["Volumes"], 1, "Expected exactly one volume in the filtered list")
149 | 		assert.Equal(t, testVolumeName, listResponse["Volumes"][0]["Name"], "Filtered volume name mismatch")
150 | 	})
151 | 
152 | 	// Subtest: Remove Volume
153 | 	// Verifies that:
154 | 	// - A DELETE request to /volumes/{name} proxies correctly.
155 | 	// - The volume created earlier is successfully removed.
156 | 	// - The handler response is empty (reflecting Docker's 204 No Content).
157 | 	t.Run("Remove Volume", func(t *testing.T) {
158 | 		request := mcp.CreateMCPRequest(map[string]any{
159 | 			"environmentId": float64(testLocalEndpointID),
160 | 			"method":        "DELETE",
161 | 			"dockerAPIPath": "/volumes/" + testVolumeName,
162 | 		})
163 | 
164 | 		handler := env.MCPServer.HandleDockerProxy()
165 | 		result, err := handler(env.Ctx, request)
166 | 
167 | 		require.NoError(t, err, "Remove Volume handler execution failed")
168 | 		require.NotNil(t, result, "Remove Volume handler returned nil result")
169 | 		require.Len(t, result.Content, 1, "Expected one content item for Remove Volume")
170 | 
171 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
172 | 		require.True(t, ok, "Expected text content for Remove Volume")
173 | 		assert.Empty(t, textContent.Text, "Remove Volume response text should be empty for 204 No Content")
174 | 	})
175 | }
176 | 
```

--------------------------------------------------------------------------------
/internal/mcp/docker_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 	"io"
  8 | 	"net/http"
  9 | 	"strings"
 10 | 	"testing"
 11 | 
 12 | 	"github.com/mark3labs/mcp-go/mcp"
 13 | 	"github.com/stretchr/testify/assert"
 14 | 	"github.com/stretchr/testify/mock"
 15 | )
 16 | 
 17 | func createMockHttpResponse(statusCode int, body string) *http.Response {
 18 | 	return &http.Response{
 19 | 		StatusCode: statusCode,
 20 | 		Body:       io.NopCloser(strings.NewReader(body)),
 21 | 	}
 22 | }
 23 | 
 24 | // errorReader simulates an error during io.ReadAll
 25 | type errorReader struct{}
 26 | 
 27 | func (r *errorReader) Read(p []byte) (n int, err error) {
 28 | 	return 0, fmt.Errorf("simulated read error")
 29 | }
 30 | 
 31 | func (r *errorReader) Close() error {
 32 | 	return nil
 33 | }
 34 | 
 35 | func TestHandleDockerProxy_ParameterValidation(t *testing.T) {
 36 | 	tests := []struct {
 37 | 		name             string
 38 | 		inputParams      map[string]any
 39 | 		expectedErrorMsg string
 40 | 	}{
 41 | 		{
 42 | 			name: "invalid body type (not a string)",
 43 | 			inputParams: map[string]any{
 44 | 				"environmentId": float64(2),
 45 | 				"dockerAPIPath": "/containers/create",
 46 | 				"method":        "POST",
 47 | 				"body":          123.45, // Invalid type for body
 48 | 			},
 49 | 			expectedErrorMsg: "body must be a string",
 50 | 		},
 51 | 		{
 52 | 			name: "missing environmentId",
 53 | 			inputParams: map[string]any{
 54 | 				"dockerAPIPath": "/containers/json",
 55 | 				"method":        "GET",
 56 | 			},
 57 | 			expectedErrorMsg: "environmentId is required",
 58 | 		},
 59 | 		{
 60 | 			name: "missing dockerAPIPath",
 61 | 			inputParams: map[string]any{
 62 | 				"environmentId": float64(1),
 63 | 				"method":        "GET",
 64 | 			},
 65 | 			expectedErrorMsg: "dockerAPIPath is required",
 66 | 		},
 67 | 		{
 68 | 			name: "missing method",
 69 | 			inputParams: map[string]any{
 70 | 				"environmentId": float64(1),
 71 | 				"dockerAPIPath": "/containers/json",
 72 | 			},
 73 | 			expectedErrorMsg: "method is required",
 74 | 		},
 75 | 		{
 76 | 			name: "invalid dockerAPIPath (no leading slash)",
 77 | 			inputParams: map[string]any{
 78 | 				"environmentId": float64(1),
 79 | 				"dockerAPIPath": "containers/json",
 80 | 				"method":        "GET",
 81 | 			},
 82 | 			expectedErrorMsg: "dockerAPIPath must start with a leading slash",
 83 | 		},
 84 | 		{
 85 | 			name: "invalid HTTP method",
 86 | 			inputParams: map[string]any{
 87 | 				"environmentId": float64(1),
 88 | 				"dockerAPIPath": "/containers/json",
 89 | 				"method":        "INVALID",
 90 | 			},
 91 | 			expectedErrorMsg: "invalid method: INVALID",
 92 | 		},
 93 | 		{
 94 | 			name: "invalid queryParams type (not an array)",
 95 | 			inputParams: map[string]any{
 96 | 				"environmentId": float64(1),
 97 | 				"dockerAPIPath": "/containers/json",
 98 | 				"method":        "GET",
 99 | 				"queryParams":   "not-an-array", // Invalid type
100 | 			},
101 | 			expectedErrorMsg: "queryParams must be an array",
102 | 		},
103 | 		{
104 | 			name: "invalid queryParams content (missing key)",
105 | 			inputParams: map[string]any{
106 | 				"environmentId": float64(1),
107 | 				"dockerAPIPath": "/containers/json",
108 | 				"method":        "GET",
109 | 				"queryParams":   []any{map[string]any{"value": "true"}}, // Missing 'key'
110 | 			},
111 | 			expectedErrorMsg: "invalid query params: invalid key: <nil>",
112 | 		},
113 | 		{
114 | 			name: "invalid headers type (not an array)",
115 | 			inputParams: map[string]any{
116 | 				"environmentId": float64(1),
117 | 				"dockerAPIPath": "/containers/json",
118 | 				"method":        "GET",
119 | 				"headers":       map[string]any{"key": "value"}, // Invalid type
120 | 			},
121 | 			expectedErrorMsg: "headers must be an array",
122 | 		},
123 | 		{
124 | 			name: "invalid headers content (value not string)",
125 | 			inputParams: map[string]any{
126 | 				"environmentId": float64(1),
127 | 				"dockerAPIPath": "/containers/json",
128 | 				"method":        "GET",
129 | 				"headers":       []any{map[string]any{"key": "X-Custom", "value": 123}}, // Value not string
130 | 			},
131 | 			expectedErrorMsg: "invalid headers: invalid value: 123",
132 | 		},
133 | 	}
134 | 
135 | 	for _, tt := range tests {
136 | 		t.Run(tt.name, func(t *testing.T) {
137 | 			server := &PortainerMCPServer{}
138 | 
139 | 			request := CreateMCPRequest(tt.inputParams)
140 | 			handler := server.HandleDockerProxy()
141 | 			result, err := handler(context.Background(), request)
142 | 
143 | 			// All parameter/validation errors now return (result{IsError: true}, nil)
144 | 			assert.NoError(t, err)   // Handler now returns nil error
145 | 			assert.NotNil(t, result) // Handler returns a result object
146 | 			assert.True(t, result.IsError, "result.IsError should be true for parameter validation errors")
147 | 			assert.Len(t, result.Content, 1)                       // Expect one content item for the error message
148 | 			textContent, ok := result.Content[0].(mcp.TextContent) // Content should be TextContent
149 | 			assert.True(t, ok, "Result content should be mcp.TextContent for errors")
150 | 			assert.Contains(t, textContent.Text, tt.expectedErrorMsg, "Error message mismatch")
151 | 		})
152 | 	}
153 | }
154 | 
155 | func TestHandleDockerProxy_ClientInteraction(t *testing.T) {
156 | 	type testCase struct {
157 | 		name  string
158 | 		input map[string]any // Parameters for the MCP request
159 | 		mock  struct {       // Details for mocking the client call
160 | 			response *http.Response
161 | 			err      error
162 | 		}
163 | 		expect struct { // Expected outcome
164 | 			errSubstring string // Check for error containing this text (if error expected)
165 | 			resultText   string // Expected text result (if success expected)
166 | 		}
167 | 	}
168 | 
169 | 	tests := []testCase{
170 | 		{
171 | 			name: "successful GET request", // Query params are parsed by toolgen, but not yet passed by handler
172 | 			input: map[string]any{
173 | 				"environmentId": float64(1),
174 | 				"dockerAPIPath": "/containers/json",
175 | 				"method":        "GET",
176 | 				"queryParams": []any{ //
177 | 					map[string]any{"key": "all", "value": "true"},
178 | 					map[string]any{"key": "filter", "value": "dangling"},
179 | 				},
180 | 			},
181 | 			mock: struct {
182 | 				response *http.Response
183 | 				err      error
184 | 			}{
185 | 				response: createMockHttpResponse(http.StatusOK, `[{"Id":"123"}]`),
186 | 				err:      nil,
187 | 			},
188 | 			expect: struct {
189 | 				errSubstring string
190 | 				resultText   string
191 | 			}{
192 | 				resultText: `[{"Id":"123"}]`,
193 | 			},
194 | 		},
195 | 		{
196 | 			name: "successful POST request with body",
197 | 			input: map[string]any{
198 | 				"environmentId": float64(2),
199 | 				"dockerAPIPath": "/containers/create",
200 | 				"method":        "POST",
201 | 				"body":          `{"name":"test"}`,
202 | 				"headers": []any{
203 | 					map[string]any{"key": "X-Custom", "value": "test-value"},
204 | 					map[string]any{"key": "Authorization", "value": "Bearer abc"},
205 | 				},
206 | 			},
207 | 			mock: struct {
208 | 				response *http.Response
209 | 				err      error
210 | 			}{
211 | 				response: createMockHttpResponse(http.StatusCreated, `{"Id":"456"}`),
212 | 				err:      nil,
213 | 			},
214 | 			expect: struct {
215 | 				errSubstring string
216 | 				resultText   string
217 | 			}{
218 | 				resultText: `{"Id":"456"}`,
219 | 			},
220 | 		},
221 | 		{
222 | 			name: "client API error",
223 | 			input: map[string]any{
224 | 				"environmentId": float64(3),
225 | 				"dockerAPIPath": "/version",
226 | 				"method":        "GET",
227 | 			},
228 | 			mock: struct {
229 | 				response *http.Response
230 | 				err      error
231 | 			}{
232 | 				response: nil,
233 | 				err:      errors.New("portainer api error"),
234 | 			},
235 | 			expect: struct {
236 | 				errSubstring string
237 | 				resultText   string
238 | 			}{
239 | 				errSubstring: "failed to send Docker API request: portainer api error",
240 | 			},
241 | 		},
242 | 		{
243 | 			name: "error reading response body",
244 | 			input: map[string]any{
245 | 				"environmentId": float64(4),
246 | 				"dockerAPIPath": "/info",
247 | 				"method":        "GET",
248 | 			},
249 | 			mock: struct {
250 | 				response *http.Response
251 | 				err      error
252 | 			}{
253 | 				response: &http.Response{
254 | 					StatusCode: http.StatusOK,
255 | 					Body:       &errorReader{}, // Simulate read error
256 | 				},
257 | 				err: nil, // No client error, but response body read fails
258 | 			},
259 | 			expect: struct {
260 | 				errSubstring string
261 | 				resultText   string
262 | 			}{
263 | 				errSubstring: "failed to read Docker API response: simulated read error",
264 | 			},
265 | 		},
266 | 	}
267 | 
268 | 	for _, tc := range tests {
269 | 		t.Run(tc.name, func(t *testing.T) {
270 | 			mockClient := new(MockPortainerClient)
271 | 
272 | 			mockClient.On("ProxyDockerRequest", mock.AnythingOfType("models.DockerProxyRequestOptions")).
273 | 				Return(tc.mock.response, tc.mock.err)
274 | 
275 | 			server := &PortainerMCPServer{
276 | 				cli: mockClient,
277 | 			}
278 | 
279 | 			request := CreateMCPRequest(tc.input)
280 | 			handler := server.HandleDockerProxy()
281 | 			result, err := handler(context.Background(), request)
282 | 
283 | 			if tc.expect.errSubstring != "" {
284 | 				assert.NoError(t, err)
285 | 				assert.NotNil(t, result)
286 | 				assert.True(t, result.IsError, "result.IsError should be true for errors")
287 | 				assert.Len(t, result.Content, 1)
288 | 				textContent, ok := result.Content[0].(mcp.TextContent)
289 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
290 | 				assert.Contains(t, textContent.Text, tc.expect.errSubstring)
291 | 			} else {
292 | 				assert.NoError(t, err)
293 | 				assert.NotNil(t, result)
294 | 				assert.Len(t, result.Content, 1)
295 | 				textContent, ok := result.Content[0].(mcp.TextContent)
296 | 				assert.True(t, ok)
297 | 				assert.Equal(t, tc.expect.resultText, textContent.Text)
298 | 			}
299 | 
300 | 			mockClient.AssertExpectations(t)
301 | 		})
302 | 	}
303 | }
304 | 
```

--------------------------------------------------------------------------------
/tests/integration/group_test.go:
--------------------------------------------------------------------------------

```go
  1 | package integration
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"fmt"
  6 | 	"testing"
  7 | 
  8 | 	mcpmodels "github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/portainer/portainer-mcp/internal/mcp"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 11 | 	"github.com/portainer/portainer-mcp/tests/integration/helpers"
 12 | 
 13 | 	"github.com/stretchr/testify/assert"
 14 | 	"github.com/stretchr/testify/require"
 15 | )
 16 | 
 17 | const (
 18 | 	testGroupName        = "test-mcp-group"
 19 | 	testGroupUpdatedName = "test-mcp-group-updated"
 20 | 	testGroupTagName1    = "test-group-tag1"
 21 | 	testGroupTagName2    = "test-group-tag2"
 22 | 	testEnvName          = "test-group-env"
 23 | )
 24 | 
 25 | // prepareEnvironmentGroupTestEnvironment prepares the test environment for environment group tests
 26 | func prepareEnvironmentGroupTestEnvironment(t *testing.T, env *helpers.TestEnv) (int, int) {
 27 | 	// Enable Edge features in Portainer
 28 | 	host, port := env.Portainer.GetHostAndPort()
 29 | 	serverAddr := fmt.Sprintf("%s:%s", host, port)
 30 | 	tunnelAddr := fmt.Sprintf("%s:8000", host)
 31 | 
 32 | 	err := env.RawClient.UpdateSettings(true, serverAddr, tunnelAddr)
 33 | 	require.NoError(t, err, "Failed to update settings to enable Edge features")
 34 | 
 35 | 	// Create a test environment for association with groups
 36 | 	envID, err := env.RawClient.CreateEdgeDockerEndpoint(testEnvName)
 37 | 	require.NoError(t, err, "Failed to create test environment")
 38 | 
 39 | 	// Create test tag
 40 | 	tagID, err := env.RawClient.CreateTag(testGroupTagName1)
 41 | 	require.NoError(t, err, "Failed to create test tag")
 42 | 
 43 | 	return int(envID), int(tagID)
 44 | }
 45 | 
 46 | // TestEnvironmentGroupManagement is an integration test suite that verifies the complete
 47 | // lifecycle of environment group management in Portainer MCP. It tests group creation,
 48 | // listing, name updates, environment association and tag association.
 49 | func TestEnvironmentGroupManagement(t *testing.T) {
 50 | 	env := helpers.NewTestEnv(t)
 51 | 	defer env.Cleanup(t)
 52 | 
 53 | 	// Prepare the test environment
 54 | 	testEnvID, testTagID := prepareEnvironmentGroupTestEnvironment(t, env)
 55 | 
 56 | 	var testGroupID int
 57 | 
 58 | 	// Subtest: Environment Group Creation
 59 | 	// Verifies that:
 60 | 	// - A new environment group can be created via the MCP handler
 61 | 	// - The handler response indicates success with an ID
 62 | 	// - The created group exists in Portainer when checked directly via Raw Client
 63 | 	t.Run("Environment Group Creation", func(t *testing.T) {
 64 | 		handler := env.MCPServer.HandleCreateEnvironmentGroup()
 65 | 		request := mcp.CreateMCPRequest(map[string]any{
 66 | 			"name":           testGroupName,
 67 | 			"environmentIds": []any{float64(testEnvID)},
 68 | 		})
 69 | 
 70 | 		result, err := handler(env.Ctx, request)
 71 | 		require.NoError(t, err, "Failed to create environment group via MCP handler")
 72 | 
 73 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
 74 | 		require.True(t, ok, "Expected text content in MCP response")
 75 | 
 76 | 		// Check for success message
 77 | 		assert.Contains(t, textContent.Text, "Environment group created successfully with ID:", "Success message prefix mismatch")
 78 | 
 79 | 		// Verify by fetching group directly via client and finding the created group by name
 80 | 		group, err := env.RawClient.GetEdgeGroupByName(testGroupName)
 81 | 		require.NoError(t, err, "Failed to get environment group directly via client")
 82 | 		assert.Equal(t, testGroupName, group.Name, "Group name mismatch")
 83 | 
 84 | 		// Extract group ID for subsequent tests
 85 | 		testGroupID = int(group.ID)
 86 | 	})
 87 | 
 88 | 	// Subtest: Environment Group Listing
 89 | 	// Verifies that:
 90 | 	// - The group list can be retrieved via the MCP handler
 91 | 	// - The list contains the expected group
 92 | 	// - The group data matches the expected properties
 93 | 	t.Run("Environment Group Listing", func(t *testing.T) {
 94 | 		handler := env.MCPServer.HandleGetEnvironmentGroups()
 95 | 		result, err := handler(env.Ctx, mcp.CreateMCPRequest(nil))
 96 | 		require.NoError(t, err, "Failed to get environment groups via MCP handler")
 97 | 
 98 | 		assert.Len(t, result.Content, 1, "Expected exactly one content block in the result")
 99 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
100 | 		assert.True(t, ok, "Expected text content in MCP response")
101 | 
102 | 		var retrievedGroups []models.Group
103 | 		err = json.Unmarshal([]byte(textContent.Text), &retrievedGroups)
104 | 		require.NoError(t, err, "Failed to unmarshal retrieved groups")
105 | 		require.Len(t, retrievedGroups, 1, "Expected exactly one group after unmarshalling")
106 | 
107 | 		group := retrievedGroups[0]
108 | 		assert.Equal(t, testGroupName, group.Name, "Group name mismatch")
109 | 
110 | 		// Fetch the same group directly via the client
111 | 		rawGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
112 | 		require.NoError(t, err, "Failed to get environment group directly via client")
113 | 
114 | 		// Convert the raw group to the expected Group model
115 | 		expectedGroup := models.ConvertEdgeGroupToGroup(rawGroup)
116 | 		assert.Equal(t, expectedGroup, group, "Group mismatch between MCP handler and direct client call")
117 | 	})
118 | 
119 | 	// Subtest: Environment Group Name Update
120 | 	// Verifies that:
121 | 	// - The group name can be updated via the MCP handler
122 | 	// - The handler response indicates success
123 | 	// - The name is correctly updated when checked directly via Raw Client
124 | 	t.Run("Environment Group Name Update", func(t *testing.T) {
125 | 		handler := env.MCPServer.HandleUpdateEnvironmentGroupName()
126 | 		request := mcp.CreateMCPRequest(map[string]any{
127 | 			"id":   float64(testGroupID),
128 | 			"name": testGroupUpdatedName,
129 | 		})
130 | 
131 | 		result, err := handler(env.Ctx, request)
132 | 		require.NoError(t, err, "Failed to update environment group name via MCP handler")
133 | 
134 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
135 | 		require.True(t, ok, "Expected text content in MCP response")
136 | 		assert.Contains(t, textContent.Text, "Environment group name updated successfully", "Success message mismatch")
137 | 
138 | 		// Verify by fetching group directly via raw client
139 | 		updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
140 | 		require.NoError(t, err, "Failed to get environment group directly via client")
141 | 		assert.Equal(t, testGroupUpdatedName, updatedGroup.Name, "Group name was not updated")
142 | 	})
143 | 
144 | 	// Subtest: Environment Group Tag Update
145 | 	// Verifies that:
146 | 	// - Tags can be associated with a group via the MCP handler
147 | 	// - The handler response indicates success
148 | 	// - The tags are correctly associated when checked directly via Raw Client
149 | 	t.Run("Environment Group Tag Update", func(t *testing.T) {
150 | 		// Create a second tag
151 | 		tagID2, err := env.RawClient.CreateTag(testGroupTagName2)
152 | 		require.NoError(t, err, "Failed to create second test tag")
153 | 
154 | 		handler := env.MCPServer.HandleUpdateEnvironmentGroupTags()
155 | 		request := mcp.CreateMCPRequest(map[string]any{
156 | 			"id":     float64(testGroupID),
157 | 			"tagIds": []any{float64(testTagID), float64(tagID2)},
158 | 		})
159 | 
160 | 		result, err := handler(env.Ctx, request)
161 | 		require.NoError(t, err, "Failed to update environment group tags via MCP handler")
162 | 
163 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
164 | 		require.True(t, ok, "Expected text content in MCP response")
165 | 		assert.Contains(t, textContent.Text, "Environment group tags updated successfully", "Success message mismatch")
166 | 
167 | 		// Verify by fetching group directly via raw client
168 | 		updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
169 | 		require.NoError(t, err, "Failed to get environment group directly via client")
170 | 		assert.ElementsMatch(t, []int64{int64(testTagID), int64(tagID2)}, updatedGroup.TagIds, "Tag IDs mismatch")
171 | 	})
172 | 
173 | 	// Subtest: Environment Group Environments Update
174 | 	// Verifies that:
175 | 	// - Environment associations can be updated via the MCP handler
176 | 	// - The handler response indicates success
177 | 	// - The environment associations are correctly updated when checked directly via Raw Client
178 | 	t.Run("Environment Group Environments Update", func(t *testing.T) {
179 | 		// Create a second environment
180 | 		env2Name := "test-env-2"
181 | 		env2ID, err := env.RawClient.CreateEdgeDockerEndpoint(env2Name)
182 | 		require.NoError(t, err, "Failed to create second test environment")
183 | 
184 | 		handler := env.MCPServer.HandleUpdateEnvironmentGroupEnvironments()
185 | 		request := mcp.CreateMCPRequest(map[string]any{
186 | 			"id":             float64(testGroupID),
187 | 			"environmentIds": []any{float64(testEnvID), float64(env2ID)},
188 | 		})
189 | 
190 | 		result, err := handler(env.Ctx, request)
191 | 		require.NoError(t, err, "Failed to update environment group environments via MCP handler")
192 | 
193 | 		textContent, ok := result.Content[0].(mcpmodels.TextContent)
194 | 		require.True(t, ok, "Expected text content in MCP response")
195 | 		assert.Contains(t, textContent.Text, "Environment group environments updated successfully", "Success message mismatch")
196 | 
197 | 		// Verify by fetching group directly via raw client
198 | 		updatedGroup, err := env.RawClient.GetEdgeGroup(int64(testGroupID))
199 | 		require.NoError(t, err, "Failed to get environment group directly via client")
200 | 		assert.ElementsMatch(t, []int64{int64(testEnvID), int64(env2ID)}, updatedGroup.Endpoints, "Environment IDs mismatch")
201 | 	})
202 | }
203 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/mocks_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"net/http"
  5 | 
  6 | 	"github.com/portainer/client-api-go/v2/client"
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/stretchr/testify/mock"
  9 | )
 10 | 
 11 | // Mock Implementation Patterns:
 12 | //
 13 | // This file contains mock implementations of the PortainerAPIClient interface.
 14 | // The following patterns are used throughout the mocks:
 15 | //
 16 | // 1. Methods returning (T, error):
 17 | //    - Uses m.Called() to record the method call and get mock behavior
 18 | //    - Includes nil check on first return value to avoid type assertion panics
 19 | //    - Example:
 20 | //      func (m *Mock) Method() (T, error) {
 21 | //          args := m.Called()
 22 | //          if args.Get(0) == nil {
 23 | //              return nil, args.Error(1)
 24 | //          }
 25 | //          return args.Get(0).(T), args.Error(1)
 26 | //      }
 27 | //
 28 | // 2. Methods returning only error:
 29 | //    - Uses m.Called() with any parameters
 30 | //    - Returns only the error value
 31 | //    - Example:
 32 | //      func (m *Mock) Method(param string) error {
 33 | //          args := m.Called(param)
 34 | //          return args.Error(0)
 35 | //      }
 36 | //
 37 | // 3. Methods with primitive return types:
 38 | //    - Uses type-specific getters (e.g., Int64, String)
 39 | //    - Example:
 40 | //      func (m *Mock) Method() (int64, error) {
 41 | //          args := m.Called()
 42 | //          return args.Get(0).(int64), args.Error(1)
 43 | //      }
 44 | //
 45 | // Usage in Tests:
 46 | //   mock := new(MockPortainerAPI)
 47 | //   mock.On("MethodName").Return(expectedValue, nil)
 48 | //   result, err := mock.MethodName()
 49 | //   mock.AssertExpectations(t)
 50 | 
 51 | // MockPortainerAPI is a mock of the PortainerAPIClient interface
 52 | type MockPortainerAPI struct {
 53 | 	mock.Mock
 54 | }
 55 | 
 56 | // ListEdgeGroups mocks the ListEdgeGroups method
 57 | func (m *MockPortainerAPI) ListEdgeGroups() ([]*apimodels.EdgegroupsDecoratedEdgeGroup, error) {
 58 | 	args := m.Called()
 59 | 	if args.Get(0) == nil {
 60 | 		return nil, args.Error(1)
 61 | 	}
 62 | 	return args.Get(0).([]*apimodels.EdgegroupsDecoratedEdgeGroup), args.Error(1)
 63 | }
 64 | 
 65 | // CreateEdgeGroup mocks the CreateEdgeGroup method
 66 | func (m *MockPortainerAPI) CreateEdgeGroup(name string, environmentIds []int64) (int64, error) {
 67 | 	args := m.Called(name, environmentIds)
 68 | 	return args.Get(0).(int64), args.Error(1)
 69 | }
 70 | 
 71 | // UpdateEdgeGroup mocks the UpdateEdgeGroup method
 72 | func (m *MockPortainerAPI) UpdateEdgeGroup(id int64, name *string, environmentIds *[]int64, tagIds *[]int64) error {
 73 | 	args := m.Called(id, name, environmentIds, tagIds)
 74 | 	return args.Error(0)
 75 | }
 76 | 
 77 | // ListEdgeStacks mocks the ListEdgeStacks method
 78 | func (m *MockPortainerAPI) ListEdgeStacks() ([]*apimodels.PortainereeEdgeStack, error) {
 79 | 	args := m.Called()
 80 | 	if args.Get(0) == nil {
 81 | 		return nil, args.Error(1)
 82 | 	}
 83 | 	return args.Get(0).([]*apimodels.PortainereeEdgeStack), args.Error(1)
 84 | }
 85 | 
 86 | // CreateEdgeStack mocks the CreateEdgeStack method
 87 | func (m *MockPortainerAPI) CreateEdgeStack(name string, file string, environmentGroupIds []int64) (int64, error) {
 88 | 	args := m.Called(name, file, environmentGroupIds)
 89 | 	return args.Get(0).(int64), args.Error(1)
 90 | }
 91 | 
 92 | // UpdateEdgeStack mocks the UpdateEdgeStack method
 93 | func (m *MockPortainerAPI) UpdateEdgeStack(id int64, file string, environmentGroupIds []int64) error {
 94 | 	args := m.Called(id, file, environmentGroupIds)
 95 | 	return args.Error(0)
 96 | }
 97 | 
 98 | // GetEdgeStackFile mocks the GetEdgeStackFile method
 99 | func (m *MockPortainerAPI) GetEdgeStackFile(id int64) (string, error) {
100 | 	args := m.Called(id)
101 | 	return args.String(0), args.Error(1)
102 | }
103 | 
104 | // ListEndpointGroups mocks the ListEndpointGroups method
105 | func (m *MockPortainerAPI) ListEndpointGroups() ([]*apimodels.PortainerEndpointGroup, error) {
106 | 	args := m.Called()
107 | 	if args.Get(0) == nil {
108 | 		return nil, args.Error(1)
109 | 	}
110 | 	return args.Get(0).([]*apimodels.PortainerEndpointGroup), args.Error(1)
111 | }
112 | 
113 | // CreateEndpointGroup mocks the CreateEndpointGroup method
114 | func (m *MockPortainerAPI) CreateEndpointGroup(name string, associatedEndpoints []int64) (int64, error) {
115 | 	args := m.Called(name, associatedEndpoints)
116 | 	return args.Get(0).(int64), args.Error(1)
117 | }
118 | 
119 | // UpdateEndpointGroup mocks the UpdateEndpointGroup method
120 | func (m *MockPortainerAPI) UpdateEndpointGroup(id int64, name *string, userAccesses *map[int64]string, teamAccesses *map[int64]string) error {
121 | 	args := m.Called(id, name, userAccesses, teamAccesses)
122 | 	return args.Error(0)
123 | }
124 | 
125 | // AddEnvironmentToEndpointGroup mocks the AddEnvironmentToEndpointGroup method
126 | func (m *MockPortainerAPI) AddEnvironmentToEndpointGroup(groupId int64, environmentId int64) error {
127 | 	args := m.Called(groupId, environmentId)
128 | 	return args.Error(0)
129 | }
130 | 
131 | // RemoveEnvironmentFromEndpointGroup mocks the RemoveEnvironmentFromEndpointGroup method
132 | func (m *MockPortainerAPI) RemoveEnvironmentFromEndpointGroup(groupId int64, environmentId int64) error {
133 | 	args := m.Called(groupId, environmentId)
134 | 	return args.Error(0)
135 | }
136 | 
137 | // ListEndpoints mocks the ListEndpoints method
138 | func (m *MockPortainerAPI) ListEndpoints() ([]*apimodels.PortainereeEndpoint, error) {
139 | 	args := m.Called()
140 | 	if args.Get(0) == nil {
141 | 		return nil, args.Error(1)
142 | 	}
143 | 	return args.Get(0).([]*apimodels.PortainereeEndpoint), args.Error(1)
144 | }
145 | 
146 | // GetEndpoint mocks the GetEndpoint method
147 | func (m *MockPortainerAPI) GetEndpoint(id int64) (*apimodels.PortainereeEndpoint, error) {
148 | 	args := m.Called(id)
149 | 	if args.Get(0) == nil {
150 | 		return nil, args.Error(1)
151 | 	}
152 | 	return args.Get(0).(*apimodels.PortainereeEndpoint), args.Error(1)
153 | }
154 | 
155 | // UpdateEndpoint mocks the UpdateEndpoint method
156 | func (m *MockPortainerAPI) UpdateEndpoint(id int64, tagIds *[]int64, userAccesses *map[int64]string, teamAccesses *map[int64]string) error {
157 | 	args := m.Called(id, tagIds, userAccesses, teamAccesses)
158 | 	return args.Error(0)
159 | }
160 | 
161 | // GetSettings mocks the GetSettings method
162 | func (m *MockPortainerAPI) GetSettings() (*apimodels.PortainereeSettings, error) {
163 | 	args := m.Called()
164 | 	if args.Get(0) == nil {
165 | 		return nil, args.Error(1)
166 | 	}
167 | 	return args.Get(0).(*apimodels.PortainereeSettings), args.Error(1)
168 | }
169 | 
170 | // ListTags mocks the ListTags method
171 | func (m *MockPortainerAPI) ListTags() ([]*apimodels.PortainerTag, error) {
172 | 	args := m.Called()
173 | 	if args.Get(0) == nil {
174 | 		return nil, args.Error(1)
175 | 	}
176 | 	return args.Get(0).([]*apimodels.PortainerTag), args.Error(1)
177 | }
178 | 
179 | // CreateTag mocks the CreateTag method
180 | func (m *MockPortainerAPI) CreateTag(name string) (int64, error) {
181 | 	args := m.Called(name)
182 | 	return args.Get(0).(int64), args.Error(1)
183 | }
184 | 
185 | // ListTeams mocks the ListTeams method
186 | func (m *MockPortainerAPI) ListTeams() ([]*apimodels.PortainerTeam, error) {
187 | 	args := m.Called()
188 | 	if args.Get(0) == nil {
189 | 		return nil, args.Error(1)
190 | 	}
191 | 	return args.Get(0).([]*apimodels.PortainerTeam), args.Error(1)
192 | }
193 | 
194 | // ListTeamMemberships mocks the ListTeamMemberships method
195 | func (m *MockPortainerAPI) ListTeamMemberships() ([]*apimodels.PortainerTeamMembership, error) {
196 | 	args := m.Called()
197 | 	if args.Get(0) == nil {
198 | 		return nil, args.Error(1)
199 | 	}
200 | 	return args.Get(0).([]*apimodels.PortainerTeamMembership), args.Error(1)
201 | }
202 | 
203 | // CreateTeam mocks the CreateTeam method
204 | func (m *MockPortainerAPI) CreateTeam(name string) (int64, error) {
205 | 	args := m.Called(name)
206 | 	return args.Get(0).(int64), args.Error(1)
207 | }
208 | 
209 | // UpdateTeamName mocks the UpdateTeamName method
210 | func (m *MockPortainerAPI) UpdateTeamName(id int, name string) error {
211 | 	args := m.Called(id, name)
212 | 	return args.Error(0)
213 | }
214 | 
215 | // DeleteTeamMembership mocks the DeleteTeamMembership method
216 | func (m *MockPortainerAPI) DeleteTeamMembership(id int) error {
217 | 	args := m.Called(id)
218 | 	return args.Error(0)
219 | }
220 | 
221 | // CreateTeamMembership mocks the CreateTeamMembership method
222 | func (m *MockPortainerAPI) CreateTeamMembership(teamId int, userId int) error {
223 | 	args := m.Called(teamId, userId)
224 | 	return args.Error(0)
225 | }
226 | 
227 | // ListUsers mocks the ListUsers method
228 | func (m *MockPortainerAPI) ListUsers() ([]*apimodels.PortainereeUser, error) {
229 | 	args := m.Called()
230 | 	if args.Get(0) == nil {
231 | 		return nil, args.Error(1)
232 | 	}
233 | 	return args.Get(0).([]*apimodels.PortainereeUser), args.Error(1)
234 | }
235 | 
236 | // UpdateUserRole mocks the UpdateUserRole method
237 | func (m *MockPortainerAPI) UpdateUserRole(id int, role int64) error {
238 | 	args := m.Called(id, role)
239 | 	return args.Error(0)
240 | }
241 | 
242 | // GetVersion mocks the GetVersion method
243 | func (m *MockPortainerAPI) GetVersion() (string, error) {
244 | 	args := m.Called()
245 | 	return args.String(0), args.Error(1)
246 | }
247 | 
248 | // ProxyDockerRequest mocks the ProxyDockerRequest method
249 | func (m *MockPortainerAPI) ProxyDockerRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error) {
250 | 	args := m.Called(environmentId, opts)
251 | 	if args.Get(0) == nil {
252 | 		return nil, args.Error(1)
253 | 	}
254 | 	return args.Get(0).(*http.Response), args.Error(1)
255 | }
256 | 
257 | // ProxyKubernetesRequest mocks the ProxyKubernetesRequest method
258 | func (m *MockPortainerAPI) ProxyKubernetesRequest(environmentId int, opts client.ProxyRequestOptions) (*http.Response, error) {
259 | 	args := m.Called(environmentId, opts)
260 | 	if args.Get(0) == nil {
261 | 		return nil, args.Error(1)
262 | 	}
263 | 	return args.Get(0).(*http.Response), args.Error(1)
264 | }
265 | 
```

--------------------------------------------------------------------------------
/internal/mcp/team_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 11 | 	"github.com/stretchr/testify/assert"
 12 | )
 13 | 
 14 | func TestHandleCreateTeam(t *testing.T) {
 15 | 	tests := []struct {
 16 | 		name        string
 17 | 		teamName    string
 18 | 		mockID      int
 19 | 		mockError   error
 20 | 		expectError bool
 21 | 		setupParams func(request *mcp.CallToolRequest)
 22 | 	}{
 23 | 		{
 24 | 			name:        "successful team creation",
 25 | 			teamName:    "test-team",
 26 | 			mockID:      1,
 27 | 			mockError:   nil,
 28 | 			expectError: false,
 29 | 			setupParams: func(request *mcp.CallToolRequest) {
 30 | 				request.Params.Arguments = map[string]any{
 31 | 					"name": "test-team",
 32 | 				}
 33 | 			},
 34 | 		},
 35 | 		{
 36 | 			name:        "api error",
 37 | 			teamName:    "test-team",
 38 | 			mockID:      0,
 39 | 			mockError:   fmt.Errorf("api error"),
 40 | 			expectError: true,
 41 | 			setupParams: func(request *mcp.CallToolRequest) {
 42 | 				request.Params.Arguments = map[string]any{
 43 | 					"name": "test-team",
 44 | 				}
 45 | 			},
 46 | 		},
 47 | 		{
 48 | 			name:        "missing name parameter",
 49 | 			teamName:    "",
 50 | 			mockID:      0,
 51 | 			mockError:   nil,
 52 | 			expectError: true,
 53 | 			setupParams: func(request *mcp.CallToolRequest) {
 54 | 				// No need to set any parameters as the request will be invalid
 55 | 			},
 56 | 		},
 57 | 	}
 58 | 
 59 | 	for _, tt := range tests {
 60 | 		t.Run(tt.name, func(t *testing.T) {
 61 | 			mockClient := &MockPortainerClient{}
 62 | 			if !tt.expectError || tt.mockError != nil {
 63 | 				mockClient.On("CreateTeam", tt.teamName).Return(tt.mockID, tt.mockError)
 64 | 			}
 65 | 
 66 | 			server := &PortainerMCPServer{
 67 | 				cli: mockClient,
 68 | 			}
 69 | 
 70 | 			request := CreateMCPRequest(map[string]any{})
 71 | 			tt.setupParams(&request)
 72 | 
 73 | 			handler := server.HandleCreateTeam()
 74 | 			result, err := handler(context.Background(), request)
 75 | 
 76 | 			if tt.expectError {
 77 | 				assert.NoError(t, err)
 78 | 				assert.NotNil(t, result)
 79 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
 80 | 				assert.Len(t, result.Content, 1)
 81 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 82 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
 83 | 				if tt.mockError != nil {
 84 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 85 | 				} else {
 86 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
 87 | 				}
 88 | 			} else {
 89 | 				assert.NoError(t, err)
 90 | 				assert.Len(t, result.Content, 1)
 91 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 92 | 				assert.True(t, ok)
 93 | 				assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
 94 | 			}
 95 | 
 96 | 			mockClient.AssertExpectations(t)
 97 | 		})
 98 | 	}
 99 | }
100 | 
101 | func TestHandleGetTeams(t *testing.T) {
102 | 	tests := []struct {
103 | 		name        string
104 | 		mockTeams   []models.Team
105 | 		mockError   error
106 | 		expectError bool
107 | 	}{
108 | 		{
109 | 			name: "successful teams retrieval",
110 | 			mockTeams: []models.Team{
111 | 				{ID: 1, Name: "team1"},
112 | 				{ID: 2, Name: "team2"},
113 | 			},
114 | 			mockError:   nil,
115 | 			expectError: false,
116 | 		},
117 | 		{
118 | 			name:        "api error",
119 | 			mockTeams:   nil,
120 | 			mockError:   fmt.Errorf("api error"),
121 | 			expectError: true,
122 | 		},
123 | 	}
124 | 
125 | 	for _, tt := range tests {
126 | 		t.Run(tt.name, func(t *testing.T) {
127 | 			mockClient := &MockPortainerClient{}
128 | 			mockClient.On("GetTeams").Return(tt.mockTeams, tt.mockError)
129 | 
130 | 			server := &PortainerMCPServer{
131 | 				cli: mockClient,
132 | 			}
133 | 
134 | 			handler := server.HandleGetTeams()
135 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
136 | 
137 | 			if tt.expectError {
138 | 				assert.NoError(t, err)
139 | 				assert.NotNil(t, result)
140 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
141 | 				assert.Len(t, result.Content, 1)
142 | 				textContent, ok := result.Content[0].(mcp.TextContent)
143 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
144 | 				if tt.mockError != nil {
145 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
146 | 				} else {
147 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
148 | 				}
149 | 			} else {
150 | 				assert.NoError(t, err)
151 | 				assert.Len(t, result.Content, 1)
152 | 				textContent, ok := result.Content[0].(mcp.TextContent)
153 | 				assert.True(t, ok)
154 | 
155 | 				var teams []models.Team
156 | 				err = json.Unmarshal([]byte(textContent.Text), &teams)
157 | 				assert.NoError(t, err)
158 | 				assert.Equal(t, tt.mockTeams, teams)
159 | 			}
160 | 
161 | 			mockClient.AssertExpectations(t)
162 | 		})
163 | 	}
164 | }
165 | 
166 | func TestHandleUpdateTeamName(t *testing.T) {
167 | 	tests := []struct {
168 | 		name        string
169 | 		inputID     int
170 | 		inputName   string
171 | 		mockError   error
172 | 		expectError bool
173 | 		setupParams func(request *mcp.CallToolRequest)
174 | 	}{
175 | 		{
176 | 			name:        "successful name update",
177 | 			inputID:     1,
178 | 			inputName:   "new-name",
179 | 			mockError:   nil,
180 | 			expectError: false,
181 | 			setupParams: func(request *mcp.CallToolRequest) {
182 | 				request.Params.Arguments = map[string]any{
183 | 					"id":   float64(1),
184 | 					"name": "new-name",
185 | 				}
186 | 			},
187 | 		},
188 | 		{
189 | 			name:        "api error",
190 | 			inputID:     1,
191 | 			inputName:   "new-name",
192 | 			mockError:   fmt.Errorf("api error"),
193 | 			expectError: true,
194 | 			setupParams: func(request *mcp.CallToolRequest) {
195 | 				request.Params.Arguments = map[string]any{
196 | 					"id":   float64(1),
197 | 					"name": "new-name",
198 | 				}
199 | 			},
200 | 		},
201 | 		{
202 | 			name:        "missing id parameter",
203 | 			inputID:     0,
204 | 			inputName:   "new-name",
205 | 			mockError:   nil,
206 | 			expectError: true,
207 | 			setupParams: func(request *mcp.CallToolRequest) {
208 | 				request.Params.Arguments = map[string]any{
209 | 					"name": "new-name",
210 | 				}
211 | 			},
212 | 		},
213 | 		{
214 | 			name:        "missing name parameter",
215 | 			inputID:     1,
216 | 			inputName:   "",
217 | 			mockError:   nil,
218 | 			expectError: true,
219 | 			setupParams: func(request *mcp.CallToolRequest) {
220 | 				request.Params.Arguments = map[string]any{
221 | 					"id": float64(1),
222 | 				}
223 | 			},
224 | 		},
225 | 	}
226 | 
227 | 	for _, tt := range tests {
228 | 		t.Run(tt.name, func(t *testing.T) {
229 | 			mockClient := &MockPortainerClient{}
230 | 			if !tt.expectError || tt.mockError != nil {
231 | 				mockClient.On("UpdateTeamName", tt.inputID, tt.inputName).Return(tt.mockError)
232 | 			}
233 | 
234 | 			server := &PortainerMCPServer{
235 | 				cli: mockClient,
236 | 			}
237 | 
238 | 			request := CreateMCPRequest(map[string]any{})
239 | 			tt.setupParams(&request)
240 | 
241 | 			handler := server.HandleUpdateTeamName()
242 | 			result, err := handler(context.Background(), request)
243 | 
244 | 			if tt.expectError {
245 | 				assert.NoError(t, err)
246 | 				assert.NotNil(t, result)
247 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
248 | 				assert.Len(t, result.Content, 1)
249 | 				textContent, ok := result.Content[0].(mcp.TextContent)
250 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
251 | 				if tt.mockError != nil {
252 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
253 | 				} else {
254 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
255 | 				}
256 | 			} else {
257 | 				assert.NoError(t, err)
258 | 				assert.Len(t, result.Content, 1)
259 | 				textContent, ok := result.Content[0].(mcp.TextContent)
260 | 				assert.True(t, ok)
261 | 				assert.Contains(t, textContent.Text, "successfully")
262 | 			}
263 | 
264 | 			mockClient.AssertExpectations(t)
265 | 		})
266 | 	}
267 | }
268 | 
269 | func TestHandleUpdateTeamMembers(t *testing.T) {
270 | 	tests := []struct {
271 | 		name        string
272 | 		inputID     int
273 | 		inputUsers  []int
274 | 		mockError   error
275 | 		expectError bool
276 | 		setupParams func(request *mcp.CallToolRequest)
277 | 	}{
278 | 		{
279 | 			name:        "successful members update",
280 | 			inputID:     1,
281 | 			inputUsers:  []int{1, 2, 3},
282 | 			mockError:   nil,
283 | 			expectError: false,
284 | 			setupParams: func(request *mcp.CallToolRequest) {
285 | 				request.Params.Arguments = map[string]any{
286 | 					"id":      float64(1),
287 | 					"userIds": []any{float64(1), float64(2), float64(3)},
288 | 				}
289 | 			},
290 | 		},
291 | 		{
292 | 			name:        "api error",
293 | 			inputID:     1,
294 | 			inputUsers:  []int{1, 2, 3},
295 | 			mockError:   fmt.Errorf("api error"),
296 | 			expectError: true,
297 | 			setupParams: func(request *mcp.CallToolRequest) {
298 | 				request.Params.Arguments = map[string]any{
299 | 					"id":      float64(1),
300 | 					"userIds": []any{float64(1), float64(2), float64(3)},
301 | 				}
302 | 			},
303 | 		},
304 | 		{
305 | 			name:        "missing id parameter",
306 | 			inputID:     0,
307 | 			inputUsers:  []int{1, 2, 3},
308 | 			mockError:   nil,
309 | 			expectError: true,
310 | 			setupParams: func(request *mcp.CallToolRequest) {
311 | 				request.Params.Arguments = map[string]any{
312 | 					"userIds": []any{float64(1), float64(2), float64(3)},
313 | 				}
314 | 			},
315 | 		},
316 | 		{
317 | 			name:        "missing userIds parameter",
318 | 			inputID:     1,
319 | 			inputUsers:  nil,
320 | 			mockError:   nil,
321 | 			expectError: true,
322 | 			setupParams: func(request *mcp.CallToolRequest) {
323 | 				request.Params.Arguments = map[string]any{
324 | 					"id": float64(1),
325 | 				}
326 | 			},
327 | 		},
328 | 	}
329 | 
330 | 	for _, tt := range tests {
331 | 		t.Run(tt.name, func(t *testing.T) {
332 | 			mockClient := &MockPortainerClient{}
333 | 			if !tt.expectError || tt.mockError != nil {
334 | 				mockClient.On("UpdateTeamMembers", tt.inputID, tt.inputUsers).Return(tt.mockError)
335 | 			}
336 | 
337 | 			server := &PortainerMCPServer{
338 | 				cli: mockClient,
339 | 			}
340 | 
341 | 			request := CreateMCPRequest(map[string]any{})
342 | 			tt.setupParams(&request)
343 | 
344 | 			handler := server.HandleUpdateTeamMembers()
345 | 			result, err := handler(context.Background(), request)
346 | 
347 | 			if tt.expectError {
348 | 				assert.NoError(t, err)
349 | 				assert.NotNil(t, result)
350 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
351 | 				assert.Len(t, result.Content, 1)
352 | 				textContent, ok := result.Content[0].(mcp.TextContent)
353 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
354 | 				if tt.mockError != nil {
355 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
356 | 				} else {
357 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
358 | 				}
359 | 			} else {
360 | 				assert.NoError(t, err)
361 | 				assert.Len(t, result.Content, 1)
362 | 				textContent, ok := result.Content[0].(mcp.TextContent)
363 | 				assert.True(t, ok)
364 | 				assert.Contains(t, textContent.Text, "successfully")
365 | 			}
366 | 
367 | 			mockClient.AssertExpectations(t)
368 | 		})
369 | 	}
370 | }
371 | 
```

--------------------------------------------------------------------------------
/pkg/portainer/client/access_group_test.go:
--------------------------------------------------------------------------------

```go
  1 | package client
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	apimodels "github.com/portainer/client-api-go/v2/pkg/models"
  8 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
  9 | 	"github.com/stretchr/testify/assert"
 10 | 	"github.com/stretchr/testify/mock"
 11 | )
 12 | 
 13 | func TestGetAccessGroups(t *testing.T) {
 14 | 	tests := []struct {
 15 | 		name                  string
 16 | 		mockEndpointGroups    []*apimodels.PortainerEndpointGroup
 17 | 		mockEndpoints         []*apimodels.PortainereeEndpoint
 18 | 		mockEndpointGroupsErr error
 19 | 		mockEndpointsErr      error
 20 | 		expected              []models.AccessGroup
 21 | 		expectedError         bool
 22 | 	}{
 23 | 		{
 24 | 			name: "successful retrieval",
 25 | 			mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
 26 | 				{
 27 | 					ID:   1,
 28 | 					Name: "group1",
 29 | 					UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
 30 | 						"1": apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
 31 | 						"2": apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
 32 | 						"3": apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
 33 | 						"4": apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
 34 | 						"5": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
 35 | 					},
 36 | 					TeamAccessPolicies: apimodels.PortainerTeamAccessPolicies{
 37 | 						"6":  apimodels.PortainerAccessPolicy{RoleID: 1}, // environment_administrator
 38 | 						"7":  apimodels.PortainerAccessPolicy{RoleID: 2}, // helpdesk_user
 39 | 						"8":  apimodels.PortainerAccessPolicy{RoleID: 3}, // standard_user
 40 | 						"9":  apimodels.PortainerAccessPolicy{RoleID: 4}, // readonly_user
 41 | 						"10": apimodels.PortainerAccessPolicy{RoleID: 5}, // operator_user
 42 | 					},
 43 | 				},
 44 | 			},
 45 | 			mockEndpoints: []*apimodels.PortainereeEndpoint{
 46 | 				{ID: 1, Name: "endpoint1", GroupID: 1},
 47 | 				{ID: 2, Name: "endpoint2", GroupID: 1},
 48 | 				{ID: 3, Name: "endpoint3", GroupID: 2},
 49 | 			},
 50 | 			expected: []models.AccessGroup{
 51 | 				{
 52 | 					ID:             1,
 53 | 					Name:           "group1",
 54 | 					EnvironmentIds: []int{1, 2},
 55 | 					UserAccesses: map[int]string{
 56 | 						1: "environment_administrator",
 57 | 						2: "helpdesk_user",
 58 | 						3: "standard_user",
 59 | 						4: "readonly_user",
 60 | 						5: "operator_user",
 61 | 					},
 62 | 					TeamAccesses: map[int]string{
 63 | 						6:  "environment_administrator",
 64 | 						7:  "helpdesk_user",
 65 | 						8:  "standard_user",
 66 | 						9:  "readonly_user",
 67 | 						10: "operator_user",
 68 | 					},
 69 | 				},
 70 | 			},
 71 | 		},
 72 | 		{
 73 | 			name:                  "endpoint group list error",
 74 | 			mockEndpointGroupsErr: errors.New("failed to list groups"),
 75 | 			expectedError:         true,
 76 | 		},
 77 | 		{
 78 | 			name: "endpoint list error",
 79 | 			mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
 80 | 				{ID: 1, Name: "group1"},
 81 | 			},
 82 | 			mockEndpointsErr: errors.New("failed to list endpoints"),
 83 | 			expectedError:    true,
 84 | 		},
 85 | 		{
 86 | 			name:               "empty groups with endpoints",
 87 | 			mockEndpointGroups: []*apimodels.PortainerEndpointGroup{},
 88 | 			mockEndpoints: []*apimodels.PortainereeEndpoint{
 89 | 				{ID: 1, Name: "endpoint1", GroupID: 1},
 90 | 				{ID: 2, Name: "endpoint2", GroupID: 2},
 91 | 			},
 92 | 			expected: []models.AccessGroup{},
 93 | 		},
 94 | 		{
 95 | 			name: "groups with empty endpoints",
 96 | 			mockEndpointGroups: []*apimodels.PortainerEndpointGroup{
 97 | 				{
 98 | 					ID:   1,
 99 | 					Name: "group1",
100 | 					UserAccessPolicies: apimodels.PortainerUserAccessPolicies{
101 | 						"1": apimodels.PortainerAccessPolicy{RoleID: 1},
102 | 					},
103 | 				},
104 | 			},
105 | 			mockEndpoints: []*apimodels.PortainereeEndpoint{},
106 | 			expected: []models.AccessGroup{
107 | 				{
108 | 					ID:             1,
109 | 					Name:           "group1",
110 | 					EnvironmentIds: []int{},
111 | 					UserAccesses: map[int]string{
112 | 						1: "environment_administrator",
113 | 					},
114 | 					TeamAccesses: map[int]string{},
115 | 				},
116 | 			},
117 | 		},
118 | 		{
119 | 			name:               "both empty",
120 | 			mockEndpointGroups: []*apimodels.PortainerEndpointGroup{},
121 | 			mockEndpoints:      []*apimodels.PortainereeEndpoint{},
122 | 			expected:           []models.AccessGroup{},
123 | 		},
124 | 	}
125 | 
126 | 	for _, tt := range tests {
127 | 		t.Run(tt.name, func(t *testing.T) {
128 | 			mockAPI := new(MockPortainerAPI)
129 | 			mockAPI.On("ListEndpointGroups").Return(tt.mockEndpointGroups, tt.mockEndpointGroupsErr)
130 | 			mockAPI.On("ListEndpoints").Return(tt.mockEndpoints, tt.mockEndpointsErr)
131 | 
132 | 			client := &PortainerClient{cli: mockAPI}
133 | 
134 | 			groups, err := client.GetAccessGroups()
135 | 
136 | 			if tt.expectedError {
137 | 				assert.Error(t, err)
138 | 				return
139 | 			}
140 | 			assert.NoError(t, err)
141 | 			assert.Equal(t, tt.expected, groups)
142 | 			mockAPI.AssertExpectations(t)
143 | 		})
144 | 	}
145 | }
146 | 
147 | func TestCreateAccessGroup(t *testing.T) {
148 | 	tests := []struct {
149 | 		name          string
150 | 		groupName     string
151 | 		envIDs        []int
152 | 		mockReturnID  int64
153 | 		mockError     error
154 | 		expected      int
155 | 		expectedError bool
156 | 	}{
157 | 		{
158 | 			name:         "successful creation",
159 | 			groupName:    "newgroup",
160 | 			envIDs:       []int{1, 2, 3},
161 | 			mockReturnID: 1,
162 | 			expected:     1,
163 | 		},
164 | 		{
165 | 			name:          "creation error",
166 | 			groupName:     "newgroup",
167 | 			envIDs:        []int{1},
168 | 			mockError:     errors.New("failed to create group"),
169 | 			expectedError: true,
170 | 		},
171 | 	}
172 | 
173 | 	for _, tt := range tests {
174 | 		t.Run(tt.name, func(t *testing.T) {
175 | 			mockAPI := new(MockPortainerAPI)
176 | 			mockAPI.On("CreateEndpointGroup", tt.groupName, mock.Anything).Return(tt.mockReturnID, tt.mockError)
177 | 
178 | 			client := &PortainerClient{cli: mockAPI}
179 | 
180 | 			id, err := client.CreateAccessGroup(tt.groupName, tt.envIDs)
181 | 
182 | 			if tt.expectedError {
183 | 				assert.Error(t, err)
184 | 				return
185 | 			}
186 | 			assert.NoError(t, err)
187 | 			assert.Equal(t, tt.expected, id)
188 | 			mockAPI.AssertExpectations(t)
189 | 		})
190 | 	}
191 | }
192 | 
193 | func TestUpdateAccessGroupName(t *testing.T) {
194 | 	tests := []struct {
195 | 		name          string
196 | 		groupID       int
197 | 		newName       string
198 | 		mockError     error
199 | 		expectedError bool
200 | 	}{
201 | 		{
202 | 			name:    "successful update",
203 | 			groupID: 1,
204 | 			newName: "updated-group",
205 | 		},
206 | 		{
207 | 			name:          "update error",
208 | 			groupID:       1,
209 | 			newName:       "updated-group",
210 | 			mockError:     errors.New("failed to update group"),
211 | 			expectedError: true,
212 | 		},
213 | 	}
214 | 
215 | 	for _, tt := range tests {
216 | 		t.Run(tt.name, func(t *testing.T) {
217 | 			mockAPI := new(MockPortainerAPI)
218 | 			mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), &tt.newName, mock.Anything, mock.Anything).Return(tt.mockError)
219 | 
220 | 			client := &PortainerClient{cli: mockAPI}
221 | 
222 | 			err := client.UpdateAccessGroupName(tt.groupID, tt.newName)
223 | 
224 | 			if tt.expectedError {
225 | 				assert.Error(t, err)
226 | 				return
227 | 			}
228 | 			assert.NoError(t, err)
229 | 			mockAPI.AssertExpectations(t)
230 | 		})
231 | 	}
232 | }
233 | 
234 | func TestUpdateAccessGroupUserAccesses(t *testing.T) {
235 | 	tests := []struct {
236 | 		name          string
237 | 		groupID       int
238 | 		userAccesses  map[int]string
239 | 		mockError     error
240 | 		expectedError bool
241 | 	}{
242 | 		{
243 | 			name:    "successful update",
244 | 			groupID: 1,
245 | 			userAccesses: map[int]string{
246 | 				1: "environment_administrator",
247 | 				2: "readonly_user",
248 | 			},
249 | 		},
250 | 		{
251 | 			name:    "update error",
252 | 			groupID: 1,
253 | 			userAccesses: map[int]string{
254 | 				1: "environment_administrator",
255 | 			},
256 | 			mockError:     errors.New("failed to update user accesses"),
257 | 			expectedError: true,
258 | 		},
259 | 	}
260 | 
261 | 	for _, tt := range tests {
262 | 		t.Run(tt.name, func(t *testing.T) {
263 | 			mockAPI := new(MockPortainerAPI)
264 | 			mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
265 | 
266 | 			client := &PortainerClient{cli: mockAPI}
267 | 
268 | 			err := client.UpdateAccessGroupUserAccesses(tt.groupID, tt.userAccesses)
269 | 
270 | 			if tt.expectedError {
271 | 				assert.Error(t, err)
272 | 				return
273 | 			}
274 | 			assert.NoError(t, err)
275 | 			mockAPI.AssertExpectations(t)
276 | 		})
277 | 	}
278 | }
279 | 
280 | func TestUpdateAccessGroupTeamAccesses(t *testing.T) {
281 | 	tests := []struct {
282 | 		name          string
283 | 		groupID       int
284 | 		teamAccesses  map[int]string
285 | 		mockError     error
286 | 		expectedError bool
287 | 	}{
288 | 		{
289 | 			name:    "successful update",
290 | 			groupID: 1,
291 | 			teamAccesses: map[int]string{
292 | 				1: "environment_administrator",
293 | 				2: "readonly_user",
294 | 			},
295 | 		},
296 | 		{
297 | 			name:    "update error",
298 | 			groupID: 1,
299 | 			teamAccesses: map[int]string{
300 | 				1: "environment_administrator",
301 | 			},
302 | 			mockError:     errors.New("failed to update team accesses"),
303 | 			expectedError: true,
304 | 		},
305 | 	}
306 | 
307 | 	for _, tt := range tests {
308 | 		t.Run(tt.name, func(t *testing.T) {
309 | 			mockAPI := new(MockPortainerAPI)
310 | 			mockAPI.On("UpdateEndpointGroup", int64(tt.groupID), mock.Anything, mock.Anything, mock.Anything).Return(tt.mockError)
311 | 
312 | 			client := &PortainerClient{cli: mockAPI}
313 | 
314 | 			err := client.UpdateAccessGroupTeamAccesses(tt.groupID, tt.teamAccesses)
315 | 
316 | 			if tt.expectedError {
317 | 				assert.Error(t, err)
318 | 				return
319 | 			}
320 | 			assert.NoError(t, err)
321 | 			mockAPI.AssertExpectations(t)
322 | 		})
323 | 	}
324 | }
325 | 
326 | func TestAddEnvironmentToAccessGroup(t *testing.T) {
327 | 	tests := []struct {
328 | 		name          string
329 | 		groupID       int
330 | 		envID         int
331 | 		mockError     error
332 | 		expectedError bool
333 | 	}{
334 | 		{
335 | 			name:    "successful addition",
336 | 			groupID: 1,
337 | 			envID:   2,
338 | 		},
339 | 		{
340 | 			name:          "addition error",
341 | 			groupID:       1,
342 | 			envID:         2,
343 | 			mockError:     errors.New("failed to add environment"),
344 | 			expectedError: true,
345 | 		},
346 | 	}
347 | 
348 | 	for _, tt := range tests {
349 | 		t.Run(tt.name, func(t *testing.T) {
350 | 			mockAPI := new(MockPortainerAPI)
351 | 			mockAPI.On("AddEnvironmentToEndpointGroup", int64(tt.groupID), int64(tt.envID)).Return(tt.mockError)
352 | 
353 | 			client := &PortainerClient{cli: mockAPI}
354 | 
355 | 			err := client.AddEnvironmentToAccessGroup(tt.groupID, tt.envID)
356 | 
357 | 			if tt.expectedError {
358 | 				assert.Error(t, err)
359 | 				return
360 | 			}
361 | 			assert.NoError(t, err)
362 | 			mockAPI.AssertExpectations(t)
363 | 		})
364 | 	}
365 | }
366 | 
367 | func TestRemoveEnvironmentFromAccessGroup(t *testing.T) {
368 | 	tests := []struct {
369 | 		name          string
370 | 		groupID       int
371 | 		envID         int
372 | 		mockError     error
373 | 		expectedError bool
374 | 	}{
375 | 		{
376 | 			name:    "successful removal",
377 | 			groupID: 1,
378 | 			envID:   2,
379 | 		},
380 | 		{
381 | 			name:          "removal error",
382 | 			groupID:       1,
383 | 			envID:         2,
384 | 			mockError:     errors.New("failed to remove environment"),
385 | 			expectedError: true,
386 | 		},
387 | 	}
388 | 
389 | 	for _, tt := range tests {
390 | 		t.Run(tt.name, func(t *testing.T) {
391 | 			mockAPI := new(MockPortainerAPI)
392 | 			mockAPI.On("RemoveEnvironmentFromEndpointGroup", int64(tt.groupID), int64(tt.envID)).Return(tt.mockError)
393 | 
394 | 			client := &PortainerClient{cli: mockAPI}
395 | 
396 | 			err := client.RemoveEnvironmentFromAccessGroup(tt.groupID, tt.envID)
397 | 
398 | 			if tt.expectedError {
399 | 				assert.Error(t, err)
400 | 				return
401 | 			}
402 | 			assert.NoError(t, err)
403 | 			mockAPI.AssertExpectations(t)
404 | 		})
405 | 	}
406 | }
407 | 
```

--------------------------------------------------------------------------------
/pkg/toolgen/param_test.go:
--------------------------------------------------------------------------------

```go
  1 | package toolgen
  2 | 
  3 | import (
  4 | 	"reflect"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/mark3labs/mcp-go/mcp"
  8 | )
  9 | 
 10 | // Helper function to create a ParameterParser with given arguments
 11 | func newTestParser(args map[string]any) *ParameterParser {
 12 | 	return NewParameterParser(mcp.CallToolRequest{
 13 | 		Params: mcp.CallToolParams{
 14 | 			Arguments: args,
 15 | 		},
 16 | 	})
 17 | }
 18 | 
 19 | func TestGetString(t *testing.T) {
 20 | 	tests := []struct {
 21 | 		name     string
 22 | 		args     map[string]any
 23 | 		param    string
 24 | 		required bool
 25 | 		want     string
 26 | 		wantErr  bool
 27 | 	}{
 28 | 		{
 29 | 			name:     "valid string",
 30 | 			args:     map[string]any{"name": "test"},
 31 | 			param:    "name",
 32 | 			required: true,
 33 | 			want:     "test",
 34 | 			wantErr:  false,
 35 | 		},
 36 | 		{
 37 | 			name:     "missing required param",
 38 | 			args:     map[string]any{},
 39 | 			param:    "name",
 40 | 			required: true,
 41 | 			want:     "",
 42 | 			wantErr:  true,
 43 | 		},
 44 | 		{
 45 | 			name:     "missing optional param",
 46 | 			args:     map[string]any{},
 47 | 			param:    "name",
 48 | 			required: false,
 49 | 			want:     "",
 50 | 			wantErr:  false,
 51 | 		},
 52 | 		{
 53 | 			name:     "wrong type",
 54 | 			args:     map[string]any{"name": 123},
 55 | 			param:    "name",
 56 | 			required: true,
 57 | 			want:     "",
 58 | 			wantErr:  true,
 59 | 		},
 60 | 		{
 61 | 			name:     "nil value",
 62 | 			args:     map[string]any{"name": nil},
 63 | 			param:    "name",
 64 | 			required: true,
 65 | 			want:     "",
 66 | 			wantErr:  true,
 67 | 		},
 68 | 	}
 69 | 
 70 | 	for _, tt := range tests {
 71 | 		t.Run(tt.name, func(t *testing.T) {
 72 | 			p := newTestParser(tt.args)
 73 | 			got, err := p.GetString(tt.param, tt.required)
 74 | 			if (err != nil) != tt.wantErr {
 75 | 				t.Errorf("GetString() error = %v, wantErr %v", err, tt.wantErr)
 76 | 				return
 77 | 			}
 78 | 			if got != tt.want {
 79 | 				t.Errorf("GetString() = %v, want %v", got, tt.want)
 80 | 			}
 81 | 		})
 82 | 	}
 83 | }
 84 | 
 85 | func TestGetNumber(t *testing.T) {
 86 | 	tests := []struct {
 87 | 		name     string
 88 | 		args     map[string]any
 89 | 		param    string
 90 | 		required bool
 91 | 		want     float64
 92 | 		wantErr  bool
 93 | 	}{
 94 | 		{
 95 | 			name:     "valid number",
 96 | 			args:     map[string]any{"num": float64(42)},
 97 | 			param:    "num",
 98 | 			required: true,
 99 | 			want:     42,
100 | 			wantErr:  false,
101 | 		},
102 | 		{
103 | 			name:     "missing required param",
104 | 			args:     map[string]any{},
105 | 			param:    "num",
106 | 			required: true,
107 | 			want:     0,
108 | 			wantErr:  true,
109 | 		},
110 | 		{
111 | 			name:     "missing optional param",
112 | 			args:     map[string]any{},
113 | 			param:    "num",
114 | 			required: false,
115 | 			want:     0,
116 | 			wantErr:  false,
117 | 		},
118 | 		{
119 | 			name:     "wrong type",
120 | 			args:     map[string]any{"num": "123"},
121 | 			param:    "num",
122 | 			required: true,
123 | 			want:     0,
124 | 			wantErr:  true,
125 | 		},
126 | 		{
127 | 			name:     "nil value",
128 | 			args:     map[string]any{"num": nil},
129 | 			param:    "num",
130 | 			required: true,
131 | 			want:     0,
132 | 			wantErr:  true,
133 | 		},
134 | 	}
135 | 
136 | 	for _, tt := range tests {
137 | 		t.Run(tt.name, func(t *testing.T) {
138 | 			p := newTestParser(tt.args)
139 | 			got, err := p.GetNumber(tt.param, tt.required)
140 | 			if (err != nil) != tt.wantErr {
141 | 				t.Errorf("GetNumber() error = %v, wantErr %v", err, tt.wantErr)
142 | 				return
143 | 			}
144 | 			if got != tt.want {
145 | 				t.Errorf("GetNumber() = %v, want %v", got, tt.want)
146 | 			}
147 | 		})
148 | 	}
149 | }
150 | 
151 | func TestGetBoolean(t *testing.T) {
152 | 	tests := []struct {
153 | 		name     string
154 | 		args     map[string]any
155 | 		param    string
156 | 		required bool
157 | 		want     bool
158 | 		wantErr  bool
159 | 	}{
160 | 		{
161 | 			name:     "valid true",
162 | 			args:     map[string]any{"flag": true},
163 | 			param:    "flag",
164 | 			required: true,
165 | 			want:     true,
166 | 			wantErr:  false,
167 | 		},
168 | 		{
169 | 			name:     "valid false",
170 | 			args:     map[string]any{"flag": false},
171 | 			param:    "flag",
172 | 			required: true,
173 | 			want:     false,
174 | 			wantErr:  false,
175 | 		},
176 | 		{
177 | 			name:     "missing required param",
178 | 			args:     map[string]any{},
179 | 			param:    "flag",
180 | 			required: true,
181 | 			want:     false,
182 | 			wantErr:  true,
183 | 		},
184 | 		{
185 | 			name:     "missing optional param",
186 | 			args:     map[string]any{},
187 | 			param:    "flag",
188 | 			required: false,
189 | 			want:     false,
190 | 			wantErr:  false,
191 | 		},
192 | 		{
193 | 			name:     "wrong type",
194 | 			args:     map[string]any{"flag": "true"},
195 | 			param:    "flag",
196 | 			required: true,
197 | 			want:     false,
198 | 			wantErr:  true,
199 | 		},
200 | 		{
201 | 			name:     "nil value",
202 | 			args:     map[string]any{"flag": nil},
203 | 			param:    "flag",
204 | 			required: true,
205 | 			want:     false,
206 | 			wantErr:  true,
207 | 		},
208 | 	}
209 | 
210 | 	for _, tt := range tests {
211 | 		t.Run(tt.name, func(t *testing.T) {
212 | 			p := newTestParser(tt.args)
213 | 			got, err := p.GetBoolean(tt.param, tt.required)
214 | 			if (err != nil) != tt.wantErr {
215 | 				t.Errorf("GetBoolean() error = %v, wantErr %v", err, tt.wantErr)
216 | 				return
217 | 			}
218 | 			if got != tt.want {
219 | 				t.Errorf("GetBoolean() = %v, want %v", got, tt.want)
220 | 			}
221 | 		})
222 | 	}
223 | }
224 | 
225 | func TestGetArrayOfObjects(t *testing.T) {
226 | 	tests := []struct {
227 | 		name     string
228 | 		args     map[string]any
229 | 		param    string
230 | 		required bool
231 | 		want     []any
232 | 		wantErr  bool
233 | 	}{
234 | 		{
235 | 			name: "valid array of objects",
236 | 			args: map[string]any{"objects": []any{
237 | 				map[string]any{"id": 1},
238 | 				map[string]any{"id": 2},
239 | 			}},
240 | 			param:    "objects",
241 | 			required: true,
242 | 			want: []any{
243 | 				map[string]any{"id": 1},
244 | 				map[string]any{"id": 2},
245 | 			},
246 | 			wantErr: false,
247 | 		},
248 | 		{
249 | 			name:     "missing required param",
250 | 			args:     map[string]any{},
251 | 			param:    "objects",
252 | 			required: true,
253 | 			want:     nil,
254 | 			wantErr:  true,
255 | 		},
256 | 		{
257 | 			name:     "missing optional param",
258 | 			args:     map[string]any{},
259 | 			param:    "objects",
260 | 			required: false,
261 | 			want:     []any{},
262 | 			wantErr:  false,
263 | 		},
264 | 		{
265 | 			name:     "wrong type",
266 | 			args:     map[string]any{"objects": "not an array"},
267 | 			param:    "objects",
268 | 			required: true,
269 | 			want:     nil,
270 | 			wantErr:  true,
271 | 		},
272 | 		{
273 | 			name:     "nil value",
274 | 			args:     map[string]any{"objects": nil},
275 | 			param:    "objects",
276 | 			required: true,
277 | 			want:     nil,
278 | 			wantErr:  true,
279 | 		},
280 | 	}
281 | 
282 | 	for _, tt := range tests {
283 | 		t.Run(tt.name, func(t *testing.T) {
284 | 			p := newTestParser(tt.args)
285 | 			got, err := p.GetArrayOfObjects(tt.param, tt.required)
286 | 			if (err != nil) != tt.wantErr {
287 | 				t.Errorf("GetArrayOfObjects() error = %v, wantErr %v", err, tt.wantErr)
288 | 				return
289 | 			}
290 | 			if !reflect.DeepEqual(got, tt.want) {
291 | 				t.Errorf("GetArrayOfObjects() = %v, want %v", got, tt.want)
292 | 			}
293 | 		})
294 | 	}
295 | }
296 | 
297 | func TestParseArrayOfIntegers(t *testing.T) {
298 | 	tests := []struct {
299 | 		name    string
300 | 		input   []any
301 | 		want    []int
302 | 		wantErr bool
303 | 	}{
304 | 		{
305 | 			name:    "empty array",
306 | 			input:   []any{},
307 | 			want:    []int{},
308 | 			wantErr: false,
309 | 		},
310 | 		{
311 | 			name:    "single value",
312 | 			input:   []any{float64(42)},
313 | 			want:    []int{42},
314 | 			wantErr: false,
315 | 		},
316 | 		{
317 | 			name:    "multiple values",
318 | 			input:   []any{float64(1), float64(2), float64(3), float64(4), float64(5)},
319 | 			want:    []int{1, 2, 3, 4, 5},
320 | 			wantErr: false,
321 | 		},
322 | 		{
323 | 			name:    "negative values",
324 | 			input:   []any{float64(-1), float64(-2), float64(-3)},
325 | 			want:    []int{-1, -2, -3},
326 | 			wantErr: false,
327 | 		},
328 | 		{
329 | 			name:    "mixed positive and negative values",
330 | 			input:   []any{float64(0), float64(1), float64(-2), float64(3), float64(-4)},
331 | 			want:    []int{0, 1, -2, 3, -4},
332 | 			wantErr: false,
333 | 		},
334 | 		{
335 | 			name:    "invalid string value",
336 | 			input:   []any{float64(1), "abc", float64(3)},
337 | 			want:    nil,
338 | 			wantErr: true,
339 | 		},
340 | 		{
341 | 			name:    "invalid boolean value",
342 | 			input:   []any{float64(1), true, float64(3)},
343 | 			want:    nil,
344 | 			wantErr: true,
345 | 		},
346 | 		{
347 | 			name:    "invalid nil value",
348 | 			input:   []any{float64(1), nil, float64(3)},
349 | 			want:    nil,
350 | 			wantErr: true,
351 | 		},
352 | 	}
353 | 
354 | 	for _, tt := range tests {
355 | 		t.Run(tt.name, func(t *testing.T) {
356 | 			got, err := parseArrayOfIntegers(tt.input)
357 | 
358 | 			// Check error status
359 | 			if (err != nil) != tt.wantErr {
360 | 				t.Errorf("ParseNumericArray() error = %v, wantErr %v", err, tt.wantErr)
361 | 				return
362 | 			}
363 | 
364 | 			// If we expect an error, no need to check the result
365 | 			if tt.wantErr {
366 | 				return
367 | 			}
368 | 
369 | 			// Check result values
370 | 			if !reflect.DeepEqual(got, tt.want) {
371 | 				t.Errorf("ParseNumericArray() = %v, want %v", got, tt.want)
372 | 			}
373 | 		})
374 | 	}
375 | }
376 | 
377 | func TestGetInt(t *testing.T) {
378 | 	tests := []struct {
379 | 		name     string
380 | 		args     map[string]any
381 | 		param    string
382 | 		required bool
383 | 		want     int
384 | 		wantErr  bool
385 | 	}{
386 | 		{
387 | 			name:     "valid integer",
388 | 			args:     map[string]any{"num": float64(42)},
389 | 			param:    "num",
390 | 			required: true,
391 | 			want:     42,
392 | 			wantErr:  false,
393 | 		},
394 | 		{
395 | 			name:     "valid zero",
396 | 			args:     map[string]any{"num": float64(0)},
397 | 			param:    "num",
398 | 			required: true,
399 | 			want:     0,
400 | 			wantErr:  false,
401 | 		},
402 | 		{
403 | 			name:     "valid negative",
404 | 			args:     map[string]any{"num": float64(-42)},
405 | 			param:    "num",
406 | 			required: true,
407 | 			want:     -42,
408 | 			wantErr:  false,
409 | 		},
410 | 		{
411 | 			name:     "missing required param",
412 | 			args:     map[string]any{},
413 | 			param:    "num",
414 | 			required: true,
415 | 			want:     0,
416 | 			wantErr:  true,
417 | 		},
418 | 		{
419 | 			name:     "missing optional param",
420 | 			args:     map[string]any{},
421 | 			param:    "num",
422 | 			required: false,
423 | 			want:     0,
424 | 			wantErr:  false,
425 | 		},
426 | 		{
427 | 			name:     "wrong type string",
428 | 			args:     map[string]any{"num": "123"},
429 | 			param:    "num",
430 | 			required: true,
431 | 			want:     0,
432 | 			wantErr:  true,
433 | 		},
434 | 		{
435 | 			name:     "wrong type boolean",
436 | 			args:     map[string]any{"num": true},
437 | 			param:    "num",
438 | 			required: true,
439 | 			want:     0,
440 | 			wantErr:  true,
441 | 		},
442 | 		{
443 | 			name:     "nil value",
444 | 			args:     map[string]any{"num": nil},
445 | 			param:    "num",
446 | 			required: true,
447 | 			want:     0,
448 | 			wantErr:  true,
449 | 		},
450 | 	}
451 | 
452 | 	for _, tt := range tests {
453 | 		t.Run(tt.name, func(t *testing.T) {
454 | 			p := newTestParser(tt.args)
455 | 			got, err := p.GetInt(tt.param, tt.required)
456 | 			if (err != nil) != tt.wantErr {
457 | 				t.Errorf("GetInt() error = %v, wantErr %v", err, tt.wantErr)
458 | 				return
459 | 			}
460 | 			if got != tt.want {
461 | 				t.Errorf("GetInt() = %v, want %v", got, tt.want)
462 | 			}
463 | 		})
464 | 	}
465 | }
466 | 
467 | func TestGetArrayOfIntegers(t *testing.T) {
468 | 	tests := []struct {
469 | 		name     string
470 | 		args     map[string]any
471 | 		param    string
472 | 		required bool
473 | 		want     []int
474 | 		wantErr  bool
475 | 	}{
476 | 		{
477 | 			name: "valid array of integers",
478 | 			args: map[string]any{"nums": []any{
479 | 				float64(1), float64(2), float64(3),
480 | 			}},
481 | 			param:    "nums",
482 | 			required: true,
483 | 			want:     []int{1, 2, 3},
484 | 			wantErr:  false,
485 | 		},
486 | 		{
487 | 			name: "valid array with negative numbers",
488 | 			args: map[string]any{"nums": []any{
489 | 				float64(-1), float64(0), float64(1),
490 | 			}},
491 | 			param:    "nums",
492 | 			required: true,
493 | 			want:     []int{-1, 0, 1},
494 | 			wantErr:  false,
495 | 		},
496 | 		{
497 | 			name:     "empty array",
498 | 			args:     map[string]any{"nums": []any{}},
499 | 			param:    "nums",
500 | 			required: true,
501 | 			want:     []int{},
502 | 			wantErr:  false,
503 | 		},
504 | 		{
505 | 			name:     "missing required param",
506 | 			args:     map[string]any{},
507 | 			param:    "nums",
508 | 			required: true,
509 | 			want:     nil,
510 | 			wantErr:  true,
511 | 		},
512 | 		{
513 | 			name:     "missing optional param",
514 | 			args:     map[string]any{},
515 | 			param:    "nums",
516 | 			required: false,
517 | 			want:     []int{},
518 | 			wantErr:  false,
519 | 		},
520 | 		{
521 | 			name: "invalid array with string",
522 | 			args: map[string]any{"nums": []any{
523 | 				float64(1), "2", float64(3),
524 | 			}},
525 | 			param:    "nums",
526 | 			required: true,
527 | 			want:     nil,
528 | 			wantErr:  true,
529 | 		},
530 | 		{
531 | 			name: "invalid array with boolean",
532 | 			args: map[string]any{"nums": []any{
533 | 				float64(1), true, float64(3),
534 | 			}},
535 | 			param:    "nums",
536 | 			required: true,
537 | 			want:     nil,
538 | 			wantErr:  true,
539 | 		},
540 | 		{
541 | 			name: "invalid array with nil",
542 | 			args: map[string]any{"nums": []any{
543 | 				float64(1), nil, float64(3),
544 | 			}},
545 | 			param:    "nums",
546 | 			required: true,
547 | 			want:     nil,
548 | 			wantErr:  true,
549 | 		},
550 | 		{
551 | 			name:     "wrong type (string instead of array)",
552 | 			args:     map[string]any{"nums": "not an array"},
553 | 			param:    "nums",
554 | 			required: true,
555 | 			want:     nil,
556 | 			wantErr:  true,
557 | 		},
558 | 		{
559 | 			name:     "nil value",
560 | 			args:     map[string]any{"nums": nil},
561 | 			param:    "nums",
562 | 			required: true,
563 | 			want:     nil,
564 | 			wantErr:  true,
565 | 		},
566 | 	}
567 | 
568 | 	for _, tt := range tests {
569 | 		t.Run(tt.name, func(t *testing.T) {
570 | 			p := newTestParser(tt.args)
571 | 			got, err := p.GetArrayOfIntegers(tt.param, tt.required)
572 | 			if (err != nil) != tt.wantErr {
573 | 				t.Errorf("GetArrayOfIntegers() error = %v, wantErr %v", err, tt.wantErr)
574 | 				return
575 | 			}
576 | 			if !reflect.DeepEqual(got, tt.want) {
577 | 				t.Errorf("GetArrayOfIntegers() = %v, want %v", got, tt.want)
578 | 			}
579 | 		})
580 | 	}
581 | }
582 | 
```

--------------------------------------------------------------------------------
/internal/mcp/environment_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"strings"
  8 | 	"testing"
  9 | 
 10 | 	"github.com/mark3labs/mcp-go/mcp"
 11 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 12 | 	"github.com/stretchr/testify/assert"
 13 | )
 14 | 
 15 | func TestHandleGetEnvironments(t *testing.T) {
 16 | 	tests := []struct {
 17 | 		name             string
 18 | 		mockEnvironments []models.Environment
 19 | 		mockError        error
 20 | 		expectError      bool
 21 | 	}{
 22 | 		{
 23 | 			name: "successful environments retrieval",
 24 | 			mockEnvironments: []models.Environment{
 25 | 				{ID: 1, Name: "env1"},
 26 | 				{ID: 2, Name: "env2"},
 27 | 			},
 28 | 			mockError:   nil,
 29 | 			expectError: false,
 30 | 		},
 31 | 		{
 32 | 			name:             "api error",
 33 | 			mockEnvironments: nil,
 34 | 			mockError:        fmt.Errorf("api error"),
 35 | 			expectError:      true,
 36 | 		},
 37 | 	}
 38 | 
 39 | 	for _, tt := range tests {
 40 | 		t.Run(tt.name, func(t *testing.T) {
 41 | 			mockClient := &MockPortainerClient{}
 42 | 			mockClient.On("GetEnvironments").Return(tt.mockEnvironments, tt.mockError)
 43 | 
 44 | 			server := &PortainerMCPServer{
 45 | 				cli: mockClient,
 46 | 			}
 47 | 
 48 | 			handler := server.HandleGetEnvironments()
 49 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
 50 | 
 51 | 			if tt.expectError {
 52 | 				assert.NoError(t, err)
 53 | 				assert.NotNil(t, result)
 54 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
 55 | 				assert.Len(t, result.Content, 1)
 56 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 57 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
 58 | 				if tt.mockError != nil {
 59 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 60 | 				} else {
 61 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
 62 | 				}
 63 | 			} else {
 64 | 				assert.NoError(t, err)
 65 | 				assert.Len(t, result.Content, 1)
 66 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 67 | 				assert.True(t, ok)
 68 | 
 69 | 				var environments []models.Environment
 70 | 				err = json.Unmarshal([]byte(textContent.Text), &environments)
 71 | 				assert.NoError(t, err)
 72 | 				assert.Equal(t, tt.mockEnvironments, environments)
 73 | 			}
 74 | 
 75 | 			mockClient.AssertExpectations(t)
 76 | 		})
 77 | 	}
 78 | }
 79 | 
 80 | func TestHandleUpdateEnvironmentTags(t *testing.T) {
 81 | 	tests := []struct {
 82 | 		name        string
 83 | 		inputID     int
 84 | 		inputTagIDs []int
 85 | 		mockError   error
 86 | 		expectError bool
 87 | 		setupParams func(request *mcp.CallToolRequest)
 88 | 	}{
 89 | 		{
 90 | 			name:        "successful tags update",
 91 | 			inputID:     1,
 92 | 			inputTagIDs: []int{1, 2, 3},
 93 | 			mockError:   nil,
 94 | 			expectError: false,
 95 | 			setupParams: func(request *mcp.CallToolRequest) {
 96 | 				request.Params.Arguments = map[string]any{
 97 | 					"id":     float64(1),
 98 | 					"tagIds": []any{float64(1), float64(2), float64(3)},
 99 | 				}
100 | 			},
101 | 		},
102 | 		{
103 | 			name:        "api error",
104 | 			inputID:     1,
105 | 			inputTagIDs: []int{1, 2, 3},
106 | 			mockError:   fmt.Errorf("api error"),
107 | 			expectError: true,
108 | 			setupParams: func(request *mcp.CallToolRequest) {
109 | 				request.Params.Arguments = map[string]any{
110 | 					"id":     float64(1),
111 | 					"tagIds": []any{float64(1), float64(2), float64(3)},
112 | 				}
113 | 			},
114 | 		},
115 | 		{
116 | 			name:        "missing id parameter",
117 | 			inputID:     0,
118 | 			inputTagIDs: []int{1, 2, 3},
119 | 			mockError:   nil,
120 | 			expectError: true,
121 | 			setupParams: func(request *mcp.CallToolRequest) {
122 | 				request.Params.Arguments = map[string]any{
123 | 					"tagIds": []any{float64(1), float64(2), float64(3)},
124 | 				}
125 | 			},
126 | 		},
127 | 		{
128 | 			name:        "missing tagIds parameter",
129 | 			inputID:     1,
130 | 			inputTagIDs: nil,
131 | 			mockError:   nil,
132 | 			expectError: true,
133 | 			setupParams: func(request *mcp.CallToolRequest) {
134 | 				request.Params.Arguments = map[string]any{
135 | 					"id": float64(1),
136 | 				}
137 | 			},
138 | 		},
139 | 	}
140 | 
141 | 	for _, tt := range tests {
142 | 		t.Run(tt.name, func(t *testing.T) {
143 | 			mockClient := &MockPortainerClient{}
144 | 			if !tt.expectError || tt.mockError != nil {
145 | 				mockClient.On("UpdateEnvironmentTags", tt.inputID, tt.inputTagIDs).Return(tt.mockError)
146 | 			}
147 | 
148 | 			server := &PortainerMCPServer{
149 | 				cli: mockClient,
150 | 			}
151 | 
152 | 			request := CreateMCPRequest(map[string]any{})
153 | 			tt.setupParams(&request)
154 | 
155 | 			handler := server.HandleUpdateEnvironmentTags()
156 | 			result, err := handler(context.Background(), request)
157 | 
158 | 			if tt.expectError {
159 | 				assert.NoError(t, err)
160 | 				assert.NotNil(t, result)
161 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
162 | 				assert.Len(t, result.Content, 1)
163 | 				textContent, ok := result.Content[0].(mcp.TextContent)
164 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
165 | 				if tt.mockError != nil {
166 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
167 | 				} else {
168 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
169 | 				}
170 | 			} else {
171 | 				assert.NoError(t, err)
172 | 				assert.Len(t, result.Content, 1)
173 | 				textContent, ok := result.Content[0].(mcp.TextContent)
174 | 				assert.True(t, ok)
175 | 				assert.Contains(t, textContent.Text, "successfully")
176 | 			}
177 | 
178 | 			mockClient.AssertExpectations(t)
179 | 		})
180 | 	}
181 | }
182 | 
183 | func TestHandleUpdateEnvironmentUserAccesses(t *testing.T) {
184 | 	tests := []struct {
185 | 		name          string
186 | 		inputID       int
187 | 		inputAccesses map[int]string
188 | 		mockError     error
189 | 		expectError   bool
190 | 		setupParams   func(request *mcp.CallToolRequest)
191 | 	}{
192 | 		{
193 | 			name:    "successful user accesses update",
194 | 			inputID: 1,
195 | 			inputAccesses: map[int]string{
196 | 				1: "environment_administrator",
197 | 				2: "standard_user",
198 | 			},
199 | 			mockError:   nil,
200 | 			expectError: false,
201 | 			setupParams: func(request *mcp.CallToolRequest) {
202 | 				request.Params.Arguments = map[string]any{
203 | 					"id": float64(1),
204 | 					"userAccesses": []any{
205 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
206 | 						map[string]any{"id": float64(2), "access": "standard_user"},
207 | 					},
208 | 				}
209 | 			},
210 | 		},
211 | 		{
212 | 			name:    "api error",
213 | 			inputID: 1,
214 | 			inputAccesses: map[int]string{
215 | 				1: "environment_administrator",
216 | 			},
217 | 			mockError:   fmt.Errorf("api error"),
218 | 			expectError: true,
219 | 			setupParams: func(request *mcp.CallToolRequest) {
220 | 				request.Params.Arguments = map[string]any{
221 | 					"id": float64(1),
222 | 					"userAccesses": []any{
223 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
224 | 					},
225 | 				}
226 | 			},
227 | 		},
228 | 		{
229 | 			name:        "missing id parameter",
230 | 			inputID:     0,
231 | 			mockError:   nil,
232 | 			expectError: true,
233 | 			setupParams: func(request *mcp.CallToolRequest) {
234 | 				request.Params.Arguments = map[string]any{
235 | 					"userAccesses": []any{
236 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
237 | 					},
238 | 				}
239 | 			},
240 | 		},
241 | 		{
242 | 			name:        "missing userAccesses parameter",
243 | 			inputID:     1,
244 | 			mockError:   nil,
245 | 			expectError: true,
246 | 			setupParams: func(request *mcp.CallToolRequest) {
247 | 				request.Params.Arguments = map[string]any{
248 | 					"id": float64(1),
249 | 				}
250 | 			},
251 | 		},
252 | 		{
253 | 			name:    "invalid access level",
254 | 			inputID: 1,
255 | 			inputAccesses: map[int]string{
256 | 				1: "invalid_access",
257 | 			},
258 | 			mockError:   nil,
259 | 			expectError: true,
260 | 			setupParams: func(request *mcp.CallToolRequest) {
261 | 				request.Params.Arguments = map[string]any{
262 | 					"id": float64(1),
263 | 					"userAccesses": []any{
264 | 						map[string]any{"id": float64(1), "access": "invalid_access"},
265 | 					},
266 | 				}
267 | 			},
268 | 		},
269 | 	}
270 | 
271 | 	for _, tt := range tests {
272 | 		t.Run(tt.name, func(t *testing.T) {
273 | 			mockClient := &MockPortainerClient{}
274 | 			if !tt.expectError || tt.mockError != nil {
275 | 				mockClient.On("UpdateEnvironmentUserAccesses", tt.inputID, tt.inputAccesses).Return(tt.mockError)
276 | 			}
277 | 
278 | 			server := &PortainerMCPServer{
279 | 				cli: mockClient,
280 | 			}
281 | 
282 | 			request := CreateMCPRequest(map[string]any{})
283 | 			tt.setupParams(&request)
284 | 
285 | 			handler := server.HandleUpdateEnvironmentUserAccesses()
286 | 			result, err := handler(context.Background(), request)
287 | 
288 | 			if tt.expectError {
289 | 				assert.NoError(t, err)
290 | 				assert.NotNil(t, result)
291 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
292 | 				assert.Len(t, result.Content, 1)
293 | 				textContent, ok := result.Content[0].(mcp.TextContent)
294 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
295 | 				if tt.mockError != nil {
296 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
297 | 				} else {
298 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
299 | 					if strings.Contains(tt.name, "invalid access level") {
300 | 						assert.Contains(t, textContent.Text, "invalid user accesses")
301 | 					}
302 | 				}
303 | 			} else {
304 | 				assert.NoError(t, err)
305 | 				assert.Len(t, result.Content, 1)
306 | 				textContent, ok := result.Content[0].(mcp.TextContent)
307 | 				assert.True(t, ok)
308 | 				assert.Contains(t, textContent.Text, "successfully")
309 | 			}
310 | 
311 | 			mockClient.AssertExpectations(t)
312 | 		})
313 | 	}
314 | }
315 | 
316 | func TestHandleUpdateEnvironmentTeamAccesses(t *testing.T) {
317 | 	tests := []struct {
318 | 		name          string
319 | 		inputID       int
320 | 		inputAccesses map[int]string
321 | 		mockError     error
322 | 		expectError   bool
323 | 		setupParams   func(request *mcp.CallToolRequest)
324 | 	}{
325 | 		{
326 | 			name:    "successful team accesses update",
327 | 			inputID: 1,
328 | 			inputAccesses: map[int]string{
329 | 				1: "environment_administrator",
330 | 				2: "standard_user",
331 | 			},
332 | 			mockError:   nil,
333 | 			expectError: false,
334 | 			setupParams: func(request *mcp.CallToolRequest) {
335 | 				request.Params.Arguments = map[string]any{
336 | 					"id": float64(1),
337 | 					"teamAccesses": []any{
338 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
339 | 						map[string]any{"id": float64(2), "access": "standard_user"},
340 | 					},
341 | 				}
342 | 			},
343 | 		},
344 | 		{
345 | 			name:    "api error",
346 | 			inputID: 1,
347 | 			inputAccesses: map[int]string{
348 | 				1: "environment_administrator",
349 | 			},
350 | 			mockError:   fmt.Errorf("api error"),
351 | 			expectError: true,
352 | 			setupParams: func(request *mcp.CallToolRequest) {
353 | 				request.Params.Arguments = map[string]any{
354 | 					"id": float64(1),
355 | 					"teamAccesses": []any{
356 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
357 | 					},
358 | 				}
359 | 			},
360 | 		},
361 | 		{
362 | 			name:        "missing id parameter",
363 | 			inputID:     0,
364 | 			mockError:   nil,
365 | 			expectError: true,
366 | 			setupParams: func(request *mcp.CallToolRequest) {
367 | 				request.Params.Arguments = map[string]any{
368 | 					"teamAccesses": []any{
369 | 						map[string]any{"id": float64(1), "access": "environment_administrator"},
370 | 					},
371 | 				}
372 | 			},
373 | 		},
374 | 		{
375 | 			name:        "missing teamAccesses parameter",
376 | 			inputID:     1,
377 | 			mockError:   nil,
378 | 			expectError: true,
379 | 			setupParams: func(request *mcp.CallToolRequest) {
380 | 				request.Params.Arguments = map[string]any{
381 | 					"id": float64(1),
382 | 				}
383 | 			},
384 | 		},
385 | 		{
386 | 			name:    "invalid access level",
387 | 			inputID: 1,
388 | 			inputAccesses: map[int]string{
389 | 				1: "invalid_access",
390 | 			},
391 | 			mockError:   nil,
392 | 			expectError: true,
393 | 			setupParams: func(request *mcp.CallToolRequest) {
394 | 				request.Params.Arguments = map[string]any{
395 | 					"id": float64(1),
396 | 					"teamAccesses": []any{
397 | 						map[string]any{"id": float64(1), "access": "invalid_access"},
398 | 					},
399 | 				}
400 | 			},
401 | 		},
402 | 	}
403 | 
404 | 	for _, tt := range tests {
405 | 		t.Run(tt.name, func(t *testing.T) {
406 | 			mockClient := &MockPortainerClient{}
407 | 			if !tt.expectError || tt.mockError != nil {
408 | 				mockClient.On("UpdateEnvironmentTeamAccesses", tt.inputID, tt.inputAccesses).Return(tt.mockError)
409 | 			}
410 | 
411 | 			server := &PortainerMCPServer{
412 | 				cli: mockClient,
413 | 			}
414 | 
415 | 			request := CreateMCPRequest(map[string]any{})
416 | 			tt.setupParams(&request)
417 | 
418 | 			handler := server.HandleUpdateEnvironmentTeamAccesses()
419 | 			result, err := handler(context.Background(), request)
420 | 
421 | 			if tt.expectError {
422 | 				assert.NoError(t, err)
423 | 				assert.NotNil(t, result)
424 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
425 | 				assert.Len(t, result.Content, 1)
426 | 				textContent, ok := result.Content[0].(mcp.TextContent)
427 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
428 | 				if tt.mockError != nil {
429 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
430 | 				} else {
431 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter/validation errors")
432 | 					if strings.Contains(tt.name, "invalid access level") {
433 | 						assert.Contains(t, textContent.Text, "invalid team accesses")
434 | 					}
435 | 				}
436 | 			} else {
437 | 				assert.NoError(t, err)
438 | 				assert.Len(t, result.Content, 1)
439 | 				textContent, ok := result.Content[0].(mcp.TextContent)
440 | 				assert.True(t, ok)
441 | 				assert.Contains(t, textContent.Text, "successfully")
442 | 			}
443 | 
444 | 			mockClient.AssertExpectations(t)
445 | 		})
446 | 	}
447 | }
448 | 
```

--------------------------------------------------------------------------------
/internal/mcp/stack_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/portainer/portainer-mcp/pkg/portainer/models"
 11 | 	"github.com/stretchr/testify/assert"
 12 | )
 13 | 
 14 | func TestHandleGetStacks(t *testing.T) {
 15 | 	tests := []struct {
 16 | 		name        string
 17 | 		mockStacks  []models.Stack
 18 | 		mockError   error
 19 | 		expectError bool
 20 | 	}{
 21 | 		{
 22 | 			name: "successful stacks retrieval",
 23 | 			mockStacks: []models.Stack{
 24 | 				{ID: 1, Name: "stack1"},
 25 | 				{ID: 2, Name: "stack2"},
 26 | 			},
 27 | 			mockError:   nil,
 28 | 			expectError: false,
 29 | 		},
 30 | 		{
 31 | 			name:        "api error",
 32 | 			mockStacks:  nil,
 33 | 			mockError:   fmt.Errorf("api error"),
 34 | 			expectError: true,
 35 | 		},
 36 | 	}
 37 | 
 38 | 	for _, tt := range tests {
 39 | 		t.Run(tt.name, func(t *testing.T) {
 40 | 			mockClient := &MockPortainerClient{}
 41 | 			mockClient.On("GetStacks").Return(tt.mockStacks, tt.mockError)
 42 | 
 43 | 			server := &PortainerMCPServer{
 44 | 				cli: mockClient,
 45 | 			}
 46 | 
 47 | 			handler := server.HandleGetStacks()
 48 | 			result, err := handler(context.Background(), mcp.CallToolRequest{})
 49 | 
 50 | 			if tt.expectError {
 51 | 				assert.NoError(t, err)
 52 | 				assert.NotNil(t, result)
 53 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
 54 | 				assert.Len(t, result.Content, 1)
 55 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 56 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
 57 | 				if tt.mockError != nil {
 58 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
 59 | 				} else {
 60 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
 61 | 				}
 62 | 			} else {
 63 | 				assert.NoError(t, err)
 64 | 				assert.Len(t, result.Content, 1)
 65 | 				textContent, ok := result.Content[0].(mcp.TextContent)
 66 | 				assert.True(t, ok)
 67 | 
 68 | 				var stacks []models.Stack
 69 | 				err = json.Unmarshal([]byte(textContent.Text), &stacks)
 70 | 				assert.NoError(t, err)
 71 | 				assert.Equal(t, tt.mockStacks, stacks)
 72 | 			}
 73 | 
 74 | 			mockClient.AssertExpectations(t)
 75 | 		})
 76 | 	}
 77 | }
 78 | 
 79 | func TestHandleGetStackFile(t *testing.T) {
 80 | 	tests := []struct {
 81 | 		name        string
 82 | 		inputID     int
 83 | 		mockContent string
 84 | 		mockError   error
 85 | 		expectError bool
 86 | 		setupParams func(request *mcp.CallToolRequest)
 87 | 	}{
 88 | 		{
 89 | 			name:        "successful file retrieval",
 90 | 			inputID:     1,
 91 | 			mockContent: "version: '3'\nservices:\n  web:\n    image: nginx",
 92 | 			mockError:   nil,
 93 | 			expectError: false,
 94 | 			setupParams: func(request *mcp.CallToolRequest) {
 95 | 				request.Params.Arguments = map[string]any{
 96 | 					"id": float64(1),
 97 | 				}
 98 | 			},
 99 | 		},
100 | 		{
101 | 			name:        "api error",
102 | 			inputID:     1,
103 | 			mockContent: "",
104 | 			mockError:   fmt.Errorf("api error"),
105 | 			expectError: true,
106 | 			setupParams: func(request *mcp.CallToolRequest) {
107 | 				request.Params.Arguments = map[string]any{
108 | 					"id": float64(1),
109 | 				}
110 | 			},
111 | 		},
112 | 		{
113 | 			name:        "missing id parameter",
114 | 			inputID:     0,
115 | 			mockContent: "",
116 | 			mockError:   nil,
117 | 			expectError: true,
118 | 			setupParams: func(request *mcp.CallToolRequest) {
119 | 				// No need to set any parameters as the request will be invalid
120 | 			},
121 | 		},
122 | 	}
123 | 
124 | 	for _, tt := range tests {
125 | 		t.Run(tt.name, func(t *testing.T) {
126 | 			mockClient := &MockPortainerClient{}
127 | 			if !tt.expectError || tt.mockError != nil {
128 | 				mockClient.On("GetStackFile", tt.inputID).Return(tt.mockContent, tt.mockError)
129 | 			}
130 | 
131 | 			server := &PortainerMCPServer{
132 | 				cli: mockClient,
133 | 			}
134 | 
135 | 			request := CreateMCPRequest(map[string]any{})
136 | 			tt.setupParams(&request)
137 | 
138 | 			handler := server.HandleGetStackFile()
139 | 			result, err := handler(context.Background(), request)
140 | 
141 | 			if tt.expectError {
142 | 				assert.NoError(t, err)
143 | 				assert.NotNil(t, result)
144 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
145 | 				assert.Len(t, result.Content, 1)
146 | 				textContent, ok := result.Content[0].(mcp.TextContent)
147 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
148 | 				if tt.mockError != nil {
149 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
150 | 				} else {
151 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
152 | 				}
153 | 			} else {
154 | 				assert.NoError(t, err)
155 | 				assert.Len(t, result.Content, 1)
156 | 				textContent, ok := result.Content[0].(mcp.TextContent)
157 | 				assert.True(t, ok)
158 | 				assert.Equal(t, tt.mockContent, textContent.Text)
159 | 			}
160 | 
161 | 			mockClient.AssertExpectations(t)
162 | 		})
163 | 	}
164 | }
165 | 
166 | func TestHandleCreateStack(t *testing.T) {
167 | 	tests := []struct {
168 | 		name             string
169 | 		inputName        string
170 | 		inputFile        string
171 | 		inputEnvGroupIDs []int
172 | 		mockID           int
173 | 		mockError        error
174 | 		expectError      bool
175 | 		setupParams      func(request *mcp.CallToolRequest)
176 | 	}{
177 | 		{
178 | 			name:             "successful stack creation",
179 | 			inputName:        "test-stack",
180 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
181 | 			inputEnvGroupIDs: []int{1, 2},
182 | 			mockID:           1,
183 | 			mockError:        nil,
184 | 			expectError:      false,
185 | 			setupParams: func(request *mcp.CallToolRequest) {
186 | 				request.Params.Arguments = map[string]any{
187 | 					"name":                "test-stack",
188 | 					"file":                "version: '3'\nservices:\n  web:\n    image: nginx",
189 | 					"environmentGroupIds": []any{float64(1), float64(2)},
190 | 				}
191 | 			},
192 | 		},
193 | 		{
194 | 			name:             "api error",
195 | 			inputName:        "test-stack",
196 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
197 | 			inputEnvGroupIDs: []int{1, 2},
198 | 			mockID:           0,
199 | 			mockError:        fmt.Errorf("api error"),
200 | 			expectError:      true,
201 | 			setupParams: func(request *mcp.CallToolRequest) {
202 | 				request.Params.Arguments = map[string]any{
203 | 					"name":                "test-stack",
204 | 					"file":                "version: '3'\nservices:\n  web:\n    image: nginx",
205 | 					"environmentGroupIds": []any{float64(1), float64(2)},
206 | 				}
207 | 			},
208 | 		},
209 | 		{
210 | 			name:             "missing name parameter",
211 | 			inputName:        "",
212 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
213 | 			inputEnvGroupIDs: []int{1, 2},
214 | 			mockID:           0,
215 | 			mockError:        nil,
216 | 			expectError:      true,
217 | 			setupParams: func(request *mcp.CallToolRequest) {
218 | 				request.Params.Arguments = map[string]any{
219 | 					"file":                "version: '3'\nservices:\n  web:\n    image: nginx",
220 | 					"environmentGroupIds": []any{float64(1), float64(2)},
221 | 				}
222 | 			},
223 | 		},
224 | 		{
225 | 			name:             "missing file parameter",
226 | 			inputName:        "test-stack",
227 | 			inputFile:        "",
228 | 			inputEnvGroupIDs: []int{1, 2},
229 | 			mockID:           0,
230 | 			mockError:        nil,
231 | 			expectError:      true,
232 | 			setupParams: func(request *mcp.CallToolRequest) {
233 | 				request.Params.Arguments = map[string]any{
234 | 					"name":                "test-stack",
235 | 					"environmentGroupIds": []any{float64(1), float64(2)},
236 | 				}
237 | 			},
238 | 		},
239 | 		{
240 | 			name:             "missing environmentGroupIds parameter",
241 | 			inputName:        "test-stack",
242 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
243 | 			inputEnvGroupIDs: nil,
244 | 			mockID:           0,
245 | 			mockError:        nil,
246 | 			expectError:      true,
247 | 			setupParams: func(request *mcp.CallToolRequest) {
248 | 				request.Params.Arguments = map[string]any{
249 | 					"name": "test-stack",
250 | 					"file": "version: '3'\nservices:\n  web:\n    image: nginx",
251 | 				}
252 | 			},
253 | 		},
254 | 	}
255 | 
256 | 	for _, tt := range tests {
257 | 		t.Run(tt.name, func(t *testing.T) {
258 | 			mockClient := &MockPortainerClient{}
259 | 			if !tt.expectError || tt.mockError != nil {
260 | 				mockClient.On("CreateStack", tt.inputName, tt.inputFile, tt.inputEnvGroupIDs).Return(tt.mockID, tt.mockError)
261 | 			}
262 | 
263 | 			server := &PortainerMCPServer{
264 | 				cli: mockClient,
265 | 			}
266 | 
267 | 			request := CreateMCPRequest(map[string]any{})
268 | 			tt.setupParams(&request)
269 | 
270 | 			handler := server.HandleCreateStack()
271 | 			result, err := handler(context.Background(), request)
272 | 
273 | 			if tt.expectError {
274 | 				assert.NoError(t, err)
275 | 				assert.NotNil(t, result)
276 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
277 | 				assert.Len(t, result.Content, 1)
278 | 				textContent, ok := result.Content[0].(mcp.TextContent)
279 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
280 | 				if tt.mockError != nil {
281 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
282 | 				} else {
283 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
284 | 				}
285 | 			} else {
286 | 				assert.NoError(t, err)
287 | 				assert.Len(t, result.Content, 1)
288 | 				textContent, ok := result.Content[0].(mcp.TextContent)
289 | 				assert.True(t, ok)
290 | 				assert.Contains(t, textContent.Text, fmt.Sprintf("ID: %d", tt.mockID))
291 | 			}
292 | 
293 | 			mockClient.AssertExpectations(t)
294 | 		})
295 | 	}
296 | }
297 | 
298 | func TestHandleUpdateStack(t *testing.T) {
299 | 	tests := []struct {
300 | 		name             string
301 | 		inputID          int
302 | 		inputFile        string
303 | 		inputEnvGroupIDs []int
304 | 		mockError        error
305 | 		expectError      bool
306 | 		setupParams      func(request *mcp.CallToolRequest)
307 | 	}{
308 | 		{
309 | 			name:             "successful stack update",
310 | 			inputID:          1,
311 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
312 | 			inputEnvGroupIDs: []int{1, 2},
313 | 			mockError:        nil,
314 | 			expectError:      false,
315 | 			setupParams: func(request *mcp.CallToolRequest) {
316 | 				request.Params.Arguments = map[string]any{
317 | 					"id":                  float64(1),
318 | 					"file":                "version: '3'\nservices:\n  web:\n    image: nginx",
319 | 					"environmentGroupIds": []any{float64(1), float64(2)},
320 | 				}
321 | 			},
322 | 		},
323 | 		{
324 | 			name:             "api error",
325 | 			inputID:          1,
326 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
327 | 			inputEnvGroupIDs: []int{1, 2},
328 | 			mockError:        fmt.Errorf("api error"),
329 | 			expectError:      true,
330 | 			setupParams: func(request *mcp.CallToolRequest) {
331 | 				request.Params.Arguments = map[string]any{
332 | 					"id":                  float64(1),
333 | 					"file":                "version: '3'\nservices:\n  web:\n    image: nginx",
334 | 					"environmentGroupIds": []any{float64(1), float64(2)},
335 | 				}
336 | 			},
337 | 		},
338 | 		{
339 | 			name:             "missing id parameter",
340 | 			inputID:          0,
341 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
342 | 			inputEnvGroupIDs: []int{1, 2},
343 | 			mockError:        nil,
344 | 			expectError:      true,
345 | 			setupParams: func(request *mcp.CallToolRequest) {
346 | 				request.Params.Arguments = map[string]any{
347 | 					"file":                "version: '3'\nservices:\n  web:\n    image: nginx",
348 | 					"environmentGroupIds": []any{float64(1), float64(2)},
349 | 				}
350 | 			},
351 | 		},
352 | 		{
353 | 			name:             "missing file parameter",
354 | 			inputID:          1,
355 | 			inputFile:        "",
356 | 			inputEnvGroupIDs: []int{1, 2},
357 | 			mockError:        nil,
358 | 			expectError:      true,
359 | 			setupParams: func(request *mcp.CallToolRequest) {
360 | 				request.Params.Arguments = map[string]any{
361 | 					"id":                  float64(1),
362 | 					"environmentGroupIds": []any{float64(1), float64(2)},
363 | 				}
364 | 			},
365 | 		},
366 | 		{
367 | 			name:             "missing environmentGroupIds parameter",
368 | 			inputID:          1,
369 | 			inputFile:        "version: '3'\nservices:\n  web:\n    image: nginx",
370 | 			inputEnvGroupIDs: nil,
371 | 			mockError:        nil,
372 | 			expectError:      true,
373 | 			setupParams: func(request *mcp.CallToolRequest) {
374 | 				request.Params.Arguments = map[string]any{
375 | 					"id":   float64(1),
376 | 					"file": "version: '3'\nservices:\n  web:\n    image: nginx",
377 | 				}
378 | 			},
379 | 		},
380 | 	}
381 | 
382 | 	for _, tt := range tests {
383 | 		t.Run(tt.name, func(t *testing.T) {
384 | 			mockClient := &MockPortainerClient{}
385 | 			if !tt.expectError || tt.mockError != nil {
386 | 				mockClient.On("UpdateStack", tt.inputID, tt.inputFile, tt.inputEnvGroupIDs).Return(tt.mockError)
387 | 			}
388 | 
389 | 			server := &PortainerMCPServer{
390 | 				cli: mockClient,
391 | 			}
392 | 
393 | 			request := CreateMCPRequest(map[string]any{})
394 | 			tt.setupParams(&request)
395 | 
396 | 			handler := server.HandleUpdateStack()
397 | 			result, err := handler(context.Background(), request)
398 | 
399 | 			if tt.expectError {
400 | 				assert.NoError(t, err)
401 | 				assert.NotNil(t, result)
402 | 				assert.True(t, result.IsError, "result.IsError should be true for expected errors")
403 | 				assert.Len(t, result.Content, 1)
404 | 				textContent, ok := result.Content[0].(mcp.TextContent)
405 | 				assert.True(t, ok, "Result content should be mcp.TextContent for errors")
406 | 				if tt.mockError != nil {
407 | 					assert.Contains(t, textContent.Text, tt.mockError.Error())
408 | 				} else {
409 | 					assert.NotEmpty(t, textContent.Text, "Error message should not be empty for parameter errors")
410 | 				}
411 | 			} else {
412 | 				assert.NoError(t, err)
413 | 				assert.Len(t, result.Content, 1)
414 | 				textContent, ok := result.Content[0].(mcp.TextContent)
415 | 				assert.True(t, ok)
416 | 				assert.Contains(t, textContent.Text, "successfully")
417 | 			}
418 | 
419 | 			mockClient.AssertExpectations(t)
420 | 		})
421 | 	}
422 | }
423 | 
```
Page 3/5FirstPrevNextLast