diff --git a/cmd/chessd/main.go b/cmd/chessd/main.go index 0fe9bd5..821d4ed 100644 --- a/cmd/chessd/main.go +++ b/cmd/chessd/main.go @@ -2,23 +2,73 @@ package main import ( + "context" + "flag" "fmt" "log" "os" + "os/signal" + "syscall" + "time" "chess/internal/service" + "chess/internal/transport/http" ) func main() { - // Placeholder for future API server implementation + // Command-line flags + var ( + host = flag.String("host", "localhost", "Server host") + port = flag.Int("port", 8080, "Server port") + dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)") + ) + flag.Parse() + + // Initialize service (includes engine) svc, err := service.New() if err != nil { log.Fatalf("Failed to initialize service: %v", err) } - defer svc.Close() + defer func() { + if err := svc.Close(); err != nil { + log.Printf("Warning: failed to close service cleanly: %v", err) + } + }() - // TODO: Phase 2 - Add HTTP/WebSocket server here - fmt.Println("Chess server daemon - not yet implemented") - fmt.Println("This will host the API in Phase 2") - os.Exit(0) -} + // Create Fiber app with dev mode flag + app := http.NewFiberApp(svc, *dev) + + // Server configuration + addr := fmt.Sprintf("%s:%d", *host, *port) + + // Start server in goroutine + go func() { + log.Printf("Chess API Server starting...") + log.Printf("Listening on: http://%s", addr) + log.Printf("API Version: v1") + log.Printf("Rate Limit: 1 request/second per IP") + log.Printf("Endpoints: http://%s/api/v1/games", addr) + log.Printf("Health: http://%s/health", addr) + + if err := app.Listen(addr); err != nil { + log.Printf("Server error: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Graceful shutdown with timeout + _, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := app.ShutdownWithTimeout(5 * time.Second); err != nil { + log.Printf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} \ No newline at end of file diff --git a/go.mod b/go.mod index 883da4c..3af57fd 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,22 @@ module chess go 1.24 -require github.com/google/uuid v1.6.0 +require ( + github.com/gofiber/fiber/v2 v2.52.9 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum index a1e72c7..12b0082 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,31 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= +github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/game/game.go b/internal/game/game.go index cedf40e..f61bce5 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -71,6 +71,10 @@ func (g *Game) NextPlayer() *core.Player { return g.players[g.NextTurn()] } +func (g *Game) GetPlayer(color core.Color) *core.Player { + return g.players[color] +} + func (g *Game) AddSnapshot(fen string, move string, nextTurn core.Color) { g.snapshots = append(g.snapshots, Snapshot{ FEN: fen, diff --git a/internal/service/service.go b/internal/service/service.go index a603207..5452766 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -224,6 +224,14 @@ func (s *Service) GetGame(gameID string) (*game.Game, error) { return g, nil } +func (s *Service) DeleteGame(gameID string) error { + if _, ok := s.games[gameID]; !ok { + return fmt.Errorf("game not found: %s", gameID) + } + delete(s.games, gameID) + return nil +} + func (s *Service) Close() error { if s.engine != nil { return s.engine.Close() diff --git a/internal/transport/http/game_handler.go b/internal/transport/http/game_handler.go new file mode 100644 index 0000000..74c14f4 --- /dev/null +++ b/internal/transport/http/game_handler.go @@ -0,0 +1,316 @@ +// FILE: internal/transport/http/game_handler.go +package http + +import ( + "fmt" + "strings" + + "chess/internal/board" + "chess/internal/core" + "chess/internal/game" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +// CreateGame creates a new game with specified player types +func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error { + var req CreateGameRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "invalid request body", + Code: ErrInvalidRequest, + Details: err.Error(), + }) + } + + gameID := uuid.New().String() + + // Create game with proper type conversion + var fenArray []string + if req.FEN != "" { + fenArray = []string{req.FEN} + } + + err := h.svc.NewGame( + gameID, + core.PlayerType(req.White), + core.PlayerType(req.Black), + fenArray..., + ) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "failed to create game", + Code: ErrInvalidRequest, + Details: err.Error(), + }) + } + + // Build response - cache game instance + g, _ := h.svc.GetGame(gameID) + response := h.buildGameResponse(gameID, g) + + // Execute computer move if computer starts + if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing { + if err := h.executeComputerMove(gameID, g, &response); err != nil { + // Log error but return game created successfully + fmt.Printf("Warning: failed to execute initial computer move: %v\n", err) + } + } + + return c.Status(fiber.StatusCreated).JSON(response) +} + +// GetGame retrieves current game state, executing computer move if needed +func (h *HTTPHandler) GetGame(c *fiber.Ctx) error { + gameID := c.Params("gameId") + + g, err := h.svc.GetGame(gameID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{ + Error: "game not found", + Code: ErrGameNotFound, + }) + } + + response := h.buildGameResponse(gameID, g) + + // Auto-execute computer move if it's computer's turn + if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing { + if err := h.executeComputerMove(gameID, g, &response); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(ErrorResponse{ + Error: "failed to execute computer move", + Code: ErrInternalError, + Details: err.Error(), + }) + } + } + + return c.JSON(response) +} + +// MakeMove submits a human player move +func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error { + gameID := c.Params("gameId") + + var req MoveRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "invalid request body", + Code: ErrInvalidRequest, + Details: err.Error(), + }) + } + + g, err := h.svc.GetGame(gameID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{ + Error: "game not found", + Code: ErrGameNotFound, + }) + } + + // Check game state BEFORE making move + if g.State() != core.StateOngoing { + fmt.Printf("DEBUG: Move rejected - game over (state: %s)\n", g.State()) + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "game is over", + Code: ErrGameOver, + Details: fmt.Sprintf("game state: %s", g.State()), + }) + } + + // Verify it's human's turn + currentPlayer := g.NextPlayer() + if currentPlayer.Type != core.PlayerHuman { + fmt.Printf("DEBUG: Move rejected - not human turn (current: %v, turn: %c)\n", + currentPlayer.Type, g.NextTurn()) + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "not human player's turn", + Code: ErrNotHumanTurn, + Details: fmt.Sprintf("current turn: %c", g.NextTurn()), + }) + } + + fmt.Printf("DEBUG: Attempting human move %s for game %s\n", req.Move, gameID) + + // Make human move + if err := h.svc.MakeHumanMove(gameID, req.Move); err != nil { + fmt.Printf("DEBUG: Move failed: %v\n", err) + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "invalid move", + Code: ErrInvalidMove, + Details: err.Error(), + }) + } + + // Get updated game state - refresh g + g, _ = h.svc.GetGame(gameID) + response := h.buildGameResponse(gameID, g) + + // Include human move info from LastResult + if result := g.LastResult(); result != nil { + response.LastMove = &MoveInfo{ + Move: result.Move, + Player: colorToString(result.Player), + } + } + + fmt.Printf("DEBUG: Human move successful, new state: %s, next turn: %c\n", + g.State(), g.NextTurn()) + + // Execute computer response if needed + if g.NextPlayer().Type == core.PlayerComputer && g.State() == core.StateOngoing { + fmt.Printf("DEBUG: Executing computer response\n") + if err := h.executeComputerMove(gameID, g, &response); err != nil { + // Computer move failed, but human move succeeded + fmt.Printf("Warning: computer move failed: %v\n", err) + } + } + + return c.JSON(response) +} + +// UndoMove undoes one or more moves +func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error { + gameID := c.Params("gameId") + + var req UndoRequest + if err := c.BodyParser(&req); err != nil { + // Body parsing failed, use default + req.Count = 1 + } + + if req.Count < 1 { + req.Count = 1 + } + + if err := h.svc.Undo(gameID, req.Count); err != nil { + // Determine if game not found or invalid undo + if strings.Contains(err.Error(), "not found") { + return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{ + Error: "game not found", + Code: ErrGameNotFound, + }) + } + return c.Status(fiber.StatusBadRequest).JSON(ErrorResponse{ + Error: "cannot undo moves", + Code: ErrInvalidRequest, + Details: err.Error(), + }) + } + + // Return updated game state + g, _ := h.svc.GetGame(gameID) + response := h.buildGameResponse(gameID, g) + + return c.JSON(response) +} + +// DeleteGame ends and cleans up a game +func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error { + gameID := c.Params("gameId") + + if err := h.svc.DeleteGame(gameID); err != nil { + return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{ + Error: "game not found", + Code: ErrGameNotFound, + }) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// GetBoard returns ASCII representation of the board +func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error { + gameID := c.Params("gameId") + + g, err := h.svc.GetGame(gameID) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{ + Error: "game not found", + Code: ErrGameNotFound, + }) + } + + b, _ := h.svc.GetCurrentBoard(gameID) + + // Generate ASCII board + ascii := h.generateASCIIBoard(b) + + return c.JSON(BoardResponse{ + FEN: g.CurrentFEN(), + Board: ascii, + }) +} + +// Helper: Build standard game response - FIXED to use GetPlayer() +func (h *HTTPHandler) buildGameResponse(gameID string, g *game.Game) GameResponse { + whitePlayer := g.GetPlayer(core.ColorWhite) + blackPlayer := g.GetPlayer(core.ColorBlack) + + return GameResponse{ + GameID: gameID, + FEN: g.CurrentFEN(), + Turn: colorToString(g.NextTurn()), + State: stateToString(g.State()), + Moves: g.Moves(), + Players: PlayersInfo{ + White: PlayerType(whitePlayer.Type), + Black: PlayerType(blackPlayer.Type), + }, + } +} + +// Helper: Execute computer move and update response - FIXED to accept game instance +func (h *HTTPHandler) executeComputerMove(gameID string, g *game.Game, response *GameResponse) error { + result, err := h.svc.MakeComputerMove(gameID) + if err != nil { + return err + } + + // Refresh game state after computer move + g, _ = h.svc.GetGame(gameID) + + // Update response fields + response.FEN = g.CurrentFEN() + response.Turn = colorToString(g.NextTurn()) + response.State = stateToString(g.State()) + response.Moves = g.Moves() + + // Add computer move info + if result != nil { + response.LastMove = &MoveInfo{ + Move: result.Move, + Player: colorToString(result.Player), + Score: result.Score, + Depth: result.Depth, + } + } + + return nil +} + +// Helper: Generate ASCII board representation +func (h *HTTPHandler) generateASCIIBoard(b *board.Board) string { + var sb strings.Builder + sb.WriteString(" a b c d e f g h\n") + + for r := 0; r < 8; r++ { + sb.WriteString(fmt.Sprintf("%d ", 8-r)) + for f := 0; f < 8; f++ { + square := fmt.Sprintf("%c%c", 'a'+f, '8'-r) + piece := b.GetPieceAt(square) + + if piece == 0 { + sb.WriteString(". ") + } else { + sb.WriteString(fmt.Sprintf("%c ", piece)) + } + } + sb.WriteString(fmt.Sprintf(" %d\n", 8-r)) + } + sb.WriteString(" a b c d e f g h") + + return sb.String() +} \ No newline at end of file diff --git a/internal/transport/http/handler.go b/internal/transport/http/handler.go new file mode 100644 index 0000000..f57a9c0 --- /dev/null +++ b/internal/transport/http/handler.go @@ -0,0 +1,148 @@ +// FILE: internal/transport/http/handler.go +package http + +import ( + "fmt" + "strings" + "time" + + "chess/internal/service" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/limiter" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +type HTTPHandler struct { + svc *service.Service +} + +func NewHTTPHandler(svc *service.Service) *HTTPHandler { + return &HTTPHandler{svc: svc} +} + +func NewFiberApp(svc *service.Service, devMode bool) *fiber.App { + // Create handler + h := NewHTTPHandler(svc) + + // Initialize Fiber app + app := fiber.New(fiber.Config{ + ErrorHandler: customErrorHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + }) + + // Global middleware (order matters) + app.Use(recover.New()) + app.Use(logger.New(logger.Config{ + Format: "${time} ${status} ${method} ${path} ${latency}\n", + })) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowMethods: "GET,POST,DELETE,OPTIONS", + AllowHeaders: "Origin,Content-Type,Accept", + })) + + // Health check (no rate limit) + app.Get("/health", h.Health) + + // API v1 routes with rate limiting + api := app.Group("/api/v1") + + // Rate limiter: 1/10 req/sec per IP with expiry + maxReq := 1 + if devMode { + maxReq = 10 + } + api.Use(limiter.New(limiter.Config{ + Max: maxReq, // Allow requests per second + Expiration: 1 * time.Second, // Per second + KeyGenerator: func(c *fiber.Ctx) string { + // Check X-Forwarded-For first, then X-Real-IP, then RemoteIP + if xff := c.Get("X-Forwarded-For"); xff != "" { + // Take the first IP from X-Forwarded-For chain + if idx := strings.Index(xff, ","); idx != -1 { + return strings.TrimSpace(xff[:idx]) + } + return xff + } + return c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(ErrorResponse{ + Error: "rate limit exceeded", + Code: ErrRateLimitExceeded, + Details: fmt.Sprintf("%d requests per second allowed", maxReq), + }) + }, + Storage: nil, // Use in-memory storage (default) + SkipFailedRequests: false, + SkipSuccessfulRequests: false, + })) + + // Content-Type validation for POST requests + api.Use(contentTypeValidator) + + // Register game routes + api.Post("/games", h.CreateGame) + api.Get("/games/:gameId", h.GetGame) + api.Delete("/games/:gameId", h.DeleteGame) + api.Post("/games/:gameId/moves", h.MakeMove) + api.Post("/games/:gameId/undo", h.UndoMove) + api.Get("/games/:gameId/board", h.GetBoard) + + return app +} + +// contentTypeValidator ensures POST requests have application/json +func contentTypeValidator(c *fiber.Ctx) error { + if c.Method() == "POST" { + contentType := c.Get("Content-Type") + if contentType != "application/json" && contentType != "" { + return c.Status(fiber.StatusUnsupportedMediaType).JSON(ErrorResponse{ + Error: "unsupported media type", + Code: ErrInvalidContent, + Details: "Content-Type must be application/json", + }) + } + } + return c.Next() +} + +// customErrorHandler provides consistent error responses +func customErrorHandler(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + response := ErrorResponse{ + Error: "internal server error", + Code: ErrInternalError, + } + + // Check if it's a Fiber error + if e, ok := err.(*fiber.Error); ok { + code = e.Code + response.Error = e.Message + + // Map HTTP status to error codes + switch code { + case fiber.StatusNotFound: + response.Code = ErrGameNotFound + case fiber.StatusBadRequest: + response.Code = ErrInvalidRequest + case fiber.StatusTooManyRequests: + response.Code = ErrRateLimitExceeded + } + } + + return c.Status(code).JSON(response) +} + +// Health check endpoint +func (h *HTTPHandler) Health(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "time": time.Now().Unix(), + }) +} \ No newline at end of file diff --git a/internal/transport/http/types.go b/internal/transport/http/types.go new file mode 100644 index 0000000..a73f6a1 --- /dev/null +++ b/internal/transport/http/types.go @@ -0,0 +1,119 @@ +// FILE: internal/transport/http/types.go +package http + +import ( + "chess/internal/core" +) + +// Request types + +type CreateGameRequest struct { + White PlayerType `json:"white"` // 0=human, 1=computer + Black PlayerType `json:"black"` // 0=human, 1=computer + FEN string `json:"fen,omitempty"` +} + +type MoveRequest struct { + Move string `json:"move"` // UCI format: "e2e4" +} + +type UndoRequest struct { + Count int `json:"count,omitempty"` // default: 1 +} + +// Response types + +type GameResponse struct { + GameID string `json:"gameId"` + FEN string `json:"fen"` + Turn string `json:"turn"` // "w" or "b" + State string `json:"state"` // "ongoing", "white_wins", etc + Moves []string `json:"moves"` + Players PlayersInfo `json:"players"` + LastMove *MoveInfo `json:"lastMove,omitempty"` +} + +type PlayersInfo struct { + White PlayerType `json:"white"` + Black PlayerType `json:"black"` +} + +type MoveInfo struct { + Move string `json:"move"` + Player string `json:"player"` // "w" or "b" + Score int `json:"score,omitempty"` + Depth int `json:"depth,omitempty"` +} + +type BoardResponse struct { + FEN string `json:"fen"` + Board string `json:"board"` // ASCII representation +} + +type ErrorResponse struct { + Error string `json:"error"` + Code string `json:"code"` + Details string `json:"details,omitempty"` +} + +// Custom type for JSON marshaling of PlayerType +type PlayerType core.PlayerType + +func (p PlayerType) MarshalJSON() ([]byte, error) { + // Map to int for JSON: 0=human, 1=computer + return []byte(string('0' + p)), nil +} + +func (p *PlayerType) UnmarshalJSON(data []byte) error { + if len(data) == 1 && data[0] >= '0' && data[0] <= '1' { + *p = PlayerType(data[0] - '0') + return nil + } + // Also accept string format for compatibility + str := string(data) + if str == `"human"` || str == "human" { + *p = PlayerType(core.PlayerHuman) + } else if str == `"computer"` || str == "computer" { + *p = PlayerType(core.PlayerComputer) + } else if str == "0" { + *p = PlayerType(core.PlayerHuman) + } else if str == "1" { + *p = PlayerType(core.PlayerComputer) + } + return nil +} + +// Helper functions + +func colorToString(c core.Color) string { + return string(c) +} + +func stateToString(s core.State) string { + switch s { + case core.StateOngoing: + return "ongoing" + case core.StateWhiteWins: + return "white_wins" + case core.StateBlackWins: + return "black_wins" + case core.StateDraw: + return "draw" + case core.StateStalemate: + return "stalemate" + default: + return "unknown" + } +} + +// Error codes +const ( + ErrGameNotFound = "GAME_NOT_FOUND" + ErrInvalidMove = "INVALID_MOVE" + ErrNotHumanTurn = "NOT_HUMAN_TURN" + ErrGameOver = "GAME_OVER" + ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED" + ErrInvalidContent = "INVALID_CONTENT_TYPE" + ErrInvalidRequest = "INVALID_REQUEST" + ErrInternalError = "INTERNAL_ERROR" +) \ No newline at end of file diff --git a/test/test-api.sh b/test/test-api.sh new file mode 100755 index 0000000..a207bd9 --- /dev/null +++ b/test/test-api.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# FILE: test/test-api.sh + +# Chess API Test Suite +# Requires: curl, jq (optional for pretty output) + +BASE_URL="http://localhost:8080" +API_URL="${BASE_URL}/api/v1" + +# Configurable delay between API calls (in seconds) +API_DELAY=${API_DELAY:-0.2} # Default 200ms between calls + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Test counters +PASS=0 +FAIL=0 + +# Helper functions +test_case() { + echo -e "\n${YELLOW}TEST: $1${NC}" + sleep $API_DELAY # Add delay before each test +} + +assert_status() { + local expected=$1 + local actual=$2 + local test_name=$3 + + if [ "$actual" = "$expected" ]; then + echo -e "${GREEN}✓ $test_name: HTTP $actual${NC}" + ((PASS++)) + else + echo -e "${RED}✗ $test_name: Expected HTTP $expected, got $actual${NC}" + ((FAIL++)) + fi +} + +api_request() { + local method=$1 + local url=$2 + shift 2 + curl -s "$@" -X "$method" "$url" + local status=$? + sleep $API_DELAY + return $status +} + +# Start tests +echo "=== Chess API Test Suite ===" +echo "Server: $BASE_URL" +echo "Starting tests..." + +# Test 1: Health check +test_case "Health Check" +STATUS=$(api_request GET "$BASE_URL/health" -o /dev/null -w "%{http_code}") +assert_status 200 "$STATUS" "Health endpoint" + +# Test 2: Create human vs computer game +test_case "Create Human vs Computer Game" +GAME_RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": 0, "black": 1}') +GAME_ID=$(echo "$GAME_RESPONSE" | grep -o '"gameId":"[^"]*' | cut -d'"' -f4) +STATUS=$(api_request POST "$API_URL/games" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d '{"white": 0, "black": 1}') +assert_status 201 "$STATUS" "Create game" +echo "Game ID: $GAME_ID" + +# Test 3: Make valid human move +test_case "Make Valid Human Move" +STATUS=$(api_request POST "$API_URL/games/$GAME_ID/moves" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d '{"move": "e2e4"}') +assert_status 200 "$STATUS" "Valid move e2e4" + +# Test 4: Get game state (should auto-execute computer move) +test_case "Get Game State (Auto-Execute Computer)" +RESPONSE=$(api_request GET "$API_URL/games/$GAME_ID") +STATUS=$(api_request GET "$API_URL/games/$GAME_ID" -o /dev/null -w "%{http_code}") +assert_status 200 "$STATUS" "Get game state" +echo "Current turn: $(echo "$RESPONSE" | grep -o '"turn":"[^"]*' | cut -d'"' -f4)" + +# Test 5: Make invalid human move +test_case "Make Invalid Human Move" +STATUS=$(api_request POST "$API_URL/games/$GAME_ID/moves" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d '{"move": "e2e5"}') +assert_status 400 "$STATUS" "Invalid move e2e5" + +# Test 6: Undo last move +test_case "Undo Last Move" +STATUS=$(api_request POST "$API_URL/games/$GAME_ID/undo" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d '{"count": 2}') +assert_status 200 "$STATUS" "Undo 2 moves" + +# Test 7: Get ASCII board +test_case "Get ASCII Board" +BOARD_RESPONSE=$(api_request GET "$API_URL/games/$GAME_ID/board") +STATUS=$(api_request GET "$API_URL/games/$GAME_ID/board" -o /dev/null -w "%{http_code}") +assert_status 200 "$STATUS" "Get board" +echo "$BOARD_RESPONSE" | grep -o '"board":"[^"]*' | cut -d'"' -f4 | sed 's/\\n/\n/g' + +# Test 8: Delete game +test_case "Delete Game" +STATUS=$(api_request DELETE "$API_URL/games/$GAME_ID" -o /dev/null -w "%{http_code}") +assert_status 204 "$STATUS" "Delete game" + +# Test 9: Create computer vs computer game +test_case "Create Computer vs Computer Game" +COMP_RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": 1, "black": 1}') +COMP_ID=$(echo "$COMP_RESPONSE" | grep -o '"gameId":"[^"]*' | cut -d'"' -f4) +echo "Computer game ID: $COMP_ID" + +# Test 10: Multiple GET requests to observe progress +test_case "Computer vs Computer Progress" +for i in {1..3}; do + RESPONSE=$(api_request GET "$API_URL/games/$COMP_ID") + MOVES=$(echo "$RESPONSE" | grep -o '"moves":\[[^]]*' | cut -d'[' -f2 | cut -d']' -f1) + STATE=$(echo "$RESPONSE" | grep -o '"state":"[^"]*' | cut -d'"' -f4) + echo "Move $i - State: $STATE, Moves made: $(echo "$MOVES" | grep -o ',' | wc -l)" + if [ "$STATE" != "ongoing" ]; then + echo "Game ended: $STATE" + break + fi +done + +# Test 11: Clean up computer game +api_request DELETE "$API_URL/games/$COMP_ID" > /dev/null + +# Test 12: Rate limiting - 2 requests within 1 second +test_case "Rate Limiting - Rapid Requests" +STATUS1=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/games/test") +STATUS2=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL/games/test") +assert_status 404 "$STATUS1" "First request" +assert_status 429 "$STATUS2" "Second request (rate limited)" +echo "Test will fail in dev mode as rate limiter is permissive." + +# Test 13: Wait and retry after rate limit +test_case "Rate Limit Recovery" +sleep 1.5 +STATUS=$(api_request GET "$API_URL/games/test" -o /dev/null -w "%{http_code}") +assert_status 404 "$STATUS" "Request after waiting" + +# Test 14: Test with X-Forwarded-For (different IPs) +test_case "Rate Limiting with Different IPs" +STATUS1=$(api_request GET "$API_URL/games/test" -o /dev/null -w "%{http_code}" -H "X-Forwarded-For: 192.168.1.1") +STATUS2=$(api_request GET "$API_URL/games/test" -o /dev/null -w "%{http_code}" -H "X-Forwarded-For: 192.168.1.2") +assert_status 404 "$STATUS1" "IP 192.168.1.1" +assert_status 404 "$STATUS2" "IP 192.168.1.2 (different IP)" + +# Test 15: Invalid JSON body +test_case "Invalid JSON Body" +STATUS=$(api_request POST "$API_URL/games" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d 'invalid json') +assert_status 400 "$STATUS" "Invalid JSON" + +# Test 16: Wrong Content-Type header +test_case "Wrong Content-Type Header" +STATUS=$(api_request POST "$API_URL/games" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: text/plain" \ + -d '{"white": 0, "black": 1}') +assert_status 415 "$STATUS" "Wrong Content-Type" + +# Test 17: Non-existent gameId +test_case "Non-existent Game ID" +STATUS=$(api_request GET "$API_URL/games/non-existent-id" -o /dev/null -w "%{http_code}") +assert_status 404 "$STATUS" "Game not found" + +# Test 18: Create game to test end conditions +test_case "Move When Game Over" +ENDGAME_RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": 0, "black": 0, "fen": "7k/5Q2/5K2/8/8/8/8/8 w - - 0 1"}') +ENDGAME_ID=$(echo "$ENDGAME_RESPONSE" | grep -o '"gameId":"[^"]*' | cut -d'"' -f4) + +# Make checkmate move +api_request POST "$API_URL/games/$ENDGAME_ID/moves" \ + -H "Content-Type: application/json" \ + -d '{"move": "f7g7"}' > /dev/null + +# Try to make another move after checkmate +STATUS=$(api_request POST "$API_URL/games/$ENDGAME_ID/moves" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d '{"move": "h8h7"}') +assert_status 400 "$STATUS" "Move after game over" + +# Clean up +api_request DELETE "$API_URL/games/$ENDGAME_ID" > /dev/null + +# Test 19: Move when not player's turn - Use human vs human game +test_case "Move When Not Player's Turn" +# Create human vs human game so no automatic moves happen +TURN_RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": 0, "black": 0}') # BOTH are human players +TURN_ID=$(echo "$TURN_RESPONSE" | grep -o '"gameId":"[^"]*' | cut -d'"' -f4) + +# White moves +api_request POST "$API_URL/games/$TURN_ID/moves" \ + -H "Content-Type: application/json" \ + -d '{"move": "e2e4"}' > /dev/null + +# Now it's black's turn, try to move as white again (should fail) +STATUS=$(api_request POST "$API_URL/games/$TURN_ID/moves" \ + -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -d '{"move": "d2d4"}') +assert_status 400 "$STATUS" "Move when not turn" + +# Clean up +api_request DELETE "$API_URL/games/$TURN_ID" > /dev/null + +# Summary +echo -e "\n=== Test Summary ===" +echo -e "${GREEN}Passed: $PASS${NC}" +echo -e "${RED}Failed: $FAIL${NC}" + +if [ $FAIL -eq 0 ]; then + echo -e "\n${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "\n${RED}Some tests failed${NC}" + exit 1 +fi \ No newline at end of file