v0.1.1 api with fiber added, basic functionlity tested
This commit is contained in:
@ -2,23 +2,73 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"chess/internal/service"
|
"chess/internal/service"
|
||||||
|
"chess/internal/transport/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
svc, err := service.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize service: %v", err)
|
log.Fatalf("Failed to initialize service: %v", err)
|
||||||
}
|
}
|
||||||
defer svc.Close()
|
defer func() {
|
||||||
|
if err := svc.Close(); err != nil {
|
||||||
// TODO: Phase 2 - Add HTTP/WebSocket server here
|
log.Printf("Warning: failed to close service cleanly: %v", err)
|
||||||
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")
|
||||||
}
|
}
|
||||||
20
go.mod
20
go.mod
@ -2,4 +2,22 @@ module chess
|
|||||||
|
|
||||||
go 1.24
|
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
|
||||||
|
)
|
||||||
|
|||||||
29
go.sum
29
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
||||||
|
|||||||
@ -71,6 +71,10 @@ func (g *Game) NextPlayer() *core.Player {
|
|||||||
return g.players[g.NextTurn()]
|
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) {
|
func (g *Game) AddSnapshot(fen string, move string, nextTurn core.Color) {
|
||||||
g.snapshots = append(g.snapshots, Snapshot{
|
g.snapshots = append(g.snapshots, Snapshot{
|
||||||
FEN: fen,
|
FEN: fen,
|
||||||
|
|||||||
@ -224,6 +224,14 @@ func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
|||||||
return g, nil
|
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 {
|
func (s *Service) Close() error {
|
||||||
if s.engine != nil {
|
if s.engine != nil {
|
||||||
return s.engine.Close()
|
return s.engine.Close()
|
||||||
|
|||||||
316
internal/transport/http/game_handler.go
Normal file
316
internal/transport/http/game_handler.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
148
internal/transport/http/handler.go
Normal file
148
internal/transport/http/handler.go
Normal file
@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
119
internal/transport/http/types.go
Normal file
119
internal/transport/http/types.go
Normal file
@ -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"
|
||||||
|
)
|
||||||
241
test/test-api.sh
Executable file
241
test/test-api.sh
Executable file
@ -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
|
||||||
Reference in New Issue
Block a user