diff --git a/cmd/prism-loadtest/server/executor_test.go b/cmd/prism-loadtest/server/executor_test.go new file mode 100644 index 00000000..1aa24cf8 --- /dev/null +++ b/cmd/prism-loadtest/server/executor_test.go @@ -0,0 +1,651 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// TestNewTestExecutor verifies executor initialization +func TestNewTestExecutor(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 100, + RegisterPct: 50, + EnumeratePct: 30, + MulticastPct: 20, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-123", config, backendConfig) + + if executor == nil { + t.Fatal("expected non-nil executor") + } + + if executor.testID != "test-123" { + t.Errorf("expected testID 'test-123', got '%s'", executor.testID) + } + + if executor.status != "running" { + t.Errorf("expected status 'running', got '%s'", executor.status) + } + + if executor.registerMetrics == nil { + t.Error("expected registerMetrics to be initialized") + } + + if executor.enumerateMetrics == nil { + t.Error("expected enumerateMetrics to be initialized") + } + + if executor.multicastMetrics == nil { + t.Error("expected multicastMetrics to be initialized") + } + + if executor.stopChan == nil { + t.Error("expected stopChan to be initialized") + } +} + +// TestExecutorStop verifies stopping a test +func TestExecutorStop(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-stop", config, backendConfig) + + // Stop should be idempotent + executor.Stop() + if !executor.stopped.Load() { + t.Error("expected stopped to be true after Stop()") + } + + // Second stop should not panic + executor.Stop() + if !executor.stopped.Load() { + t.Error("expected stopped to remain true after second Stop()") + } +} + +// TestExecutorGetInfo verifies getting test information +func TestExecutorGetInfo(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 100, + RegisterPct: 50, + EnumeratePct: 30, + MulticastPct: 20, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-info", config, backendConfig) + + info := executor.GetInfo() + + if info.TestID != "test-info" { + t.Errorf("expected testID 'test-info', got '%s'", info.TestID) + } + + if info.Status != "running" { + t.Errorf("expected status 'running', got '%s'", info.Status) + } + + if info.Config.Mix != "mixed" { + t.Errorf("expected mix 'mixed', got '%s'", info.Config.Mix) + } + + if info.Config.Rate != 100 { + t.Errorf("expected rate 100, got %d", info.Config.Rate) + } + + if info.StartedAt.IsZero() { + t.Error("expected non-zero StartedAt time") + } + + if info.StoppedAt != nil { + t.Error("expected StoppedAt to be nil for running test") + } +} + +// TestCollectMetrics verifies metrics collection and aggregation +func TestCollectMetrics(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-metrics", config, backendConfig) + + // Record some metrics + executor.registerMetrics.RecordSuccess(10 * time.Millisecond) + executor.registerMetrics.RecordSuccess(20 * time.Millisecond) + executor.enumerateMetrics.RecordSuccess(5 * time.Millisecond) + executor.multicastMetrics.RecordFailure() + + // Give the executor a brief moment to initialize + time.Sleep(10 * time.Millisecond) + + metrics := executor.collectMetrics() + + if metrics.TotalRequests != 4 { + t.Errorf("expected 4 total requests, got %d", metrics.TotalRequests) + } + + if metrics.FailedRequests != 1 { + t.Errorf("expected 1 failed request, got %d", metrics.FailedRequests) + } + + if metrics.SuccessRate < 70.0 || metrics.SuccessRate > 80.0 { + t.Errorf("expected success rate around 75%%, got %.2f%%", metrics.SuccessRate) + } + + if metrics.Throughput <= 0 { + t.Error("expected positive throughput") + } + + if metrics.Timestamp.IsZero() { + t.Error("expected non-zero timestamp") + } +} + +// TestCollectMetricsEmpty verifies metrics collection with no data +func TestCollectMetricsEmpty(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-empty", config, backendConfig) + + metrics := executor.collectMetrics() + + if metrics.TotalRequests != 0 { + t.Errorf("expected 0 total requests, got %d", metrics.TotalRequests) + } + + if metrics.SuccessRate != 100.0 { + t.Errorf("expected 100%% success rate with no requests, got %.2f%%", metrics.SuccessRate) + } + + if metrics.Throughput != 0 { + t.Errorf("expected 0 throughput with no requests, got %.2f", metrics.Throughput) + } +} + +// TestAddClient verifies adding WebSocket clients +func TestAddClient(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-client", config, backendConfig) + + // Create mock WebSocket server and client + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("failed to upgrade: %v", err) + } + executor.AddClient(conn) + })) + defer server.Close() + + // Connect a client + wsURL := "ws" + server.URL[4:] // Convert http:// to ws:// + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer conn.Close() + + // Give the server time to process + time.Sleep(100 * time.Millisecond) + + executor.clientsMu.RLock() + clientCount := len(executor.clients) + executor.clientsMu.RUnlock() + + if clientCount != 1 { + t.Errorf("expected 1 client, got %d", clientCount) + } +} + +// TestBroadcast verifies broadcasting to WebSocket clients +func TestBroadcast(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-broadcast", config, backendConfig) + + // Create mock WebSocket server and client + messageReceived := make(chan MetricsMessage, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("failed to upgrade: %v", err) + } + executor.AddClient(conn) + })) + defer server.Close() + + // Connect a client + wsURL := "ws" + server.URL[4:] + clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer clientConn.Close() + + // Give the server time to add the client + time.Sleep(100 * time.Millisecond) + + // Start reading in a goroutine + go func() { + _, msg, err := clientConn.ReadMessage() + if err == nil { + var metrics MetricsMessage + if err := json.Unmarshal(msg, &metrics); err == nil { + messageReceived <- metrics + } + } + }() + + // Give reader goroutine time to start + time.Sleep(50 * time.Millisecond) + + // Broadcast a message + testMetrics := MetricsMessage{ + Timestamp: time.Now(), + Throughput: 123.45, + LatencyP50: 1.5, + LatencyP95: 3.0, + LatencyP99: 5.0, + SuccessRate: 99.5, + TotalRequests: 1000, + FailedRequests: 5, + } + + executor.broadcast(testMetrics) + + // Wait for message with timeout + select { + case received := <-messageReceived: + if received.Throughput != 123.45 { + t.Errorf("expected throughput 123.45, got %.2f", received.Throughput) + } + if received.TotalRequests != 1000 { + t.Errorf("expected 1000 total requests, got %d", received.TotalRequests) + } + case <-time.After(2 * time.Second): + t.Error("timeout waiting for broadcast message") + } +} + +// TestBroadcastNoClients verifies broadcasting with no clients doesn't panic +func TestBroadcastNoClients(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-no-clients", config, backendConfig) + + testMetrics := MetricsMessage{ + Timestamp: time.Now(), + Throughput: 100.0, + SuccessRate: 100.0, + } + + // Should not panic + executor.broadcast(testMetrics) +} + +// TestCloseAllClients verifies closing all WebSocket clients +func TestCloseAllClients(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-close", config, backendConfig) + + // Create mock WebSocket server and clients + clientClosed := make(chan bool, 2) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("failed to upgrade: %v", err) + } + executor.AddClient(conn) + + // Wait for close + go func() { + for { + _, _, err := conn.ReadMessage() + if err != nil { + clientClosed <- true + return + } + } + }() + })) + defer server.Close() + + // Connect two clients + wsURL := "ws" + server.URL[4:] + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial client 1: %v", err) + } + defer conn1.Close() + + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial client 2: %v", err) + } + defer conn2.Close() + + // Give the server time to process + time.Sleep(100 * time.Millisecond) + + executor.clientsMu.RLock() + clientCount := len(executor.clients) + executor.clientsMu.RUnlock() + + if clientCount != 2 { + t.Errorf("expected 2 clients before close, got %d", clientCount) + } + + // Close all clients + executor.closeAllClients() + + executor.clientsMu.RLock() + clientCount = len(executor.clients) + executor.clientsMu.RUnlock() + + if clientCount != 0 { + t.Errorf("expected 0 clients after close, got %d", clientCount) + } + + // Wait for both clients to detect closure + timeout := time.After(2 * time.Second) + closedCount := 0 + for closedCount < 2 { + select { + case <-clientClosed: + closedCount++ + case <-timeout: + t.Errorf("timeout waiting for clients to close (closed %d/2)", closedCount) + return + } + } +} + +// TestBroadcastRemovesDeadClient verifies dead clients are removed during broadcast +func TestBroadcastRemovesDeadClient(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-dead-client", config, backendConfig) + + // Create mock WebSocket server and client + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("failed to upgrade: %v", err) + } + executor.AddClient(conn) + })) + defer server.Close() + + // Connect a client + wsURL := "ws" + server.URL[4:] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + + // Give the server time to process + time.Sleep(100 * time.Millisecond) + + executor.clientsMu.RLock() + clientCount := len(executor.clients) + executor.clientsMu.RUnlock() + + if clientCount != 1 { + t.Errorf("expected 1 client, got %d", clientCount) + } + + // Close the client connection + conn.Close() + + // Give time for close to propagate and try multiple broadcasts + // The first write might succeed due to buffering, but subsequent ones should fail + time.Sleep(200 * time.Millisecond) + + // Broadcast multiple times to ensure dead client is detected + testMetrics := MetricsMessage{ + Timestamp: time.Now(), + Throughput: 100.0, + SuccessRate: 100.0, + } + + for i := 0; i < 3; i++ { + executor.broadcast(testMetrics) + time.Sleep(50 * time.Millisecond) + } + + executor.clientsMu.Lock() + clientCount = len(executor.clients) + executor.clientsMu.Unlock() + + if clientCount != 0 { + t.Errorf("expected dead client to be removed after multiple broadcasts, got %d clients", clientCount) + } +} + +// TestBroadcastMetricsContext verifies the broadcast metrics goroutine respects context +func TestBroadcastMetricsContext(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "1s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-context", config, backendConfig) + + // Create a short-lived context + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + // Start broadcasting + done := make(chan bool) + go func() { + executor.broadcastMetrics(ctx) + done <- true + }() + + // Should finish after context timeout + select { + case <-done: + // Success - goroutine exited after context cancellation + case <-time.After(2 * time.Second): + t.Error("broadcastMetrics did not respect context timeout") + } +} + +// TestBroadcastMetricsStopChan verifies the broadcast metrics goroutine respects stop channel +func TestBroadcastMetricsStopChan(t *testing.T) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("test-stopchan", config, backendConfig) + + ctx := context.Background() + + // Start broadcasting + done := make(chan bool) + go func() { + executor.broadcastMetrics(ctx) + done <- true + }() + + // Stop the executor + time.Sleep(100 * time.Millisecond) + executor.Stop() + + // Should finish quickly after stop + select { + case <-done: + // Success - goroutine exited after stop + case <-time.After(2 * time.Second): + t.Error("broadcastMetrics did not respect stop channel") + } +} + +// Benchmark tests +func BenchmarkCollectMetrics(b *testing.B) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 100, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("bench", config, backendConfig) + + // Record some metrics + for i := 0; i < 100; i++ { + executor.registerMetrics.RecordSuccess(time.Duration(i) * time.Millisecond) + executor.enumerateMetrics.RecordSuccess(time.Duration(i) * time.Millisecond) + executor.multicastMetrics.RecordSuccess(time.Duration(i) * time.Millisecond) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + executor.collectMetrics() + } +} + +func BenchmarkBroadcast(b *testing.B) { + config := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 100, + } + + backendConfig := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + executor := NewTestExecutor("bench", config, backendConfig) + + testMetrics := MetricsMessage{ + Timestamp: time.Now(), + Throughput: 123.45, + LatencyP50: 1.5, + SuccessRate: 99.5, + TotalRequests: 1000, + FailedRequests: 5, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + executor.broadcast(testMetrics) + } +} diff --git a/cmd/prism-loadtest/server/integration_test.go b/cmd/prism-loadtest/server/integration_test.go new file mode 100644 index 00000000..e7d0250a --- /dev/null +++ b/cmd/prism-loadtest/server/integration_test.go @@ -0,0 +1,673 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// TestIntegrationStartStopTest tests the complete lifecycle of starting and stopping a test +func TestIntegrationStartStopTest(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Start a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "10s", + Rate: 5, + RegisterPct: 50, + EnumeratePct: 30, + MulticastPct: 20, + } + + body, _ := json.Marshal(testConfig) + resp, err := http.Post(testServer.URL+"/api/loadtest/start", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to start test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Errorf("expected status 201, got %d", resp.StatusCode) + } + + var startResp TestInfo + if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { + t.Fatalf("failed to decode start response: %v", err) + } + + testID := startResp.TestID + if testID == "" { + t.Fatal("expected non-empty test ID") + } + + // Wait a bit for test to run + time.Sleep(2 * time.Second) + + // Get test status + resp, err = http.Get(testServer.URL + "/api/loadtest/status/" + testID) + if err != nil { + t.Fatalf("failed to get status: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + var statusResp TestInfo + if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil { + t.Fatalf("failed to decode status response: %v", err) + } + + if statusResp.Status != "running" { + t.Errorf("expected status 'running', got '%s'", statusResp.Status) + } + + // Stop the test + resp, err = http.Post(testServer.URL+"/api/loadtest/stop/"+testID, "application/json", nil) + if err != nil { + t.Fatalf("failed to stop test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + // Verify test is stopped + time.Sleep(500 * time.Millisecond) + + server.testsMu.RLock() + executor := server.tests[testID] + server.testsMu.RUnlock() + + if !executor.stopped.Load() { + t.Error("expected test to be stopped") + } +} + +// TestIntegrationWebSocketStreaming tests WebSocket metrics streaming +func TestIntegrationWebSocketStreaming(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Start a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "10s", + Rate: 5, + } + + body, _ := json.Marshal(testConfig) + resp, err := http.Post(testServer.URL+"/api/loadtest/start", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to start test: %v", err) + } + defer resp.Body.Close() + + var startResp TestInfo + if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { + t.Fatalf("failed to decode start response: %v", err) + } + + testID := startResp.TestID + + // Connect WebSocket + wsURL := "ws" + testServer.URL[4:] + "/ws/metrics/" + testID + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to connect WebSocket: %v", err) + } + defer conn.Close() + + // Read metrics messages + messagesReceived := 0 + timeout := time.After(5 * time.Second) + + for messagesReceived < 3 { + select { + case <-timeout: + t.Fatalf("timeout waiting for metrics messages (received %d)", messagesReceived) + default: + } + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read WebSocket message: %v", err) + } + + var metrics MetricsMessage + if err := json.Unmarshal(msg, &metrics); err != nil { + t.Fatalf("failed to unmarshal metrics: %v", err) + } + + // Verify metrics structure + if metrics.Timestamp.IsZero() { + t.Error("expected non-zero timestamp") + } + + if metrics.SuccessRate < 0 || metrics.SuccessRate > 100 { + t.Errorf("expected success rate 0-100, got %.2f", metrics.SuccessRate) + } + + messagesReceived++ + } + + // Stop the test + server.testsMu.RLock() + executor := server.tests[testID] + server.testsMu.RUnlock() + executor.Stop() +} + +// TestIntegrationMultipleWebSocketClients tests multiple WebSocket clients receiving metrics +func TestIntegrationMultipleWebSocketClients(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Start a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "10s", + Rate: 5, + } + + body, _ := json.Marshal(testConfig) + resp, err := http.Post(testServer.URL+"/api/loadtest/start", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to start test: %v", err) + } + defer resp.Body.Close() + + var startResp TestInfo + json.NewDecoder(resp.Body).Decode(&startResp) + testID := startResp.TestID + + // Connect multiple WebSocket clients + numClients := 3 + var wg sync.WaitGroup + errors := make(chan error, numClients) + + for i := 0; i < numClients; i++ { + wg.Add(1) + go func(clientID int) { + defer wg.Done() + + wsURL := "ws" + testServer.URL[4:] + "/ws/metrics/" + testID + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + errors <- fmt.Errorf("client %d failed to connect: %v", clientID, err) + return + } + defer conn.Close() + + // Read at least one message + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil { + errors <- fmt.Errorf("client %d failed to read: %v", clientID, err) + return + } + + var metrics MetricsMessage + if err := json.Unmarshal(msg, &metrics); err != nil { + errors <- fmt.Errorf("client %d failed to unmarshal: %v", clientID, err) + return + } + + if metrics.Timestamp.IsZero() { + errors <- fmt.Errorf("client %d received invalid metrics", clientID) + return + } + }(i) + } + + // Wait for all clients + wg.Wait() + close(errors) + + // Check for errors + for err := range errors { + t.Error(err) + } + + // Stop the test + server.testsMu.RLock() + executor := server.tests[testID] + server.testsMu.RUnlock() + executor.Stop() +} + +// TestIntegrationConcurrentTests tests running multiple tests concurrently +func TestIntegrationConcurrentTests(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Start multiple tests concurrently with longer duration + numTests := 3 + testIDs := make([]string, numTests) + var wg sync.WaitGroup + var mu sync.Mutex + startErrors := make([]error, 0) + + for i := 0; i < numTests; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + + testConfig := TestConfig{ + Mix: "mixed", + Duration: "30s", // Longer duration so tests are running when we check + Rate: 5, + } + + body, _ := json.Marshal(testConfig) + resp, err := http.Post(testServer.URL+"/api/loadtest/start", "application/json", bytes.NewReader(body)) + if err != nil { + mu.Lock() + startErrors = append(startErrors, fmt.Errorf("test %d: %v", index, err)) + mu.Unlock() + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + mu.Lock() + startErrors = append(startErrors, fmt.Errorf("test %d: unexpected status %d", index, resp.StatusCode)) + mu.Unlock() + return + } + + var startResp TestInfo + if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { + mu.Lock() + startErrors = append(startErrors, fmt.Errorf("test %d decode: %v", index, err)) + mu.Unlock() + return + } + + testIDs[index] = startResp.TestID + t.Logf("Successfully started test %d with ID: %s", index, startResp.TestID) + }(i) + } + + wg.Wait() + + // Check if any starts failed + if len(startErrors) > 0 { + for _, err := range startErrors { + t.Errorf("Start error: %v", err) + } + t.Fatalf("Failed to start %d test(s)", len(startErrors)) + } + + // Verify all tests are running - wait a bit to ensure all executor.Run() goroutines have started + time.Sleep(1000 * time.Millisecond) + + resp, err := http.Get(testServer.URL + "/api/loadtest/list") + if err != nil { + t.Fatalf("failed to list tests: %v", err) + } + defer resp.Body.Close() + + var tests []TestInfo + if err := json.NewDecoder(resp.Body).Decode(&tests); err != nil { + t.Fatalf("failed to decode list response: %v", err) + } + + // Count only running tests + runningTests := 0 + for _, test := range tests { + t.Logf("Test %s: status=%s", test.TestID, test.Status) + if test.Status == "running" { + runningTests++ + } + } + + if runningTests != numTests { + t.Errorf("expected %d running tests, got %d (total tests: %d)", numTests, runningTests, len(tests)) + } + + // Stop all tests + for _, testID := range testIDs { + if testID != "" { + http.Post(testServer.URL+"/api/loadtest/stop/"+testID, "application/json", nil) + } + } +} + +// TestIntegrationServerShutdown tests graceful server shutdown +func TestIntegrationServerShutdown(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + + // Start a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 5, + } + + body, _ := json.Marshal(testConfig) + resp, err := http.Post(testServer.URL+"/api/loadtest/start", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to start test: %v", err) + } + defer resp.Body.Close() + + var startResp TestInfo + json.NewDecoder(resp.Body).Decode(&startResp) + + // Wait for test to start + time.Sleep(500 * time.Millisecond) + + // Shutdown server + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil && err != http.ErrServerClosed { + t.Errorf("unexpected shutdown error: %v", err) + } + + // Close the test server + testServer.Close() + + // Verify all tests are stopped + server.testsMu.RLock() + for _, executor := range server.tests { + if !executor.stopped.Load() { + t.Error("expected all tests to be stopped after shutdown") + } + } + server.testsMu.RUnlock() +} + +// TestIntegrationDashboardAccess tests accessing the dashboard +func TestIntegrationDashboardAccess(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Access dashboard via root path + resp, err := http.Get(testServer.URL + "/") + if err != nil { + t.Fatalf("failed to access dashboard: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "text/html" { + t.Errorf("expected Content-Type 'text/html', got '%s'", contentType) + } + + // Access dashboard via /dashboard path + resp, err = http.Get(testServer.URL + "/dashboard") + if err != nil { + t.Fatalf("failed to access dashboard: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} + +// TestIntegrationCORSHeaders tests CORS headers on API endpoints +func TestIntegrationCORSHeaders(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Make OPTIONS request + req, err := http.NewRequest("OPTIONS", testServer.URL+"/api/loadtest/list", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to make OPTIONS request: %v", err) + } + defer resp.Body.Close() + + // Verify CORS headers + if resp.Header.Get("Access-Control-Allow-Origin") != "*" { + t.Error("expected CORS Allow-Origin header") + } + + if resp.Header.Get("Access-Control-Allow-Methods") == "" { + t.Error("expected CORS Allow-Methods header") + } + + if resp.Header.Get("Access-Control-Allow-Headers") == "" { + t.Error("expected CORS Allow-Headers header") + } +} + +// TestIntegrationWebSocketReconnect tests WebSocket reconnection +func TestIntegrationWebSocketReconnect(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + // Start a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "30s", + Rate: 5, + } + + body, _ := json.Marshal(testConfig) + resp, err := http.Post(testServer.URL+"/api/loadtest/start", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to start test: %v", err) + } + defer resp.Body.Close() + + var startResp TestInfo + json.NewDecoder(resp.Body).Decode(&startResp) + testID := startResp.TestID + + // Connect first WebSocket client + wsURL := "ws" + testServer.URL[4:] + "/ws/metrics/" + testID + conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to connect first WebSocket: %v", err) + } + + // Read a message + conn1.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, err = conn1.ReadMessage() + if err != nil { + t.Fatalf("failed to read from first connection: %v", err) + } + + // Close first connection + conn1.Close() + + // Wait a bit + time.Sleep(500 * time.Millisecond) + + // Connect second WebSocket client (reconnect) + conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to reconnect WebSocket: %v", err) + } + defer conn2.Close() + + // Should be able to read from second connection + conn2.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, _, err = conn2.ReadMessage() + if err != nil { + t.Fatalf("failed to read from second connection: %v", err) + } + + // Stop the test + server.testsMu.RLock() + executor := server.tests[testID] + server.testsMu.RUnlock() + executor.Stop() +} + +// TestIntegrationErrorHandling tests various error scenarios +func TestIntegrationErrorHandling(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + testServer := httptest.NewServer(server.router) + defer testServer.Close() + + tests := []struct { + name string + method string + path string + body string + expectedStatus int + }{ + { + name: "invalid JSON in start request", + method: "POST", + path: "/api/loadtest/start", + body: "invalid json", + expectedStatus: http.StatusBadRequest, + }, + { + name: "non-existent test status", + method: "GET", + path: "/api/loadtest/status/nonexistent", + body: "", + expectedStatus: http.StatusNotFound, + }, + { + name: "non-existent test stop", + method: "POST", + path: "/api/loadtest/stop/nonexistent", + body: "", + expectedStatus: http.StatusNotFound, + }, + { + name: "WebSocket to non-existent test", + method: "GET", + path: "/ws/metrics/nonexistent", + body: "", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req *http.Request + var err error + + if tt.body != "" { + req, err = http.NewRequest(tt.method, testServer.URL+tt.path, bytes.NewBufferString(tt.body)) + } else { + req, err = http.NewRequest(tt.method, testServer.URL+tt.path, nil) + } + + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, resp.StatusCode) + } + }) + } +} diff --git a/cmd/prism-loadtest/server/server_test.go b/cmd/prism-loadtest/server/server_test.go new file mode 100644 index 00000000..19241988 --- /dev/null +++ b/cmd/prism-loadtest/server/server_test.go @@ -0,0 +1,603 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestNewServer verifies server initialization +func TestNewServer(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + if server == nil { + t.Fatal("expected non-nil server") + } + + if server.port != 8091 { + t.Errorf("expected port 8091, got %d", server.port) + } + + if server.tests == nil { + t.Error("expected tests map to be initialized") + } + + if server.router == nil { + t.Error("expected router to be initialized") + } +} + +// TestHandleStartTest tests the start test endpoint +func TestHandleStartTest(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create test request + testConfig := TestConfig{ + Mix: "mixed", + Duration: "5s", + Rate: 10, + RegisterPct: 50, + EnumeratePct: 30, + MulticastPct: 20, + } + + body, err := json.Marshal(testConfig) + if err != nil { + t.Fatalf("failed to marshal test config: %v", err) + } + + req := httptest.NewRequest("POST", "/api/loadtest/start", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleStartTest(w, req) + + // Check response + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d", w.Code) + } + + var response TestInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.TestID == "" { + t.Error("expected non-empty test ID") + } + + if response.Status != "running" { + t.Errorf("expected status 'running', got '%s'", response.Status) + } + + if response.Config.Mix != "mixed" { + t.Errorf("expected mix 'mixed', got '%s'", response.Config.Mix) + } + + // Verify test was stored + server.testsMu.RLock() + _, ok := server.tests[response.TestID] + server.testsMu.RUnlock() + + if !ok { + t.Error("expected test to be stored in server.tests") + } + + // Stop the test immediately to prevent it from running + server.testsMu.RLock() + executor := server.tests[response.TestID] + server.testsMu.RUnlock() + executor.Stop() +} + +// TestHandleStartTestInvalidJSON tests error handling for invalid JSON +func TestHandleStartTestInvalidJSON(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + req := httptest.NewRequest("POST", "/api/loadtest/start", bytes.NewReader([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleStartTest(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + + if response["error"] != "invalid_request" { + t.Errorf("expected error 'invalid_request', got '%v'", response["error"]) + } +} + +// TestHandleStartTestDefaults tests default values +func TestHandleStartTestDefaults(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create test request with minimal config + testConfig := TestConfig{} + + body, err := json.Marshal(testConfig) + if err != nil { + t.Fatalf("failed to marshal test config: %v", err) + } + + req := httptest.NewRequest("POST", "/api/loadtest/start", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.handleStartTest(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d", w.Code) + } + + var response TestInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Verify defaults were applied + if response.Config.Mix != "mixed" { + t.Errorf("expected default mix 'mixed', got '%s'", response.Config.Mix) + } + + if response.Config.Duration != "60s" { + t.Errorf("expected default duration '60s', got '%s'", response.Config.Duration) + } + + if response.Config.Rate != 100 { + t.Errorf("expected default rate 100, got %d", response.Config.Rate) + } + + // Stop the test + server.testsMu.RLock() + executor := server.tests[response.TestID] + server.testsMu.RUnlock() + executor.Stop() +} + +// TestHandleStopTest tests stopping a running test +func TestHandleStopTest(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create and start a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + executor := NewTestExecutor("test-123", testConfig, config) + server.testsMu.Lock() + server.tests["test-123"] = executor + server.testsMu.Unlock() + + // Stop the test + req := httptest.NewRequest("POST", "/api/loadtest/stop/test-123", nil) + w := httptest.NewRecorder() + + // Mock mux.Vars by using the router + req = httptest.NewRequest("POST", "/api/loadtest/stop/test-123", nil) + w = httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response TestInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.TestID != "test-123" { + t.Errorf("expected test ID 'test-123', got '%s'", response.TestID) + } +} + +// TestHandleStopTestNotFound tests stopping a non-existent test +func TestHandleStopTestNotFound(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + req := httptest.NewRequest("POST", "/api/loadtest/stop/nonexistent", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + + if response["error"] != "not_found" { + t.Errorf("expected error 'not_found', got '%v'", response["error"]) + } +} + +// TestHandleGetStatus tests getting test status +func TestHandleGetStatus(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create a test + testConfig := TestConfig{ + Mix: "mixed", + Duration: "60s", + Rate: 10, + } + + executor := NewTestExecutor("test-456", testConfig, config) + server.testsMu.Lock() + server.tests["test-456"] = executor + server.testsMu.Unlock() + + // Get status + req := httptest.NewRequest("GET", "/api/loadtest/status/test-456", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response TestInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response.TestID != "test-456" { + t.Errorf("expected test ID 'test-456', got '%s'", response.TestID) + } + + if response.Status != "running" { + t.Errorf("expected status 'running', got '%s'", response.Status) + } + + executor.Stop() +} + +// TestHandleGetStatusNotFound tests getting status for non-existent test +func TestHandleGetStatusNotFound(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + req := httptest.NewRequest("GET", "/api/loadtest/status/nonexistent", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } +} + +// TestHandleListTests tests listing all tests +func TestHandleListTests(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create multiple tests + testConfig1 := TestConfig{Mix: "mixed", Duration: "60s", Rate: 10} + testConfig2 := TestConfig{Mix: "register", Duration: "30s", Rate: 20} + + executor1 := NewTestExecutor("test-1", testConfig1, config) + executor2 := NewTestExecutor("test-2", testConfig2, config) + + server.testsMu.Lock() + server.tests["test-1"] = executor1 + server.tests["test-2"] = executor2 + server.testsMu.Unlock() + + // List tests + req := httptest.NewRequest("GET", "/api/loadtest/list", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response []TestInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(response) != 2 { + t.Errorf("expected 2 tests, got %d", len(response)) + } + + // Stop tests + executor1.Stop() + executor2.Stop() +} + +// TestHandleListTestsEmpty tests listing when no tests exist +func TestHandleListTestsEmpty(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + req := httptest.NewRequest("GET", "/api/loadtest/list", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var response []TestInfo + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response == nil { + t.Error("expected empty array, got nil") + } +} + +// TestHandleDashboard tests dashboard endpoint +func TestHandleDashboard(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + req := httptest.NewRequest("GET", "/dashboard", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "text/html" { + t.Errorf("expected Content-Type 'text/html', got '%s'", contentType) + } + + if w.Body.Len() == 0 { + t.Error("expected non-empty dashboard HTML") + } +} + +// TestCORSMiddleware tests CORS headers +func TestCORSMiddleware(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + req := httptest.NewRequest("OPTIONS", "/api/loadtest/list", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200 for OPTIONS, got %d", w.Code) + } + + headers := w.Header() + if headers.Get("Access-Control-Allow-Origin") != "*" { + t.Error("expected CORS Allow-Origin header") + } + + if headers.Get("Access-Control-Allow-Methods") == "" { + t.Error("expected CORS Allow-Methods header") + } +} + +// TestShutdown tests graceful shutdown +func TestShutdown(t *testing.T) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create a test + testConfig := TestConfig{Mix: "mixed", Duration: "60s", Rate: 10} + executor := NewTestExecutor("test-789", testConfig, config) + + server.testsMu.Lock() + server.tests["test-789"] = executor + server.testsMu.Unlock() + + // Start the server in background (but don't actually listen, just initialize) + server.httpServer = &http.Server{ + Addr: ":8091", + Handler: server.router, + } + + // Shutdown should not error even without actual server running + ctx := httptest.NewRequest("GET", "/", nil).Context() + + // Shutdown will stop all tests + if err := server.Shutdown(ctx); err != nil && err != http.ErrServerClosed { + t.Errorf("unexpected shutdown error: %v", err) + } + + // Verify test was stopped + if !executor.stopped.Load() { + t.Error("expected test to be stopped after shutdown") + } +} + +// TestSendJSON tests JSON response helper +func TestSendJSON(t *testing.T) { + data := map[string]interface{}{ + "key": "value", + "count": 42, + } + + w := httptest.NewRecorder() + sendJSON(w, data, http.StatusOK) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("expected Content-Type 'application/json', got '%s'", contentType) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response["key"] != "value" { + t.Errorf("expected key 'value', got '%v'", response["key"]) + } + + if response["count"] != float64(42) { + t.Errorf("expected count 42, got %v", response["count"]) + } +} + +// TestSendError tests error response helper +func TestSendError(t *testing.T) { + w := httptest.NewRecorder() + sendError(w, "test_error", "Something went wrong", http.StatusInternalServerError) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if response["error"] != "test_error" { + t.Errorf("expected error 'test_error', got '%v'", response["error"]) + } + + if response["message"] != "Something went wrong" { + t.Errorf("expected message 'Something went wrong', got '%v'", response["message"]) + } + + if response["code"] != float64(500) { + t.Errorf("expected code 500, got %v", response["code"]) + } +} + +// Benchmark tests +func BenchmarkHandleStartTest(b *testing.B) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + testConfig := TestConfig{ + Mix: "mixed", + Duration: "1s", + Rate: 10, + } + + body, _ := json.Marshal(testConfig) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("POST", "/api/loadtest/start", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + server.handleStartTest(w, req) + + // Clean up + var response TestInfo + json.NewDecoder(w.Body).Decode(&response) + server.testsMu.RLock() + executor := server.tests[response.TestID] + server.testsMu.RUnlock() + if executor != nil { + executor.Stop() + } + } +} + +func BenchmarkHandleListTests(b *testing.B) { + config := BackendConfig{ + RedisAddr: "localhost:6379", + NATSServers: []string{"nats://localhost:4222"}, + } + + server := NewServer(8091, config) + + // Create 10 tests + for i := 0; i < 10; i++ { + testConfig := TestConfig{Mix: "mixed", Duration: "60s", Rate: 10} + executor := NewTestExecutor(time.Now().String(), testConfig, config) + server.testsMu.Lock() + server.tests[executor.testID] = executor + server.testsMu.Unlock() + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", "/api/loadtest/list", nil) + w := httptest.NewRecorder() + server.router.ServeHTTP(w, req) + } +}