Skip to content

Commit 7629d62

Browse files
JAORMXclaude
andcommitted
Add comprehensive unit tests for vMCP capability discovery
Adds unit tests for CLI backend discoverer and backend client, completing test coverage for the capability discovery implementation. Changes: - Add CLI discoverer tests with 8 test scenarios - Successful multi-backend discovery - Filtering stopped/unhealthy workloads - Filtering workloads without URLs - Error handling for nonexistent groups - Graceful handling of workload query failures - All tests parallel-safe with individual mock controllers - Add backend client tests - Factory error handling for all methods - Unsupported transport validation (stdio, unknown, empty) - Table-driven tests for transport types - Tests use interface-based approach (no SDK mocking) Test Results: - 13 test functions total across aggregator + discoverer + client - 19 test scenarios - All tests pass and run in parallel - Zero linter issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 41799e2 commit 7629d62

File tree

2 files changed

+553
-0
lines changed

2 files changed

+553
-0
lines changed
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
package aggregator
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"go.uber.org/mock/gomock"
11+
12+
"github.com/stacklok/toolhive/pkg/container/runtime"
13+
"github.com/stacklok/toolhive/pkg/core"
14+
"github.com/stacklok/toolhive/pkg/groups/mocks"
15+
"github.com/stacklok/toolhive/pkg/transport/types"
16+
"github.com/stacklok/toolhive/pkg/vmcp"
17+
workloadmocks "github.com/stacklok/toolhive/pkg/workloads/mocks"
18+
)
19+
20+
const testGroupName = "test-group"
21+
22+
func TestCLIBackendDiscoverer_Discover(t *testing.T) {
23+
t.Parallel()
24+
25+
t.Run("successful discovery with multiple backends", func(t *testing.T) {
26+
t.Parallel()
27+
ctrl := gomock.NewController(t)
28+
defer ctrl.Finish()
29+
30+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
31+
mockGroups := mocks.NewMockManager(ctrl)
32+
33+
groupName := testGroupName
34+
35+
// Group exists check
36+
mockGroups.EXPECT().
37+
Exists(gomock.Any(), groupName).
38+
Return(true, nil).
39+
Times(1)
40+
41+
// List workloads in group
42+
mockWorkloads.EXPECT().
43+
ListWorkloadsInGroup(gomock.Any(), groupName).
44+
Return([]string{"workload1", "workload2"}, nil).
45+
Times(1)
46+
47+
// Get workload details
48+
workload1 := core.Workload{
49+
Name: "workload1",
50+
Status: runtime.WorkloadStatusRunning,
51+
URL: "http://localhost:8080/mcp",
52+
TransportType: types.TransportTypeStreamableHTTP,
53+
ToolType: "github",
54+
Group: groupName,
55+
Labels: map[string]string{
56+
"env": "prod",
57+
},
58+
}
59+
60+
workload2 := core.Workload{
61+
Name: "workload2",
62+
Status: runtime.WorkloadStatusRunning,
63+
URL: "http://localhost:8081/mcp",
64+
TransportType: types.TransportTypeSSE,
65+
ToolType: "jira",
66+
Group: groupName,
67+
}
68+
69+
mockWorkloads.EXPECT().
70+
GetWorkload(gomock.Any(), "workload1").
71+
Return(workload1, nil).
72+
Times(1)
73+
74+
mockWorkloads.EXPECT().
75+
GetWorkload(gomock.Any(), "workload2").
76+
Return(workload2, nil).
77+
Times(1)
78+
79+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
80+
backends, err := discoverer.Discover(context.Background(), groupName)
81+
82+
require.NoError(t, err)
83+
assert.Len(t, backends, 2)
84+
85+
// Check first backend
86+
assert.Equal(t, "workload1", backends[0].ID)
87+
assert.Equal(t, "workload1", backends[0].Name)
88+
assert.Equal(t, "http://localhost:8080/mcp", backends[0].BaseURL)
89+
assert.Equal(t, "streamable-http", backends[0].TransportType)
90+
assert.Equal(t, vmcp.BackendHealthy, backends[0].HealthStatus)
91+
assert.Equal(t, groupName, backends[0].Metadata["group"])
92+
assert.Equal(t, "github", backends[0].Metadata["tool_type"])
93+
assert.Equal(t, "prod", backends[0].Metadata["env"])
94+
95+
// Check second backend
96+
assert.Equal(t, "workload2", backends[1].ID)
97+
assert.Equal(t, "sse", backends[1].TransportType)
98+
})
99+
100+
t.Run("filters out stopped workloads", func(t *testing.T) {
101+
t.Parallel()
102+
ctrl := gomock.NewController(t)
103+
defer ctrl.Finish()
104+
105+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
106+
mockGroups := mocks.NewMockManager(ctrl)
107+
108+
groupName := testGroupName
109+
110+
mockGroups.EXPECT().
111+
Exists(gomock.Any(), groupName).
112+
Return(true, nil)
113+
114+
mockWorkloads.EXPECT().
115+
ListWorkloadsInGroup(gomock.Any(), groupName).
116+
Return([]string{"running-workload", "stopped-workload"}, nil)
117+
118+
runningWorkload := core.Workload{
119+
Name: "running-workload",
120+
Status: runtime.WorkloadStatusRunning,
121+
URL: "http://localhost:8080/mcp",
122+
TransportType: types.TransportTypeStreamableHTTP,
123+
Group: groupName,
124+
}
125+
126+
stoppedWorkload := core.Workload{
127+
Name: "stopped-workload",
128+
Status: runtime.WorkloadStatusStopped,
129+
URL: "http://localhost:8081/mcp",
130+
TransportType: types.TransportTypeSSE,
131+
Group: groupName,
132+
}
133+
134+
mockWorkloads.EXPECT().
135+
GetWorkload(gomock.Any(), "running-workload").
136+
Return(runningWorkload, nil)
137+
138+
mockWorkloads.EXPECT().
139+
GetWorkload(gomock.Any(), "stopped-workload").
140+
Return(stoppedWorkload, nil)
141+
142+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
143+
backends, err := discoverer.Discover(context.Background(), groupName)
144+
145+
require.NoError(t, err)
146+
assert.Len(t, backends, 1)
147+
assert.Equal(t, "running-workload", backends[0].ID)
148+
})
149+
150+
t.Run("filters out workloads without URL", func(t *testing.T) {
151+
t.Parallel()
152+
ctrl := gomock.NewController(t)
153+
defer ctrl.Finish()
154+
155+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
156+
mockGroups := mocks.NewMockManager(ctrl)
157+
158+
groupName := testGroupName
159+
160+
mockGroups.EXPECT().
161+
Exists(gomock.Any(), groupName).
162+
Return(true, nil)
163+
164+
mockWorkloads.EXPECT().
165+
ListWorkloadsInGroup(gomock.Any(), groupName).
166+
Return([]string{"workload1", "workload2"}, nil)
167+
168+
workloadWithURL := core.Workload{
169+
Name: "workload1",
170+
Status: runtime.WorkloadStatusRunning,
171+
URL: "http://localhost:8080/mcp",
172+
TransportType: types.TransportTypeStreamableHTTP,
173+
Group: groupName,
174+
}
175+
176+
workloadWithoutURL := core.Workload{
177+
Name: "workload2",
178+
Status: runtime.WorkloadStatusRunning,
179+
URL: "", // No URL
180+
TransportType: types.TransportTypeStreamableHTTP,
181+
Group: groupName,
182+
}
183+
184+
mockWorkloads.EXPECT().
185+
GetWorkload(gomock.Any(), "workload1").
186+
Return(workloadWithURL, nil)
187+
188+
mockWorkloads.EXPECT().
189+
GetWorkload(gomock.Any(), "workload2").
190+
Return(workloadWithoutURL, nil)
191+
192+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
193+
backends, err := discoverer.Discover(context.Background(), groupName)
194+
195+
require.NoError(t, err)
196+
assert.Len(t, backends, 1)
197+
assert.Equal(t, "workload1", backends[0].ID)
198+
})
199+
200+
t.Run("returns error when group does not exist", func(t *testing.T) {
201+
t.Parallel()
202+
ctrl := gomock.NewController(t)
203+
defer ctrl.Finish()
204+
205+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
206+
mockGroups := mocks.NewMockManager(ctrl)
207+
208+
groupName := "nonexistent-group"
209+
210+
mockGroups.EXPECT().
211+
Exists(gomock.Any(), groupName).
212+
Return(false, nil).
213+
Times(1)
214+
215+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
216+
backends, err := discoverer.Discover(context.Background(), groupName)
217+
218+
require.Error(t, err)
219+
assert.Nil(t, backends)
220+
assert.Contains(t, err.Error(), "not found")
221+
})
222+
223+
t.Run("returns error when group check fails", func(t *testing.T) {
224+
t.Parallel()
225+
ctrl := gomock.NewController(t)
226+
defer ctrl.Finish()
227+
228+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
229+
mockGroups := mocks.NewMockManager(ctrl)
230+
231+
groupName := testGroupName
232+
233+
mockGroups.EXPECT().
234+
Exists(gomock.Any(), groupName).
235+
Return(false, errors.New("database error")).
236+
Times(1)
237+
238+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
239+
backends, err := discoverer.Discover(context.Background(), groupName)
240+
241+
require.Error(t, err)
242+
assert.Nil(t, backends)
243+
assert.Contains(t, err.Error(), "failed to check if group exists")
244+
})
245+
246+
t.Run("returns ErrNoBackendsFound when group is empty", func(t *testing.T) {
247+
t.Parallel()
248+
ctrl := gomock.NewController(t)
249+
defer ctrl.Finish()
250+
251+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
252+
mockGroups := mocks.NewMockManager(ctrl)
253+
254+
groupName := "empty-group"
255+
256+
mockGroups.EXPECT().
257+
Exists(gomock.Any(), groupName).
258+
Return(true, nil)
259+
260+
mockWorkloads.EXPECT().
261+
ListWorkloadsInGroup(gomock.Any(), groupName).
262+
Return([]string{}, nil)
263+
264+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
265+
backends, err := discoverer.Discover(context.Background(), groupName)
266+
267+
require.Error(t, err)
268+
assert.ErrorIs(t, err, ErrNoBackendsFound)
269+
assert.Nil(t, backends)
270+
})
271+
272+
t.Run("returns ErrNoBackendsFound when all workloads are unhealthy", func(t *testing.T) {
273+
t.Parallel()
274+
ctrl := gomock.NewController(t)
275+
defer ctrl.Finish()
276+
277+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
278+
mockGroups := mocks.NewMockManager(ctrl)
279+
280+
groupName := testGroupName
281+
282+
mockGroups.EXPECT().
283+
Exists(gomock.Any(), groupName).
284+
Return(true, nil)
285+
286+
mockWorkloads.EXPECT().
287+
ListWorkloadsInGroup(gomock.Any(), groupName).
288+
Return([]string{"stopped1", "error1"}, nil)
289+
290+
stoppedWorkload := core.Workload{
291+
Name: "stopped1",
292+
Status: runtime.WorkloadStatusStopped,
293+
URL: "http://localhost:8080/mcp",
294+
Group: groupName,
295+
}
296+
297+
errorWorkload := core.Workload{
298+
Name: "error1",
299+
Status: runtime.WorkloadStatusError,
300+
URL: "http://localhost:8081/mcp",
301+
Group: groupName,
302+
}
303+
304+
mockWorkloads.EXPECT().
305+
GetWorkload(gomock.Any(), "stopped1").
306+
Return(stoppedWorkload, nil)
307+
308+
mockWorkloads.EXPECT().
309+
GetWorkload(gomock.Any(), "error1").
310+
Return(errorWorkload, nil)
311+
312+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
313+
backends, err := discoverer.Discover(context.Background(), groupName)
314+
315+
require.Error(t, err)
316+
assert.ErrorIs(t, err, ErrNoBackendsFound)
317+
assert.Nil(t, backends)
318+
})
319+
320+
t.Run("gracefully handles workload get failures", func(t *testing.T) {
321+
t.Parallel()
322+
ctrl := gomock.NewController(t)
323+
defer ctrl.Finish()
324+
325+
mockWorkloads := workloadmocks.NewMockManager(ctrl)
326+
mockGroups := mocks.NewMockManager(ctrl)
327+
328+
groupName := testGroupName
329+
330+
mockGroups.EXPECT().
331+
Exists(gomock.Any(), groupName).
332+
Return(true, nil)
333+
334+
mockWorkloads.EXPECT().
335+
ListWorkloadsInGroup(gomock.Any(), groupName).
336+
Return([]string{"good-workload", "failing-workload"}, nil)
337+
338+
goodWorkload := core.Workload{
339+
Name: "good-workload",
340+
Status: runtime.WorkloadStatusRunning,
341+
URL: "http://localhost:8080/mcp",
342+
TransportType: types.TransportTypeStreamableHTTP,
343+
Group: groupName,
344+
}
345+
346+
mockWorkloads.EXPECT().
347+
GetWorkload(gomock.Any(), "good-workload").
348+
Return(goodWorkload, nil)
349+
350+
mockWorkloads.EXPECT().
351+
GetWorkload(gomock.Any(), "failing-workload").
352+
Return(core.Workload{}, errors.New("workload query failed"))
353+
354+
discoverer := NewCLIBackendDiscoverer(mockWorkloads, mockGroups)
355+
backends, err := discoverer.Discover(context.Background(), groupName)
356+
357+
// Should succeed with partial results
358+
require.NoError(t, err)
359+
assert.Len(t, backends, 1)
360+
assert.Equal(t, "good-workload", backends[0].ID)
361+
})
362+
}

0 commit comments

Comments
 (0)