v0.6.0 multi-user game support with longpoll, tests and doc updated

This commit is contained in:
2025-11-05 12:08:18 -05:00
parent a3f4db96fa
commit 52868af4ea
13 changed files with 708 additions and 120 deletions

View File

@ -1,6 +1,8 @@
// FILE: cmd/chessd/main.go
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"flag" "flag"
"fmt" "fmt"
@ -18,6 +20,10 @@ import (
"chess/internal/webserver" "chess/internal/webserver"
) )
const (
gracefulShutdownTimeout = time.Second * 5
)
func main() { func main() {
// Check for CLI database commands // Check for CLI database commands
if len(os.Args) > 1 && os.Args[1] == "db" { if len(os.Args) > 1 && os.Args[1] == "db" {
@ -93,22 +99,14 @@ func main() {
} }
// 2. Initialize the Service with optional storage and auth // 2. Initialize the Service with optional storage and auth
svc, err := service.New(store, jwtSecret) svc := service.New(store, jwtSecret)
if err != nil {
log.Fatalf("Failed to initialize service: %v", err)
}
defer svc.Close()
// 3. Initialize the Processor (Orchestrator), injecting the service // 3. Initialize the Processor (Orchestrator), injecting the service
proc, err := processor.New(svc) proc, err := processor.New(svc)
if err != nil { if err != nil {
svc.Shutdown(gracefulShutdownTimeout)
log.Fatalf("Failed to initialize processor: %v", err) log.Fatalf("Failed to initialize processor: %v", err)
} }
defer func() {
if err := proc.Close(); err != nil {
log.Printf("Warning: failed to close processor cleanly: %v", err)
}
}()
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service // 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
app := http.NewFiberApp(proc, svc, *dev) app := http.NewFiberApp(proc, svc, *dev)
@ -164,10 +162,24 @@ func main() {
log.Println("Shutting down servers...") log.Println("Shutting down servers...")
// Graceful shutdown with a timeout // Graceful shutdown of service (includes wait registry)
if err := app.ShutdownWithTimeout(5 * time.Second); err != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), gracefulShutdownTimeout)
defer shutdownCancel()
// Graceful shutdown of HTTP server with timeout
if err = app.ShutdownWithContext(shutdownCtx); err != nil {
log.Printf("Server forced to shutdown: %v", err) log.Printf("Server forced to shutdown: %v", err)
} }
// Close processor after service shutdown
if err = proc.Close(); err != nil {
log.Printf("Processor close error: %v", err)
}
// Shutdown service first (includes wait registry cleanup)
if err = svc.Shutdown(gracefulShutdownTimeout); err != nil {
log.Printf("Service shutdown error: %v", err)
}
log.Println("Servers exited") log.Println("Servers exited")
} }

View File

@ -154,6 +154,23 @@ Note: When authenticated, human player IDs match the user's ID. Anonymous player
Returns current game state. Returns current game state.
**Long-polling support:**
Add query parameters for real-time updates:
- `wait=true` - Enable long-polling (waits up to 25 seconds)
- `moveCount=N` - Last known move count
Returns immediately if game state changed, otherwise waits for updates:
```
GET /games/{gameId}?wait=true&moveCount=5
```
Response includes all game data. Compare `moves` array length to detect changes.
**Timeout behavior:**
- Returns current state after 25 seconds even if no changes
- Client disconnection cancels wait immediately
- Game deletion notifies all waiting clients
### Make Move ### Make Move
`POST /games/{gameId}/moves` `POST /games/{gameId}/moves`

View File

@ -11,15 +11,18 @@ Central command handler containing business logic. Single `Execute(Command)` ent
### Service Layer (`internal/service`) ### Service Layer (`internal/service`)
In-memory state storage with authentication support. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, player configuration, user accounts, and JWT token generation. Coordinates with storage layer for persistence of both games and users. In-memory state storage with authentication support. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, player configuration, user accounts, and JWT token generation. Coordinates with storage layer for persistence of both games and users.
### Storage Layer (`internal/storage`) #### Long-Polling Registry (`internal/service/waiter.go`)
SQLite persistence with async writes for games, synchronous writes for authentication operations. Buffered channel (1000 ops) processes game writes sequentially in background. User operations use direct database access for consistency. Graceful degradation on write failures. WAL mode for development environments. Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 25-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events.
### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`) #### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
- **Password Hashing**: Argon2id for secure password storage - **Password Hashing**: Argon2id for secure password storage
- **JWT Management**: HS256 tokens with 7-day expiration - **JWT Management**: HS256 tokens with 7-day expiration
- **User Operations**: Registration, login, profile management - **User Operations**: Registration, login, profile management
- **Session Tracking**: Last login timestamps - **Session Tracking**: Last login timestamps
### Storage Layer (`internal/storage`)
SQLite persistence with async writes for games, synchronous writes for authentication operations. Buffered channel (1000 ops) processes game writes sequentially in background. User operations use direct database access for consistency. Graceful degradation on write failures. WAL mode for development environments.
### Supporting Modules ### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication - **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
- **Game** (`internal/game`): Game state with snapshot history and player associations - **Game** (`internal/game`): Game state with snapshot history and player associations
@ -62,6 +65,17 @@ SQLite persistence with async writes for games, synchronous writes for authentic
4. Worker goroutine calculates move with dedicated Stockfish instance 4. Worker goroutine calculates move with dedicated Stockfish instance
5. Callback updates game state via service 5. Callback updates game state via service
6. Client polls for completion 6. Client polls for completion
7. Returns GameResponse
### Long-Polling Flow
1. Client sends `GET /games/{id}?wait=true&moveCount=N`
2. Handler creates context from HTTP connection
3. Registers wait with WaitRegistry using game ID and move count
4. If game state unchanged, blocks up to 25 seconds
5. On any game update, NotifyGame sends to all waiters
6. Returns immediately with current state
7. Client disconnection cancels wait via context
8. Game deletion notifies and removes all waiters
## Persistence Flow ## Persistence Flow

View File

@ -158,6 +158,9 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
# Run test server with sample users # Run test server with sample users
./test/test-db-server.sh ./test/test-db-server.sh
# Test real-time game updates via long-polling
./test/test-longpoll.sh
``` ```
## Configuration ## Configuration
@ -170,6 +173,8 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
- Write queue: 1000 operations (internal/storage/storage.go) - Write queue: 1000 operations (internal/storage/storage.go)
- DB connections: 25 max, 5 idle (internal/storage/storage.go) - DB connections: 25 max, 5 idle (internal/storage/storage.go)
- JWT expiration: 7 days (internal/service/user.go) - JWT expiration: 7 days (internal/service/user.go)
- Long-poll timeout: 25 seconds (internal/service/waiter.go)
- Long-poll channel buffer: 1 (internal/service/waiter.go)
### Authentication Configuration ### Authentication Configuration
- Password minimum: 8 characters with letter and number - Password minimum: 8 characters with letter and number
@ -235,3 +240,5 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
- No email verification for registration - No email verification for registration
- Fixed worker pool size for engine calculations - Fixed worker pool size for engine calculations
- No real-time game updates (polling required) - No real-time game updates (polling required)
- Long-polling limited to 25 seconds per request
- REST API only

View File

@ -3,6 +3,7 @@ package http
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -295,16 +296,73 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
}) })
} }
// Create command and execute // Check for long-polling parameters
cmd := processor.NewGetGameCommand(gameID) waitStr := c.Query("wait", "false")
resp := h.proc.Execute(cmd) moveCountStr := c.Query("moveCount", "-1")
// Return appropriate HTTP response // Non-wait path - existing behavior
if !resp.Success { if waitStr != "true" {
return c.Status(fiber.StatusNotFound).JSON(resp.Error) // Create command and execute
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
// Return appropriate HTTP response
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
} }
return c.JSON(resp.Data) // Long-polling path
moveCount, err := strconv.Atoi(moveCountStr)
if err != nil {
moveCount = -1
}
// First check if game exists and get current state
g, err := h.svc.GetGame(gameID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game not found",
Code: core.ErrGameNotFound,
})
}
currentMoveCount := len(g.Moves())
// If move count already different, return immediately
if moveCount != currentMoveCount {
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
}
// Register wait with service
ctx := c.Context()
notify := h.svc.RegisterWait(gameID, moveCount, ctx)
// Wait for notification, timeout, or client disconnect
select {
case <-notify:
// State changed or timeout, get fresh game state
cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd)
// Game might have been deleted
if !resp.Success {
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
}
return c.JSON(resp.Data)
case <-ctx.Done():
// Client disconnected
return nil
}
} }
// MakeMove submits a move // MakeMove submits a move

View File

@ -104,6 +104,9 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
// Add the new position to game history // Add the new position to game history
g.AddSnapshot(newFEN, moveUCI, nextTurn) g.AddSnapshot(newFEN, moveUCI, nextTurn)
// Notify waiting clients about the state change
s.waiter.NotifyGame(gameID, len(g.Moves()))
// Persist if storage enabled // Persist if storage enabled
if s.store != nil { if s.store != nil {
moveNumber := len(g.Moves()) moveNumber := len(g.Moves())
@ -132,6 +135,12 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
} }
g.SetState(state) g.SetState(state)
// Notify if game ended
if state != core.StateOngoing && state != core.StatePending {
s.waiter.NotifyGame(gameID, len(g.Moves()))
}
return nil return nil
} }
@ -165,6 +174,9 @@ func (s *Service) UndoMoves(gameID string, count int) error {
return err return err
} }
// Notify waiting clients about the undo
s.waiter.NotifyGame(gameID, len(g.Moves()))
// Delete undone moves from storage if enabled // Delete undone moves from storage if enabled
if s.store != nil { if s.store != nil {
remainingMoves := originalMoveCount - count remainingMoves := originalMoveCount - count
@ -183,6 +195,9 @@ func (s *Service) DeleteGame(gameID string) error {
return fmt.Errorf("game not found: %s", gameID) return fmt.Errorf("game not found: %s", gameID)
} }
// Notify and remove all waiters before deletion
s.waiter.RemoveGame(gameID)
delete(s.games, gameID) delete(s.games, gameID)
return nil return nil
} }

View File

@ -2,7 +2,11 @@
package service package service
import ( import (
"context"
"errors"
"fmt"
"sync" "sync"
"time"
"chess/internal/game" "chess/internal/game"
"chess/internal/storage" "chess/internal/storage"
@ -14,15 +18,17 @@ type Service struct {
mu sync.RWMutex mu sync.RWMutex
store *storage.Store // nil if persistence disabled store *storage.Store // nil if persistence disabled
jwtSecret []byte jwtSecret []byte
waiter *WaitRegistry // Long-polling notification registry
} }
// New creates a new service instance with optional storage // New creates a new service instance with optional storage
func New(store *storage.Store, jwtSecret []byte) (*Service, error) { func New(store *storage.Store, jwtSecret []byte) *Service {
return &Service{ return &Service{
games: make(map[string]*game.Game), games: make(map[string]*game.Game),
store: store, store: store,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
}, nil waiter: NewWaitRegistry(),
}
} }
// GetStorageHealth returns the storage component status // GetStorageHealth returns the storage component status
@ -36,8 +42,21 @@ func (s *Service) GetStorageHealth() string {
return "degraded" return "degraded"
} }
// Close cleans up resources // RegisterWait registers a client to wait for game state changes
func (s *Service) Close() error { func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
// Shutdown gracefully shuts down the service
func (s *Service) Shutdown(timeout time.Duration) error {
// Collect all errors
var errs []error
// Shutdown wait registry
if err := s.waiter.Shutdown(timeout); err != nil {
errs = append(errs, fmt.Errorf("wait registry: %w", err))
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@ -46,8 +65,10 @@ func (s *Service) Close() error {
// Close storage if enabled // Close storage if enabled
if s.store != nil { if s.store != nil {
return s.store.Close() if err := s.store.Close(); err != nil {
errs = append(errs, fmt.Errorf("storage: %w", err))
}
} }
return nil return errors.Join(errs...)
} }

178
internal/service/waiter.go Normal file
View File

@ -0,0 +1,178 @@
// FILE: internal/service/waiter.go
package service
import (
"context"
"fmt"
"sync"
"time"
)
const (
// WaitTimeout is the maximum time a client can wait for notifications
WaitTimeout = 25 * time.Second
// WaitChannelBuffer size for notification channels
WaitChannelBuffer = 1
)
// WaitRegistry manages long-polling clients waiting for game state changes
type WaitRegistry struct {
mu sync.RWMutex
waiters map[string][]*WaitRequest // gameID → waiting clients
shutdown chan struct{}
wg sync.WaitGroup
}
// WaitRequest represents a single client waiting for game updates
type WaitRequest struct {
MoveCount int // Last known move count
Notify chan struct{} // Buffered channel for notifications
Timer *time.Timer // Timeout timer
Context context.Context // Client connection context
GameID string // Game being watched
}
// NewWaitRegistry creates a new wait registry
func NewWaitRegistry() *WaitRegistry {
return &WaitRegistry{
waiters: make(map[string][]*WaitRequest),
shutdown: make(chan struct{}),
}
}
// RegisterWait registers a client to wait for game state changes
func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
w.mu.Lock()
defer w.mu.Unlock()
// Create wait request
req := &WaitRequest{
MoveCount: moveCount,
Notify: make(chan struct{}, WaitChannelBuffer),
Context: ctx,
GameID: gameID,
}
// Setup timeout timer
req.Timer = time.AfterFunc(WaitTimeout, func() {
w.handleTimeout(req)
})
// Add to waiters map
w.waiters[gameID] = append(w.waiters[gameID], req)
// Setup cleanup on context cancellation
w.wg.Add(1)
go func() {
defer w.wg.Done()
select {
case <-ctx.Done():
// Client disconnected
w.removeWaiter(gameID, req)
case <-req.Notify:
// Notification received
req.Timer.Stop()
w.removeWaiter(gameID, req)
case <-w.shutdown:
// Server shutting down
req.Timer.Stop()
close(req.Notify)
}
}()
return req.Notify
}
// NotifyGame notifies all clients waiting on a game about state change
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int) {
w.mu.RLock()
waitList := w.waiters[gameID]
w.mu.RUnlock()
if len(waitList) == 0 {
return
}
// Non-blocking notification to all waiters
for _, req := range waitList {
// Only notify if move count changed
if req.MoveCount != currentMoveCount {
select {
case req.Notify <- struct{}{}:
// Notification sent
default:
// Channel full or closed, skip slow client
}
}
}
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.Lock()
waitList := w.waiters[gameID]
delete(w.waiters, gameID)
w.mu.Unlock()
// Notify all waiters that game is gone
for _, req := range waitList {
select {
case req.Notify <- struct{}{}:
default:
}
}
}
// Shutdown gracefully shuts down the wait registry
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
close(w.shutdown)
// Wait for all goroutines with timeout
done := make(chan struct{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-time.After(timeout):
return fmt.Errorf("http wait registry shutdown failed")
}
}
// handleTimeout handles wait request timeout
func (w *WaitRegistry) handleTimeout(req *WaitRequest) {
// Send timeout notification
select {
case req.Notify <- struct{}{}:
// Timeout notification sent
default:
// Channel full or closed
}
}
// removeWaiter removes a specific waiter from the registry
func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
w.mu.Lock()
defer w.mu.Unlock()
waitList := w.waiters[gameID]
for i, waiter := range waitList {
if waiter == req {
// Remove from slice
w.waiters[gameID] = append(waitList[:i], waitList[i+1:]...)
break
}
}
// Clean up empty entries
if len(w.waiters[gameID]) == 0 {
delete(w.waiters, gameID)
}
// Stop timer if still running
req.Timer.Stop()
}

View File

@ -1,84 +1,146 @@
# Chess API Test Suite # Chess API Test Suite
This directory contains comprehensive test suites for the Chess API server, covering both API functionality and database persistence. This directory contains comprehensive test suites for the Chess API server, covering API functionality, database operations, authentication, and real-time updates.
## Prerequisites ## Prerequisites
- `jq` - JSON processor - `jq` - JSON processor
- `curl` - HTTP client - `curl` - HTTP client
- `sqlite3` - SQLite CLI (for database tests only) - `sqlite3` - SQLite CLI (for database tests)
- Compiled `chessd` binary in parent directory - `base64` - Base64 encoder (for JWT tests)
- Compiled `chessd` binary in accessible path
## Test Suites ## Running the test server
### 1. API Functionality Tests (`test-api.sh`)
Tests all API endpoints, error handling, rate limiting, and game logic.
**Running the test:**
```bash ```bash
# Start server in development mode (required for tests to pass) ./run-test-server.sh
../chessd -dev
# In another terminal, run tests
./test-api.sh
``` ```
**Coverage:** Pass binary path as first argument of the script if it's not placed in current directory `./chessd`.
- Game creation and management (HvH, HvC, CvC) Server will run with '-dev' option, enabling db WAL mode and relaxing rate limiting.
- Move validation and execution Will clean up test database and temporary files, so it's preferred for clean testing.
- Computer move triggering with "cccc" Can be used for all the tests.
- Undo functionality
- Player configuration changes
- Rate limiting (dev mode: 20 req/s)
- Security hardening and input validation
### 2. Database Persistence Tests (`test-db.sh`) ### Pre-configured Users
**Outdated: test-db.sh and test-db-server.sh currently focus on user operations.** | Username | Password | Email |
|----------|----------|-------|
| alice | AlicePass123 | alice@example.com |
| bob | BobPass456 | bob@example.com |
| charlie | CharliePass789 | - |
Tests database storage, async writes, and data integrity. ### Features
- Automatically initializes database schema
- Creates three test users
- Runs on port 8080 (API) and 9090 (Web UI)
- Development mode with relaxed rate limits
- Fixed JWT secret for consistent tokens
- Graceful shutdown on Ctrl+C
**Running the test:** ### Manual Testing Examples
```bash ```bash
# Terminal 1: Start server with database # Login as alice
./run-server-with-db.sh ../chessd curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"AlicePass123"}'
# Terminal 2: Run database tests # Create authenticated game
./test-db.sh TOKEN="<jwt-from-login>"
curl -X POST http://localhost:8080/games \
# When done, press Ctrl+C in Terminal 1 -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"white":{"type":1},"black":{"type":2,"level":10}}'
``` ```
**Coverage:** ## Test Suite Overview
- Game and move persistence
- Async write buffer behavior
- Multi-game isolation
- Undo effects on database
- WAL mode verification
- Foreign key constraints
## Important Notes | Test Suite | File | Coverage |
|------------|------|----------|
| API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts |
| Test Server | `test-db-server.sh` | Pre-populated test environment |
1. **Development Mode Required**: The server MUST be started with `-dev` flag for tests to pass. This enables: ## 1. API Functionality Tests (`test-api.sh`)
- Relaxed rate limiting (20 req/s instead of 10)
- WAL mode for SQLite (better concurrency)
2. **Database Tests**: The `run-server-with-db.sh` script automatically: Tests core game mechanics and API endpoints.
- Creates a temporary test database
- Initializes the schema
- Cleans up on exit (Ctrl+C)
3. **Test Isolation**: Each test suite can be run independently. The database tests use a separate `test.db` file that doesn't affect production data. ### Running the test
```bash
# Terminal 1: Start server in development mode
test/run-test-server.sh ./chessd
# Direct (no cleanup required): ./chessd -dev
## Troubleshooting # Terminal 2: Run API tests
test/test-api.sh
```
**Rate limiting failures:** Ensure server is running with `-dev` flag ### Coverage
**Database test failures:** Check that no other instance is using `test.db` - **Game Creation**: Human vs Human, Human vs Computer, Computer vs Computer
**Port conflicts:** Default port is 8080, ensure it's available - **Move Validation**: Legal/illegal moves, UCI notation
- **Computer Play**: Async engine moves with "cccc" trigger
- **Undo System**: Single and multiple move reversal
- **Player Configuration**: Dynamic player type changes
- **Rate Limiting**: 20 req/s in dev mode
- **Error Handling**: Invalid inputs, missing games, wrong content-types
- **Security**: Input validation, FEN injection prevention
## Exit Codes ## 2. Database & Authentication Tests (`test-db.sh`)
- `0` - All tests passed Tests user management, authentication, and persistence via API integration.
- `1` - One or more tests failed **Requires Running Server Script**
Check colored output for detailed pass/fail information for each test case. ### Running the test
```bash
# Terminal 1: Start test server with database
# Server is running with -dev option (WAL mode db)
test/test-db-server.sh ./chessd
# Terminal 2: Run API integration tests
test/test-db.sh ./chessd
```
### Coverage
- **User Registration**: Account creation, password hashing
- **Duplicate Prevention**: Username/email uniqueness
- **Authentication**: Login with JWT generation
- **Token Validation**: JWT parsing and claims verification
- **Password Security**: Argon2id hashing, complexity requirements
- **Case Sensitivity**: Case-insensitive username/email matching
- **Database Schema**: Table creation, constraints, indexes
### Test Flow
1. Creates temporary `test.db` with schema
2. Registers test users (alice, bob, charlie)
3. Tests authentication endpoints
4. Validates JWT tokens and claims
5. Tests duplicate user prevention
6. Cleans up test database
## 3. Long-Polling Tests (`test-longpoll.sh`)
Tests real-time game updates via HTTP long-polling.
### Running the test
```bash
# Terminal 1: Start server with storage
test/run-test-server.sh ./chessd
# Direct (test.db cleanup required): ./chessd -dev -storage-path test.db
# Terminal 2: Run long-polling tests
test/test-longpoll.sh
```
### Coverage
- **Basic Long-Polling**: Wait for game state changes
- **Multi-Client**: Multiple simultaneous waiters
- **Timeout Behavior**: 25-second timeout verification
- **Immediate Response**: No wait when state already changed
- **Connection Handling**: Client disconnect cleanup
- **Game Deletion**: Notification on game removal
- **Move Detection**: Accurate move count tracking
### Test Scenarios
1. **Single Waiter**: Client waits, receives update after move
2. **Multiple Waiters**: 3 clients wait, all receive notification
3. **Timeout**: Verify 25-second timeout with valid response
4. **Skip Wait**: Immediate return when moveCount outdated
5. **Disconnection**: Proper cleanup on client disconnect

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# FILE: run-test-db-server.sh # FILE: test/run-test-server.sh
set -e set -e
@ -19,7 +19,8 @@ NC='\033[0m'
# Check executable # Check executable
if [ ! -x "$CHESSD_EXEC" ]; then if [ ! -x "$CHESSD_EXEC" ]; then
echo -e "${RED}Error: chessd executable not found or not executable: $CHESSD_EXEC${NC}" echo -e "${RED}Error: chessd executable not found or not executable: $CHESSD_EXEC${NC}"
echo "Please build the application first: go build ./cmd/chessd" echo "Provide the path to chessd binary as first argument or place it in the current directory."
echo "Build the binary if not available: go build ./cmd/chessd"
exit 1 exit 1
fi fi
@ -91,11 +92,12 @@ echo " Executable: $CHESSD_EXEC"
echo " Database: $TEST_DB" echo " Database: $TEST_DB"
echo " Port: $API_PORT" echo " Port: $API_PORT"
echo " Mode: Development (WAL enabled, relaxed rate limits)" echo " Mode: Development (WAL enabled, relaxed rate limits)"
echo " Purpose: Backend for chessd tests"
echo " PID File: $PID_FILE" echo " PID File: $PID_FILE"
echo "" echo ""
echo -e "${YELLOW}Instructions:${NC}" echo -e "${YELLOW}Instructions:${NC}"
echo " 1. Server will start in foreground" echo " 1. Server will run in foreground with test database"
echo " 2. Open another terminal and run: ./test-db.sh" echo " 2. Open another terminal and run the test script or manual tests"
echo " 3. Press Ctrl+C here when testing is complete" echo " 3. Press Ctrl+C here when testing is complete"
echo "" echo ""
echo -e "${CYAN}──────────────────────────────────────────────────────────${NC}" echo -e "${CYAN}──────────────────────────────────────────────────────────${NC}"

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# FILE: test-api.sh # FILE: test/test-api.sh
# Chess API Robustness Test Suite # Chess API Robustness Test Suite
# Tests the refactored chess API with security hardening # Tests the refactored chess API with security hardening
@ -132,8 +132,9 @@ fi
print_header "Chess API Robustness Test Suite" print_header "Chess API Robustness Test Suite"
echo "Server: $BASE_URL" echo "Server: $BASE_URL"
echo "API Version: v1" echo "API Version: v1"
echo -e "${MAGENTA}⚠️ IMPORTANT: Server must be started with -dev flag for tests to pass!${NC}" echo -e "${MAGENTA} IMPORTANT: Server must be started with -dev flag for tests to pass!${NC}"
echo -e "${MAGENTA} Example: ./chessd -dev${NC}" echo -e "${MAGENTA} Start the server first: test/run-test-server.sh${NC}"
echo -e "${MAGENTA} Or directly after build: ./chessd -dev${NC}"
echo "" echo ""
echo "Starting comprehensive tests..." echo "Starting comprehensive tests..."

View File

@ -1,8 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# FILE: test-db.sh # FILE: test/test-db.sh
# Database and User Management Test Suite # Database & Authentication API Integration Test Suite
# Tests user operations, authentication, and game persistence # Tests user operations, authentication, and persistence via HTTP API
#
# REQUIRES: Server running on localhost:8080 with database storage
# Start with: test/run-test-server.sh
BASE_URL="http://localhost:8080" BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1" API_URL="${BASE_URL}/api/v1"
@ -118,29 +121,6 @@ api_request() {
return $status return $status
} }
wait_for_state() {
local game_id=$1
local target_state=$2
local token=$3
local max_attempts=${4:-20}
local attempt=0
while [ $attempt -lt $max_attempts ]; do
local response=$(api_request GET "$API_URL/games/$game_id" \
-H "Authorization: Bearer $token")
local current_state=$(echo "$response" | jq -r '.state' 2>/dev/null)
if [ "$current_state" = "$target_state" ] || [[ "$target_state" = "!pending" && "$current_state" != "pending" ]]; then
return 0
fi
((attempt++))
sleep 0.1
done
return 1
}
# Check dependencies # Check dependencies
for cmd in jq sqlite3 curl; do for cmd in jq sqlite3 curl; do
if ! command -v $cmd &> /dev/null; then if ! command -v $cmd &> /dev/null; then
@ -155,10 +135,18 @@ if [ ! -x "$CHESSD_EXEC" ]; then
exit 1 exit 1
fi fi
# Verify server connectivity before running tests
if ! curl -sf "$BASE_URL/health" > /dev/null 2>&1; then
echo -e "${RED}Error: Cannot connect to server at $BASE_URL${NC}"
echo "Start the server first: test/run-test-server.sh"
exit 1
fi
# Start tests # Start tests
print_header "Database & User Management Test Suite" print_header "Database & User Management Test Suite"
echo "Server: $BASE_URL"
echo "Executable: $CHESSD_EXEC" echo "Executable: $CHESSD_EXEC"
echo "Database: $TEST_DB" echo "Test Database (server-managed): $TEST_DB"
echo "" echo ""
# ============================================================================== # ==============================================================================

213
test/test-longpoll.sh Executable file
View File

@ -0,0 +1,213 @@
#!/bin/bash
# test-longpoll.sh - Test long-polling functionality
set -e
# Configuration
API_URL="${API_URL:-http://localhost:8080/api/v1}" # Updated to include /api/v1
VERBOSE="${VERBOSE:-false}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_test() {
echo -e "${YELLOW}[TEST]${NC} $1"
}
# Function to create a test game
create_game() {
local white_type=$1
local black_type=$2
response=$(curl -s -X POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d "{\"white\": {\"type\": $white_type}, \"black\": {\"type\": $black_type}}")
echo "$response" | jq -r '.gameId'
}
# Test 1: Basic long-polling
test_basic_longpoll() {
log_test "Basic long-polling functionality"
# Create a human vs human game
GAME_ID=$(create_game 1 1)
log_info "Created game: $GAME_ID"
# Start background poller
log_info "Starting long-poll request (25s timeout)..."
curl -s -X GET "$API_URL/games/$GAME_ID?wait=true&moveCount=0" > /tmp/poll_result.json &
POLL_PID=$!
# Wait a moment then make a move
sleep 2
log_info "Making move e2e4..."
curl -s -X POST "$API_URL/games/$GAME_ID/moves" \
-H "Content-Type: application/json" \
-d '{"move":"e2e4"}' > /dev/null
# Wait for poller to complete
if wait $POLL_PID; then
# Check if we got the updated game state
moves=$(cat /tmp/poll_result.json | jq -r '.moves | length')
if [ "$moves" = "1" ]; then
log_info "✓ Long-poll received notification successfully"
else
log_error "✗ Long-poll did not receive correct state"
exit 1
fi
else
log_error "✗ Long-poll request failed"
exit 1
fi
# Cleanup
curl -s -X DELETE "$API_URL/games/$GAME_ID" > /dev/null
}
# Test 2: Multiple concurrent waiters
test_multiple_waiters() {
log_test "Multiple concurrent waiters"
# Create a game
GAME_ID=$(create_game 1 1)
log_info "Created game: $GAME_ID"
# Start multiple background pollers
log_info "Starting 3 concurrent long-poll requests..."
curl -s -X GET "$API_URL/games/$GAME_ID?wait=true&moveCount=0" > /tmp/poll1.json &
PID1=$!
curl -s -X GET "$API_URL/games/$GAME_ID?wait=true&moveCount=0" > /tmp/poll2.json &
PID2=$!
curl -s -X GET "$API_URL/games/$GAME_ID?wait=true&moveCount=0" > /tmp/poll3.json &
PID3=$!
# Make a move
sleep 1
log_info "Making move e2e4..."
curl -s -X POST "$API_URL/games/$GAME_ID/moves" \
-H "Content-Type: application/json" \
-d '{"move":"e2e4"}' > /dev/null
# Wait for all pollers
wait $PID1 $PID2 $PID3
# Check all received the update
success=true
for i in 1 2 3; do
moves=$(cat /tmp/poll$i.json | jq -r '.moves | length')
if [ "$moves" != "1" ]; then
log_error "✗ Poller $i did not receive update"
success=false
fi
done
if $success; then
log_info "✓ All waiters received notifications"
else
exit 1
fi
# Cleanup
curl -s -X DELETE "$API_URL/games/$GAME_ID" > /dev/null
}
# Test 3: Timeout behavior
test_timeout() {
log_test "Timeout behavior (this takes 25 seconds)"
# Create a game
GAME_ID=$(create_game 1 1)
log_info "Created game: $GAME_ID"
# Start poller without making any moves
log_info "Starting long-poll that will timeout..."
start_time=$(date +%s)
curl -s -X GET "$API_URL/games/$GAME_ID?wait=true&moveCount=0" > /tmp/timeout.json
end_time=$(date +%s)
elapsed=$((end_time - start_time))
# Check timeout was ~25 seconds
if [ "$elapsed" -ge 24 ] && [ "$elapsed" -le 26 ]; then
log_info "✓ Request timed out after ~25 seconds"
else
log_error "✗ Timeout was $elapsed seconds (expected ~25)"
exit 1
fi
# Should still get valid game state
game_id=$(cat /tmp/timeout.json | jq -r '.gameId')
if [ "$game_id" = "$GAME_ID" ]; then
log_info "✓ Timeout response contains valid game state"
else
log_error "✗ Timeout response invalid"
exit 1
fi
# Cleanup
curl -s -X DELETE "$API_URL/games/$GAME_ID" > /dev/null
}
# Test 4: Immediate response when state already changed
test_immediate_response() {
log_test "Immediate response when state already changed"
# Create game and make a move
GAME_ID=$(create_game 1 1)
log_info "Created game: $GAME_ID"
curl -s -X POST "$API_URL/games/$GAME_ID/moves" \
-H "Content-Type: application/json" \
-d '{"move":"e2e4"}' > /dev/null
# Poll with moveCount=0 (should return immediately)
log_info "Polling with outdated move count..."
start_time=$(date +%s)
response=$(curl -s -X GET "$API_URL/games/$GAME_ID?wait=true&moveCount=0")
end_time=$(date +%s)
elapsed=$((end_time - start_time))
if [ "$elapsed" -le 1 ]; then
log_info "✓ Immediate response when move count differs"
else
log_error "✗ Response took $elapsed seconds (should be immediate)"
exit 1
fi
# Cleanup
curl -s -X DELETE "$API_URL/games/$GAME_ID" > /dev/null
}
# Run tests
log_info "Starting long-poll tests against $API_URL"
echo ""
test_basic_longpoll
echo ""
test_multiple_waiters
echo ""
test_immediate_response
echo ""
if [ "${SKIP_TIMEOUT_TEST:-false}" = "false" ]; then
test_timeout
else
log_info "Skipping timeout test (set SKIP_TIMEOUT_TEST=false to run)"
fi
echo ""
log_info "All tests passed! ✓"