Skip to content

Commit 8da16ef

Browse files
🌱 [scanner] test: add unit tests for MCP and Stellar handler packages (#18186)
- Add handler_test.go: WaitWithDeadline deadline behavior, clusterErrorTracker - Add helpers_test.go: withDemoFallback, respondClusterResources, demo mode detection - Add query_test.go: query parameter parsing (cluster, namespace) - Add setup_test.go: test environment helpers for MCP package - Add solver_test.go: solver constants, safe auto actions, solveFullStore interface - Add solver_workers_test.go: worker loop constants, context cancellation, digest configuration All new tests pass. Covers input validation, demo mode responses, and error handling paths as requested. Signed-off-by: GitHub Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: GitHub Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e92ee0f commit 8da16ef

6 files changed

Lines changed: 1013 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestWaitWithDeadline(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
goroutines int
17+
workDuration time.Duration
18+
deadline time.Duration
19+
wantTimeout bool
20+
}{
21+
{
22+
name: "all goroutines complete before deadline",
23+
goroutines: 3,
24+
workDuration: 10 * time.Millisecond,
25+
deadline: 100 * time.Millisecond,
26+
wantTimeout: false,
27+
},
28+
{
29+
name: "deadline reached with goroutines still running",
30+
goroutines: 3,
31+
workDuration: 200 * time.Millisecond,
32+
deadline: 50 * time.Millisecond,
33+
wantTimeout: true,
34+
},
35+
{
36+
name: "zero goroutines completes immediately",
37+
goroutines: 0,
38+
workDuration: 0,
39+
deadline: 50 * time.Millisecond,
40+
wantTimeout: false,
41+
},
42+
{
43+
name: "deadline exactly at completion time",
44+
goroutines: 2,
45+
workDuration: 30 * time.Millisecond,
46+
deadline: 100 * time.Millisecond,
47+
wantTimeout: false,
48+
},
49+
}
50+
51+
for _, tt := range tests {
52+
t.Run(tt.name, func(t *testing.T) {
53+
var wg sync.WaitGroup
54+
ctx, cancel := context.WithCancel(context.Background())
55+
56+
// Spawn goroutines that respect context cancellation
57+
for i := 0; i < tt.goroutines; i++ {
58+
wg.Add(1)
59+
go func() {
60+
defer wg.Done()
61+
select {
62+
case <-time.After(tt.workDuration):
63+
// Normal completion
64+
case <-ctx.Done():
65+
// Cancelled by deadline
66+
}
67+
}()
68+
}
69+
70+
timedOut := WaitWithDeadline(&wg, cancel, tt.deadline)
71+
cancel() // Clean up
72+
73+
assert.Equal(t, tt.wantTimeout, timedOut, "WaitWithDeadline timeout mismatch")
74+
75+
// Verify cancel was called when deadline hit
76+
if tt.wantTimeout {
77+
select {
78+
case <-ctx.Done():
79+
// Expected: context was cancelled
80+
case <-time.After(10 * time.Millisecond):
81+
t.Error("context should have been cancelled when deadline hit")
82+
}
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestClusterErrorTracker(t *testing.T) {
89+
t.Run("add single error", func(t *testing.T) {
90+
tracker := &clusterErrorTracker{}
91+
tracker.add("cluster-1", assert.AnError)
92+
93+
require.Len(t, tracker.errors, 1)
94+
assert.Equal(t, "cluster-1", tracker.errors[0].Cluster)
95+
assert.NotEmpty(t, tracker.errors[0].Message)
96+
})
97+
98+
t.Run("add multiple errors concurrently", func(t *testing.T) {
99+
tracker := &clusterErrorTracker{}
100+
var wg sync.WaitGroup
101+
102+
// Add errors from multiple goroutines
103+
for i := 0; i < 10; i++ {
104+
wg.Add(1)
105+
go func(idx int) {
106+
defer wg.Done()
107+
tracker.add("cluster-"+string(rune('0'+idx)), assert.AnError)
108+
}(i)
109+
}
110+
111+
wg.Wait()
112+
assert.Len(t, tracker.errors, 10)
113+
})
114+
115+
t.Run("annotate adds cluster errors to response", func(t *testing.T) {
116+
tracker := &clusterErrorTracker{}
117+
tracker.add("cluster-1", assert.AnError)
118+
tracker.add("cluster-2", assert.AnError)
119+
120+
resp := map[string]interface{}{"items": []string{}}
121+
annotated := tracker.annotate(resp)
122+
123+
require.Contains(t, annotated, "clusterErrors")
124+
// clusterErrors is []handlers.ClusterError, not []interface{}
125+
assert.Len(t, annotated["clusterErrors"], 2)
126+
})
127+
128+
t.Run("nil tracker does not panic", func(t *testing.T) {
129+
var tracker *clusterErrorTracker
130+
resp := map[string]interface{}{"items": []string{}}
131+
132+
// Should not panic
133+
assert.NotPanics(t, func() {
134+
if tracker != nil {
135+
tracker.annotate(resp)
136+
}
137+
})
138+
})
139+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/gofiber/fiber/v2"
9+
"github.com/kubestellar/console/pkg/k8s"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestWithDemoFallback(t *testing.T) {
15+
t.Run("returns demo data in demo mode", func(t *testing.T) {
16+
app := fiber.New()
17+
handler := &MCPHandlers{k8sClient: nil}
18+
19+
demoData := map[string]string{"status": "demo"}
20+
21+
app.Get("/test", func(c *fiber.Ctx) error {
22+
c.Locals("demoMode", true)
23+
return handler.withDemoFallback(c, "test-data", demoData, func(client *k8s.MultiClusterClient) error {
24+
return c.JSON(map[string]string{"status": "real"})
25+
})
26+
})
27+
28+
req := httptest.NewRequest("GET", "/test", nil)
29+
resp, err := app.Test(req, -1)
30+
require.NoError(t, err)
31+
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
32+
33+
var result map[string]interface{}
34+
err = json.NewDecoder(resp.Body).Decode(&result)
35+
require.NoError(t, err)
36+
assert.Equal(t, "demo", result["status"])
37+
})
38+
39+
t.Run("returns error when k8s client is nil in non-demo mode", func(t *testing.T) {
40+
app := fiber.New()
41+
handler := &MCPHandlers{k8sClient: nil}
42+
43+
app.Get("/test", func(c *fiber.Ctx) error {
44+
c.Locals("demoMode", false)
45+
return handler.withDemoFallback(c, "test-data", map[string]string{}, func(client *k8s.MultiClusterClient) error {
46+
return c.JSON(map[string]string{"status": "real"})
47+
})
48+
})
49+
50+
req := httptest.NewRequest("GET", "/test", nil)
51+
resp, err := app.Test(req, -1)
52+
require.NoError(t, err)
53+
assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode)
54+
})
55+
56+
t.Run("executes handler when k8s client is available", func(t *testing.T) {
57+
app := fiber.New()
58+
// Create a mock k8s client (nil is fine for this test since we just check it's passed)
59+
handler := &MCPHandlers{k8sClient: &k8s.MultiClusterClient{}}
60+
61+
app.Get("/test", func(c *fiber.Ctx) error {
62+
c.Locals("demoMode", false)
63+
return handler.withDemoFallback(c, "test-data", map[string]string{}, func(client *k8s.MultiClusterClient) error {
64+
assert.NotNil(t, client)
65+
return c.JSON(map[string]string{"status": "real"})
66+
})
67+
})
68+
69+
req := httptest.NewRequest("GET", "/test", nil)
70+
resp, err := app.Test(req, -1)
71+
require.NoError(t, err)
72+
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
73+
74+
var result map[string]interface{}
75+
err = json.NewDecoder(resp.Body).Decode(&result)
76+
require.NoError(t, err)
77+
assert.Equal(t, "real", result["status"])
78+
})
79+
}
80+
81+
func TestRespondClusterResources(t *testing.T) {
82+
t.Run("responds with items and source", func(t *testing.T) {
83+
app := fiber.New()
84+
app.Get("/test", func(c *fiber.Ctx) error {
85+
items := []string{"item1", "item2", "item3"}
86+
return respondClusterResources(c, "pods", items, nil)
87+
})
88+
89+
req := httptest.NewRequest("GET", "/test", nil)
90+
resp, err := app.Test(req, -1)
91+
require.NoError(t, err)
92+
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
93+
94+
var result map[string]interface{}
95+
err = json.NewDecoder(resp.Body).Decode(&result)
96+
require.NoError(t, err)
97+
assert.Equal(t, "k8s", result["source"])
98+
assert.Contains(t, result, "pods")
99+
})
100+
101+
t.Run("includes cluster errors when tracker provided", func(t *testing.T) {
102+
app := fiber.New()
103+
app.Get("/test", func(c *fiber.Ctx) error {
104+
items := []string{"item1"}
105+
tracker := &clusterErrorTracker{}
106+
tracker.add("cluster-1", assert.AnError)
107+
return respondClusterResources(c, "pods", items, tracker)
108+
})
109+
110+
req := httptest.NewRequest("GET", "/test", nil)
111+
resp, err := app.Test(req, -1)
112+
require.NoError(t, err)
113+
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
114+
115+
var result map[string]interface{}
116+
err = json.NewDecoder(resp.Body).Decode(&result)
117+
require.NoError(t, err)
118+
assert.Contains(t, result, "clusterErrors")
119+
})
120+
121+
t.Run("handles empty items", func(t *testing.T) {
122+
app := fiber.New()
123+
app.Get("/test", func(c *fiber.Ctx) error {
124+
items := []string{}
125+
return respondClusterResources(c, "pods", items, nil)
126+
})
127+
128+
req := httptest.NewRequest("GET", "/test", nil)
129+
resp, err := app.Test(req, -1)
130+
require.NoError(t, err)
131+
132+
var result map[string]interface{}
133+
err = json.NewDecoder(resp.Body).Decode(&result)
134+
require.NoError(t, err)
135+
pods := result["pods"].([]interface{})
136+
assert.Len(t, pods, 0)
137+
})
138+
}

0 commit comments

Comments
 (0)