diff --git a/backend/main.go b/backend/main.go index a452e79..95f8162 100644 --- a/backend/main.go +++ b/backend/main.go @@ -31,6 +31,20 @@ func main() { srv.HandleBotAction(w, r) return } + // Check if this is a bot management endpoint: /api/rooms/{roomId}/bots or /api/rooms/{roomId}/bots/{botId} + if strings.Contains(r.URL.Path, "/bots") { + // DELETE /api/rooms/{roomId}/bots/{botId} - count path segments to distinguish + // Path format: /api/rooms/{roomId}/bots/{botId} = 6 segments + // Path format: /api/rooms/{roomId}/bots = 5 segments + pathSegments := strings.Split(r.URL.Path, "/") + if len(pathSegments) == 6 || (r.Method == http.MethodOptions && len(pathSegments) == 6) { + srv.HandleRemoveBot(w, r) + return + } + // POST /api/rooms/{roomId}/bots + srv.HandleAddBot(w, r) + return + } // Check if this is a reset endpoint: /api/rooms/{roomId}/reset if strings.HasSuffix(r.URL.Path, "/reset") { srv.HandleResetGame(w, r) diff --git a/backend/pkg/server/http_handlers.go b/backend/pkg/server/http_handlers.go index 5b35442..17e0af6 100644 --- a/backend/pkg/server/http_handlers.go +++ b/backend/pkg/server/http_handlers.go @@ -1,14 +1,17 @@ package server import ( + "bytes" "encoding/json" "fmt" "go-ws-server/pkg/server/constants" "go-ws-server/pkg/server/game_maps" "go-ws-server/pkg/server/game_objects" "go-ws-server/pkg/server/types" + "io" "log" "net/http" + "os" "path/filepath" "strings" "time" @@ -157,6 +160,54 @@ type ResetGameHTTPResponse struct { Error string `json:"error,omitempty"` } +// AddBotRequest represents the request to add a bot to a game room +type AddBotRequest struct { + BotType string `json:"botType"` // "rule_based" or "neural_network" + ModelID string `json:"modelId,omitempty"` // For neural_network bots + Generation *int `json:"generation,omitempty"` // Alternative to modelId + PlayerName string `json:"playerName,omitempty"` // Bot's display name (default: "Bot") +} + +// AddBotResponse represents the response when adding a bot +type AddBotResponse struct { + Success bool `json:"success"` + BotID string `json:"botId,omitempty"` + Error string `json:"error,omitempty"` +} + +// RemoveBotResponse represents the response when removing a bot +type RemoveBotResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// botServiceSpawnRequest is the internal request format for Bot Service +type botServiceSpawnRequest struct { + RoomCode string `json:"room_code"` + RoomPassword string `json:"room_password"` + BotConfig botServiceBotConfig `json:"bot_config"` +} + +type botServiceBotConfig struct { + BotType string `json:"bot_type"` + ModelID string `json:"model_id,omitempty"` + Generation *int `json:"generation,omitempty"` + PlayerName string `json:"player_name"` +} + +// botServiceSpawnResponse is the response format from Bot Service +type botServiceSpawnResponse struct { + Success bool `json:"success"` + BotID string `json:"bot_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// botServiceRemoveResponse is the response format from Bot Service for remove operations +type botServiceRemoveResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + // HandleCreateGame handles HTTP requests to create a new game func (s *Server) HandleCreateGame(w http.ResponseWriter, r *http.Request) { // Set response headers @@ -759,6 +810,27 @@ func extractBotActionPathParams(path string) (roomID string, playerID string, ok return "", "", false } +// extractAddBotPathParams extracts roomId from /api/rooms/{roomId}/bots +func extractAddBotPathParams(path string) (roomID string, ok bool) { + prefix := "/api/rooms/" + suffix := "/bots" + if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) { + return "", false + } + roomID = strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix) + return roomID, roomID != "" +} + +// extractRemoveBotPathParams extracts roomId and botId from /api/rooms/{roomId}/bots/{botId} +func extractRemoveBotPathParams(path string) (roomID string, botID string, ok bool) { + parts := strings.Split(path, "/") + // parts = ["", "api", "rooms", "{roomId}", "bots", "{botId}"] + if len(parts) == 6 && parts[1] == "api" && parts[2] == "rooms" && parts[4] == "bots" { + return parts[3], parts[5], parts[3] != "" && parts[5] != "" + } + return "", "", false +} + // HandleBotAction handles HTTP requests to submit bot actions func (s *Server) HandleBotAction(w http.ResponseWriter, r *http.Request) { // Set response headers @@ -1026,3 +1098,403 @@ func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { Timestamp: time.Now().UTC().Format(time.RFC3339), }) } + +// getBotServiceURL returns the Bot Service URL from environment or default +func getBotServiceURL() string { + url := os.Getenv("BOT_SERVICE_URL") + if url == "" { + return "http://localhost:8080" + } + return url +} + +// botServiceClient is a reusable HTTP client for Bot Service requests +// This enables connection pooling and HTTP keep-alive +var botServiceClient = &http.Client{Timeout: 10 * time.Second} + +// HandleAddBot handles POST /api/rooms/{roomId}/bots +func (s *Server) HandleAddBot(w http.ResponseWriter, r *http.Request) { + // Set response headers + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Player-Token") + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Only allow POST requests + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Method not allowed", + }) + return + } + + // Extract roomId from URL path: /api/rooms/{roomId}/bots + roomID, ok := extractAddBotPathParams(r.URL.Path) + if !ok { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Invalid URL format. Expected: /api/rooms/{roomId}/bots", + }) + return + } + + if roomID == "" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Room ID is required", + }) + return + } + + // Get player token from X-Player-Token header or Authorization header + playerToken := r.Header.Get("X-Player-Token") + if playerToken == "" { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + playerToken = strings.TrimPrefix(auth, "Bearer ") + } + } + if playerToken == "" { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Player token is required (provide via X-Player-Token header or Authorization: Bearer header)", + }) + return + } + + // Get room by ID + room, exists := s.roomManager.GetGameRoom(roomID) + if !exists { + w.WriteHeader(http.StatusNotFound) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Room not found", + }) + return + } + + // Verify player token belongs to a player in the room + if !room.IsPlayerTokenValid(playerToken) { + w.WriteHeader(http.StatusForbidden) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Player token is not authorized for this room", + }) + return + } + + // Parse request body (limit to 1MB to prevent abuse) + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var req AddBotRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Invalid request format", + }) + return + } + + // Validate bot type + if req.BotType != "rule_based" && req.BotType != "neural_network" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Invalid botType. Must be 'rule_based' or 'neural_network'", + }) + return + } + + // Set default player name if not provided + playerName := req.PlayerName + if playerName == "" { + playerName = "Bot" + } + + // Construct Bot Service request + botServiceReq := botServiceSpawnRequest{ + RoomCode: room.RoomCode, + RoomPassword: room.Password, + BotConfig: botServiceBotConfig{ + BotType: req.BotType, + ModelID: req.ModelID, + Generation: req.Generation, + PlayerName: playerName, + }, + } + + // Marshal request body + reqBody, err := json.Marshal(botServiceReq) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Failed to construct request", + }) + return + } + + // Forward request to Bot Service (using request context for cancellation propagation) + botServiceURL := getBotServiceURL() + proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, botServiceURL+"/bots/spawn", bytes.NewReader(reqBody)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Failed to construct request", + }) + return + } + proxyReq.Header.Set("Content-Type", "application/json") + + resp, err := botServiceClient.Do(proxyReq) + if err != nil { + log.Printf("HandleAddBot: Failed to connect to Bot Service: %v", err) + w.WriteHeader(http.StatusServiceUnavailable) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Bot Service unavailable", + }) + return + } + defer resp.Body.Close() + + // Read response body (limit to 1MB to prevent memory exhaustion) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Failed to read Bot Service response", + }) + return + } + + // Handle Bot Service errors + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Room not found in Bot Service", + }) + return + } + + // Handle other non-success status codes from Bot Service + if resp.StatusCode >= 400 { + log.Printf("HandleAddBot: Bot Service returned status %d", resp.StatusCode) + w.WriteHeader(resp.StatusCode) + writeJSON(w, AddBotResponse{ + Success: false, + Error: fmt.Sprintf("Bot Service error (status %d)", resp.StatusCode), + }) + return + } + + // Parse Bot Service response + var botServiceResp botServiceSpawnResponse + if err := json.Unmarshal(respBody, &botServiceResp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, AddBotResponse{ + Success: false, + Error: "Failed to parse Bot Service response", + }) + return + } + + // Update room activity + s.serverLock.Lock() + s.lastActivity[roomID] = time.Now() + s.serverLock.Unlock() + + // Return response + w.WriteHeader(http.StatusOK) + writeJSON(w, AddBotResponse(botServiceResp)) + + if botServiceResp.Success { + log.Printf("Bot %s added to room %s via HTTP API", botServiceResp.BotID, roomID) + } +} + +// HandleRemoveBot handles DELETE /api/rooms/{roomId}/bots/{botId} +func (s *Server) HandleRemoveBot(w http.ResponseWriter, r *http.Request) { + // Set response headers + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Player-Token") + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Only allow DELETE requests + if r.Method != "DELETE" { + w.WriteHeader(http.StatusMethodNotAllowed) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Method not allowed", + }) + return + } + + // Extract roomId and botId from URL path: /api/rooms/{roomId}/bots/{botId} + roomID, botID, ok := extractRemoveBotPathParams(r.URL.Path) + if !ok { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Invalid URL format. Expected: /api/rooms/{roomId}/bots/{botId}", + }) + return + } + + if roomID == "" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Room ID is required", + }) + return + } + + if botID == "" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Bot ID is required", + }) + return + } + + // Get player token from X-Player-Token header or Authorization header + playerToken := r.Header.Get("X-Player-Token") + if playerToken == "" { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + playerToken = strings.TrimPrefix(auth, "Bearer ") + } + } + if playerToken == "" { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Player token is required (provide via X-Player-Token header or Authorization: Bearer header)", + }) + return + } + + // Get room by ID + room, exists := s.roomManager.GetGameRoom(roomID) + if !exists { + w.WriteHeader(http.StatusNotFound) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Room not found", + }) + return + } + + // Verify player token belongs to a player in the room + if !room.IsPlayerTokenValid(playerToken) { + w.WriteHeader(http.StatusForbidden) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Player token is not authorized for this room", + }) + return + } + + // Forward DELETE request to Bot Service (using request context for cancellation propagation) + botServiceURL := getBotServiceURL() + proxyReq, err := http.NewRequestWithContext(r.Context(), http.MethodDelete, botServiceURL+"/bots/"+botID, nil) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Failed to construct request", + }) + return + } + + resp, err := botServiceClient.Do(proxyReq) + if err != nil { + log.Printf("HandleRemoveBot: Failed to connect to Bot Service: %v", err) + w.WriteHeader(http.StatusServiceUnavailable) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Bot Service unavailable", + }) + return + } + defer resp.Body.Close() + + // Read response body (limit to 1MB to prevent memory exhaustion) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Failed to read Bot Service response", + }) + return + } + + // Handle Bot Service 404 (bot not found) + if resp.StatusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Bot not found", + }) + return + } + + // Handle other non-success status codes from Bot Service + if resp.StatusCode >= 400 { + log.Printf("HandleRemoveBot: Bot Service returned status %d", resp.StatusCode) + w.WriteHeader(resp.StatusCode) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: fmt.Sprintf("Bot Service error (status %d)", resp.StatusCode), + }) + return + } + + // Parse Bot Service response + var botServiceResp botServiceRemoveResponse + if err := json.Unmarshal(respBody, &botServiceResp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, RemoveBotResponse{ + Success: false, + Error: "Failed to parse Bot Service response", + }) + return + } + + // Update room activity + s.serverLock.Lock() + s.lastActivity[roomID] = time.Now() + s.serverLock.Unlock() + + // Return response + w.WriteHeader(http.StatusOK) + writeJSON(w, RemoveBotResponse(botServiceResp)) + + if botServiceResp.Success { + log.Printf("Bot %s removed from room %s via HTTP API", botID, roomID) + } +} diff --git a/backend/pkg/server/http_handlers_test.go b/backend/pkg/server/http_handlers_test.go index 81d91ee..cd19ff9 100644 --- a/backend/pkg/server/http_handlers_test.go +++ b/backend/pkg/server/http_handlers_test.go @@ -6,6 +6,7 @@ import ( "go-ws-server/pkg/server/types" "net/http" "net/http/httptest" + "os" "strings" "testing" ) @@ -1624,3 +1625,698 @@ func TestHandleGetRoomStats_StatsResetAfterGameReset(t *testing.T) { response2.PlayerStats[playerID].Kills, response2.PlayerStats[playerID].Deaths) } } + +func TestExtractAddBotPathParams(t *testing.T) { + tests := []struct { + name string + path string + wantRoomID string + wantOk bool + }{ + { + name: "Valid path", + path: "/api/rooms/room123/bots", + wantRoomID: "room123", + wantOk: true, + }, + { + name: "Valid path with UUID", + path: "/api/rooms/550e8400-e29b-41d4-a716-446655440000/bots", + wantRoomID: "550e8400-e29b-41d4-a716-446655440000", + wantOk: true, + }, + { + name: "Invalid path - missing /bots suffix", + path: "/api/rooms/room123", + wantOk: false, + }, + { + name: "Invalid path - extra segments", + path: "/api/rooms/room123/bots/extra", + wantOk: false, + }, + { + name: "Invalid path - wrong prefix", + path: "/rooms/room123/bots", + wantOk: false, + }, + { + name: "Empty room ID", + path: "/api/rooms//bots", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + roomID, ok := extractAddBotPathParams(tt.path) + if ok != tt.wantOk { + t.Errorf("extractAddBotPathParams() ok = %v, want %v", ok, tt.wantOk) + } + if ok && roomID != tt.wantRoomID { + t.Errorf("extractAddBotPathParams() roomID = %v, want %v", roomID, tt.wantRoomID) + } + }) + } +} + +func TestExtractRemoveBotPathParams(t *testing.T) { + tests := []struct { + name string + path string + wantRoomID string + wantBotID string + wantOk bool + }{ + { + name: "Valid path", + path: "/api/rooms/room123/bots/bot456", + wantRoomID: "room123", + wantBotID: "bot456", + wantOk: true, + }, + { + name: "Valid path with UUIDs", + path: "/api/rooms/550e8400-e29b-41d4-a716-446655440000/bots/6ba7b810-9dad-11d1-80b4-00c04fd430c8", + wantRoomID: "550e8400-e29b-41d4-a716-446655440000", + wantBotID: "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + wantOk: true, + }, + { + name: "Invalid path - missing bot ID", + path: "/api/rooms/room123/bots", + wantOk: false, + }, + { + name: "Invalid path - extra segments", + path: "/api/rooms/room123/bots/bot456/extra", + wantOk: false, + }, + { + name: "Invalid path - wrong prefix", + path: "/rooms/room123/bots/bot456", + wantOk: false, + }, + { + name: "Empty room ID", + path: "/api/rooms//bots/bot456", + wantOk: false, + }, + { + name: "Empty bot ID", + path: "/api/rooms/room123/bots/", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + roomID, botID, ok := extractRemoveBotPathParams(tt.path) + if ok != tt.wantOk { + t.Errorf("extractRemoveBotPathParams() ok = %v, want %v", ok, tt.wantOk) + } + if ok && roomID != tt.wantRoomID { + t.Errorf("extractRemoveBotPathParams() roomID = %v, want %v", roomID, tt.wantRoomID) + } + if ok && botID != tt.wantBotID { + t.Errorf("extractRemoveBotPathParams() botID = %v, want %v", botID, tt.wantBotID) + } + }) + } +} + +func TestHandleAddBot(t *testing.T) { + // Create a mock Bot Service + mockBotService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/bots/spawn" && r.Method == http.MethodPost { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "bot_id": "bot_test_123", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockBotService.Close() + + // Set BOT_SERVICE_URL to mock server + os.Setenv("BOT_SERVICE_URL", mockBotService.URL) + defer os.Unsetenv("BOT_SERVICE_URL") + + server := NewServer() + + // First create a game to get a valid room and player token + createReq := CreateGameHTTPRequest{ + PlayerName: "TestPlayer", + RoomName: "TestRoom", + MapType: "default", + } + createBody, _ := json.Marshal(createReq) + createHttpReq := httptest.NewRequest(http.MethodPost, "/api/createGame", bytes.NewReader(createBody)) + createHttpReq.Header.Set("Content-Type", "application/json") + createRR := httptest.NewRecorder() + server.HandleCreateGame(createRR, createHttpReq) + + var createResp CreateGameHTTPResponse + if err := json.NewDecoder(createRR.Body).Decode(&createResp); err != nil { + t.Fatalf("Failed to decode create game response: %v", err) + } + if !createResp.Success { + t.Fatalf("Failed to create test game: %s", createResp.Error) + } + + roomID := createResp.RoomID + playerToken := createResp.PlayerToken + + tests := []struct { + name string + url string + token string + tokenLocation string // "header", "bearer" + request AddBotRequest + wantStatusCode int + wantSuccess bool + wantError string + }{ + { + name: "Valid rule-based bot request with X-Player-Token", + url: "/api/rooms/" + roomID + "/bots", + token: playerToken, + tokenLocation: "header", + request: AddBotRequest{ + BotType: "rule_based", + PlayerName: "TestBot", + }, + wantStatusCode: http.StatusOK, + wantSuccess: true, + }, + { + name: "Valid neural_network bot request with Bearer token", + url: "/api/rooms/" + roomID + "/bots", + token: playerToken, + tokenLocation: "bearer", + request: AddBotRequest{ + BotType: "neural_network", + ModelID: "model_v1", + PlayerName: "NeuralBot", + }, + wantStatusCode: http.StatusOK, + wantSuccess: true, + }, + { + name: "Missing player token", + url: "/api/rooms/" + roomID + "/bots", + tokenLocation: "", + request: AddBotRequest{ + BotType: "rule_based", + }, + wantStatusCode: http.StatusUnauthorized, + wantSuccess: false, + wantError: "Player token is required", + }, + { + name: "Invalid player token", + url: "/api/rooms/" + roomID + "/bots", + token: "invalid-token", + tokenLocation: "header", + request: AddBotRequest{ + BotType: "rule_based", + }, + wantStatusCode: http.StatusForbidden, + wantSuccess: false, + wantError: "Player token is not authorized for this room", + }, + { + name: "Room not found", + url: "/api/rooms/nonexistent-room-id/bots", + token: playerToken, + tokenLocation: "header", + request: AddBotRequest{ + BotType: "rule_based", + }, + wantStatusCode: http.StatusNotFound, + wantSuccess: false, + wantError: "Room not found", + }, + { + name: "Invalid bot type", + url: "/api/rooms/" + roomID + "/bots", + token: playerToken, + tokenLocation: "header", + request: AddBotRequest{ + BotType: "invalid_type", + }, + wantStatusCode: http.StatusBadRequest, + wantSuccess: false, + wantError: "Invalid botType", + }, + { + name: "Invalid URL format", + url: "/api/rooms/" + roomID, + token: playerToken, + tokenLocation: "header", + request: AddBotRequest{BotType: "rule_based"}, + wantStatusCode: http.StatusBadRequest, + wantSuccess: false, + wantError: "Invalid URL format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, _ := json.Marshal(tt.request) + req := httptest.NewRequest(http.MethodPost, tt.url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + switch tt.tokenLocation { + case "header": + req.Header.Set("X-Player-Token", tt.token) + case "bearer": + req.Header.Set("Authorization", "Bearer "+tt.token) + } + + rr := httptest.NewRecorder() + server.HandleAddBot(rr, req) + + if rr.Code != tt.wantStatusCode { + t.Errorf("HandleAddBot() status = %v, want %v", rr.Code, tt.wantStatusCode) + } + + var response AddBotResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Success != tt.wantSuccess { + t.Errorf("HandleAddBot() success = %v, want %v", response.Success, tt.wantSuccess) + } + + if tt.wantError != "" && !strings.Contains(response.Error, tt.wantError) { + t.Errorf("HandleAddBot() error = %q, want to contain %q", response.Error, tt.wantError) + } + + if tt.wantSuccess && response.BotID == "" { + t.Error("HandleAddBot() botId should not be empty for successful requests") + } + }) + } +} + +func TestHandleAddBot_MethodNotAllowed(t *testing.T) { + server := NewServer() + + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/rooms/test-room/bots", nil) + rr := httptest.NewRecorder() + + server.HandleAddBot(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("HandleAddBot() with %s status = %v, want %v", method, rr.Code, http.StatusMethodNotAllowed) + } + }) + } +} + +func TestHandleAddBot_CORSPreflight(t *testing.T) { + server := NewServer() + + req := httptest.NewRequest(http.MethodOptions, "/api/rooms/test-room/bots", nil) + rr := httptest.NewRecorder() + + server.HandleAddBot(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("HandleAddBot() OPTIONS status = %v, want %v", rr.Code, http.StatusOK) + } + + if rr.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("Missing or incorrect Access-Control-Allow-Origin header") + } + if rr.Header().Get("Access-Control-Allow-Methods") != "POST, OPTIONS" { + t.Error("Missing or incorrect Access-Control-Allow-Methods header") + } +} + +func TestHandleAddBot_DefaultPlayerName(t *testing.T) { + // Track what player_name was received by the mock Bot Service + var receivedPlayerName string + + mockBotService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/bots/spawn" && r.Method == http.MethodPost { + // Parse the incoming request to check player_name + var reqBody struct { + BotConfig struct { + PlayerName string `json:"player_name"` + } `json:"bot_config"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + receivedPlayerName = reqBody.BotConfig.PlayerName + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "bot_id": "bot_test_default", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockBotService.Close() + + os.Setenv("BOT_SERVICE_URL", mockBotService.URL) + defer os.Unsetenv("BOT_SERVICE_URL") + + server := NewServer() + + // Create a game first + createReq := CreateGameHTTPRequest{ + PlayerName: "TestPlayer", + RoomName: "TestRoom", + MapType: "default", + } + createBody, _ := json.Marshal(createReq) + createHttpReq := httptest.NewRequest(http.MethodPost, "/api/createGame", bytes.NewReader(createBody)) + createHttpReq.Header.Set("Content-Type", "application/json") + createRR := httptest.NewRecorder() + server.HandleCreateGame(createRR, createHttpReq) + + var createResp CreateGameHTTPResponse + if err := json.NewDecoder(createRR.Body).Decode(&createResp); err != nil { + t.Fatalf("Failed to decode create game response: %v", err) + } + + // Send AddBot request WITHOUT a PlayerName (should default to "Bot") + addBotReq := AddBotRequest{ + BotType: "rule_based", + // PlayerName intentionally omitted + } + body, _ := json.Marshal(addBotReq) + req := httptest.NewRequest(http.MethodPost, "/api/rooms/"+createResp.RoomID+"/bots", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Player-Token", createResp.PlayerToken) + + rr := httptest.NewRecorder() + server.HandleAddBot(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("HandleAddBot() status = %v, want %v", rr.Code, http.StatusOK) + } + + // Verify the Bot Service received "Bot" as the player_name + if receivedPlayerName != "Bot" { + t.Errorf("Bot Service received player_name = %q, want %q", receivedPlayerName, "Bot") + } +} + +func TestHandleAddBot_BotServiceUnavailable(t *testing.T) { + // Set BOT_SERVICE_URL to an unreachable address + os.Setenv("BOT_SERVICE_URL", "http://localhost:59999") + defer os.Unsetenv("BOT_SERVICE_URL") + + server := NewServer() + + // Create a game first + createReq := CreateGameHTTPRequest{ + PlayerName: "TestPlayer", + RoomName: "TestRoom", + MapType: "default", + } + createBody, _ := json.Marshal(createReq) + createHttpReq := httptest.NewRequest(http.MethodPost, "/api/createGame", bytes.NewReader(createBody)) + createHttpReq.Header.Set("Content-Type", "application/json") + createRR := httptest.NewRecorder() + server.HandleCreateGame(createRR, createHttpReq) + + var createResp CreateGameHTTPResponse + _ = json.NewDecoder(createRR.Body).Decode(&createResp) + + // Try to add a bot + addBotReq := AddBotRequest{ + BotType: "rule_based", + PlayerName: "TestBot", + } + body, _ := json.Marshal(addBotReq) + req := httptest.NewRequest(http.MethodPost, "/api/rooms/"+createResp.RoomID+"/bots", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Player-Token", createResp.PlayerToken) + + rr := httptest.NewRecorder() + server.HandleAddBot(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("HandleAddBot() status = %v, want %v", rr.Code, http.StatusServiceUnavailable) + } + + var response AddBotResponse + _ = json.NewDecoder(rr.Body).Decode(&response) + + if response.Success { + t.Error("HandleAddBot() success should be false when Bot Service is unavailable") + } + if !strings.Contains(response.Error, "Bot Service unavailable") { + t.Errorf("HandleAddBot() error = %q, want to contain 'Bot Service unavailable'", response.Error) + } +} + +func TestHandleRemoveBot(t *testing.T) { + // Create a mock Bot Service + mockBotService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/bots/") && r.Method == http.MethodDelete { + botID := strings.TrimPrefix(r.URL.Path, "/bots/") + if botID == "nonexistent-bot" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockBotService.Close() + + // Set BOT_SERVICE_URL to mock server + os.Setenv("BOT_SERVICE_URL", mockBotService.URL) + defer os.Unsetenv("BOT_SERVICE_URL") + + server := NewServer() + + // First create a game to get a valid room and player token + createReq := CreateGameHTTPRequest{ + PlayerName: "TestPlayer", + RoomName: "TestRoom", + MapType: "default", + } + createBody, _ := json.Marshal(createReq) + createHttpReq := httptest.NewRequest(http.MethodPost, "/api/createGame", bytes.NewReader(createBody)) + createHttpReq.Header.Set("Content-Type", "application/json") + createRR := httptest.NewRecorder() + server.HandleCreateGame(createRR, createHttpReq) + + var createResp CreateGameHTTPResponse + if err := json.NewDecoder(createRR.Body).Decode(&createResp); err != nil { + t.Fatalf("Failed to decode create game response: %v", err) + } + if !createResp.Success { + t.Fatalf("Failed to create test game: %s", createResp.Error) + } + + roomID := createResp.RoomID + playerToken := createResp.PlayerToken + + tests := []struct { + name string + url string + token string + tokenLocation string + wantStatusCode int + wantSuccess bool + wantError string + }{ + { + name: "Valid remove bot request with X-Player-Token", + url: "/api/rooms/" + roomID + "/bots/bot123", + token: playerToken, + tokenLocation: "header", + wantStatusCode: http.StatusOK, + wantSuccess: true, + }, + { + name: "Valid remove bot request with Bearer token", + url: "/api/rooms/" + roomID + "/bots/bot456", + token: playerToken, + tokenLocation: "bearer", + wantStatusCode: http.StatusOK, + wantSuccess: true, + }, + { + name: "Missing player token", + url: "/api/rooms/" + roomID + "/bots/bot123", + tokenLocation: "", + wantStatusCode: http.StatusUnauthorized, + wantSuccess: false, + wantError: "Player token is required", + }, + { + name: "Invalid player token", + url: "/api/rooms/" + roomID + "/bots/bot123", + token: "invalid-token", + tokenLocation: "header", + wantStatusCode: http.StatusForbidden, + wantSuccess: false, + wantError: "Player token is not authorized for this room", + }, + { + name: "Room not found", + url: "/api/rooms/nonexistent-room-id/bots/bot123", + token: playerToken, + tokenLocation: "header", + wantStatusCode: http.StatusNotFound, + wantSuccess: false, + wantError: "Room not found", + }, + { + name: "Bot not found in Bot Service", + url: "/api/rooms/" + roomID + "/bots/nonexistent-bot", + token: playerToken, + tokenLocation: "header", + wantStatusCode: http.StatusNotFound, + wantSuccess: false, + wantError: "Bot not found", + }, + { + name: "Invalid URL format - missing bot ID", + url: "/api/rooms/" + roomID + "/bots", + token: playerToken, + tokenLocation: "header", + wantStatusCode: http.StatusBadRequest, + wantSuccess: false, + wantError: "Invalid URL format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, tt.url, nil) + + switch tt.tokenLocation { + case "header": + req.Header.Set("X-Player-Token", tt.token) + case "bearer": + req.Header.Set("Authorization", "Bearer "+tt.token) + } + + rr := httptest.NewRecorder() + server.HandleRemoveBot(rr, req) + + if rr.Code != tt.wantStatusCode { + t.Errorf("HandleRemoveBot() status = %v, want %v", rr.Code, tt.wantStatusCode) + } + + var response RemoveBotResponse + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Success != tt.wantSuccess { + t.Errorf("HandleRemoveBot() success = %v, want %v", response.Success, tt.wantSuccess) + } + + if tt.wantError != "" && !strings.Contains(response.Error, tt.wantError) { + t.Errorf("HandleRemoveBot() error = %q, want to contain %q", response.Error, tt.wantError) + } + }) + } +} + +func TestHandleRemoveBot_MethodNotAllowed(t *testing.T) { + server := NewServer() + + methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch} + + for _, method := range methods { + t.Run(method, func(t *testing.T) { + req := httptest.NewRequest(method, "/api/rooms/test-room/bots/bot123", nil) + rr := httptest.NewRecorder() + + server.HandleRemoveBot(rr, req) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("HandleRemoveBot() with %s status = %v, want %v", method, rr.Code, http.StatusMethodNotAllowed) + } + }) + } +} + +func TestHandleRemoveBot_CORSPreflight(t *testing.T) { + server := NewServer() + + req := httptest.NewRequest(http.MethodOptions, "/api/rooms/test-room/bots/bot123", nil) + rr := httptest.NewRecorder() + + server.HandleRemoveBot(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("HandleRemoveBot() OPTIONS status = %v, want %v", rr.Code, http.StatusOK) + } + + if rr.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("Missing or incorrect Access-Control-Allow-Origin header") + } + if rr.Header().Get("Access-Control-Allow-Methods") != "DELETE, OPTIONS" { + t.Error("Missing or incorrect Access-Control-Allow-Methods header") + } +} + +func TestHandleRemoveBot_BotServiceUnavailable(t *testing.T) { + // Set BOT_SERVICE_URL to an unreachable address + os.Setenv("BOT_SERVICE_URL", "http://localhost:59999") + defer os.Unsetenv("BOT_SERVICE_URL") + + server := NewServer() + + // Create a game first + createReq := CreateGameHTTPRequest{ + PlayerName: "TestPlayer", + RoomName: "TestRoom", + MapType: "default", + } + createBody, _ := json.Marshal(createReq) + createHttpReq := httptest.NewRequest(http.MethodPost, "/api/createGame", bytes.NewReader(createBody)) + createHttpReq.Header.Set("Content-Type", "application/json") + createRR := httptest.NewRecorder() + server.HandleCreateGame(createRR, createHttpReq) + + var createResp CreateGameHTTPResponse + _ = json.NewDecoder(createRR.Body).Decode(&createResp) + + // Try to remove a bot + req := httptest.NewRequest(http.MethodDelete, "/api/rooms/"+createResp.RoomID+"/bots/bot123", nil) + req.Header.Set("X-Player-Token", createResp.PlayerToken) + + rr := httptest.NewRecorder() + server.HandleRemoveBot(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Errorf("HandleRemoveBot() status = %v, want %v", rr.Code, http.StatusServiceUnavailable) + } + + var response RemoveBotResponse + _ = json.NewDecoder(rr.Body).Decode(&response) + + if response.Success { + t.Error("HandleRemoveBot() success should be false when Bot Service is unavailable") + } + if !strings.Contains(response.Error, "Bot Service unavailable") { + t.Errorf("HandleRemoveBot() error = %q, want to contain 'Bot Service unavailable'", response.Error) + } +}