v0.6.0 multi-user game support with longpoll, tests and doc updated
This commit is contained in:
@ -1,6 +1,8 @@
|
||||
// FILE: cmd/chessd/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
@ -18,6 +20,10 @@ import (
|
||||
"chess/internal/webserver"
|
||||
)
|
||||
|
||||
const (
|
||||
gracefulShutdownTimeout = time.Second * 5
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for CLI database commands
|
||||
if len(os.Args) > 1 && os.Args[1] == "db" {
|
||||
@ -93,22 +99,14 @@ func main() {
|
||||
}
|
||||
|
||||
// 2. Initialize the Service with optional storage and auth
|
||||
svc, err := service.New(store, jwtSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize service: %v", err)
|
||||
}
|
||||
defer svc.Close()
|
||||
svc := service.New(store, jwtSecret)
|
||||
|
||||
// 3. Initialize the Processor (Orchestrator), injecting the service
|
||||
proc, err := processor.New(svc)
|
||||
if err != nil {
|
||||
svc.Shutdown(gracefulShutdownTimeout)
|
||||
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
|
||||
app := http.NewFiberApp(proc, svc, *dev)
|
||||
@ -164,10 +162,24 @@ func main() {
|
||||
|
||||
log.Println("Shutting down servers...")
|
||||
|
||||
// Graceful shutdown with a timeout
|
||||
if err := app.ShutdownWithTimeout(5 * time.Second); err != nil {
|
||||
// Graceful shutdown of service (includes wait registry)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
17
doc/api.md
17
doc/api.md
@ -154,6 +154,23 @@ Note: When authenticated, human player IDs match the user's ID. Anonymous player
|
||||
|
||||
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
|
||||
`POST /games/{gameId}/moves`
|
||||
|
||||
|
||||
@ -11,15 +11,18 @@ Central command handler containing business logic. Single `Execute(Command)` ent
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
#### Long-Polling Registry (`internal/service/waiter.go`)
|
||||
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
|
||||
- **JWT Management**: HS256 tokens with 7-day expiration
|
||||
- **User Operations**: Registration, login, profile management
|
||||
- **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
|
||||
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
||||
- **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
|
||||
5. Callback updates game state via service
|
||||
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
|
||||
|
||||
|
||||
@ -158,6 +158,9 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
|
||||
|
||||
# Run test server with sample users
|
||||
./test/test-db-server.sh
|
||||
|
||||
# Test real-time game updates via long-polling
|
||||
./test/test-longpoll.sh
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@ -170,6 +173,8 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
|
||||
- Write queue: 1000 operations (internal/storage/storage.go)
|
||||
- DB connections: 25 max, 5 idle (internal/storage/storage.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
|
||||
- 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
|
||||
- Fixed worker pool size for engine calculations
|
||||
- No real-time game updates (polling required)
|
||||
- Long-polling limited to 25 seconds per request
|
||||
- REST API only
|
||||
@ -3,6 +3,7 @@ package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -295,6 +296,12 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Check for long-polling parameters
|
||||
waitStr := c.Query("wait", "false")
|
||||
moveCountStr := c.Query("moveCount", "-1")
|
||||
|
||||
// Non-wait path - existing behavior
|
||||
if waitStr != "true" {
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
@ -307,6 +314,57 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
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
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
@ -104,6 +104,9 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||
// Add the new position to game history
|
||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||
|
||||
// Notify waiting clients about the state change
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()))
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
moveNumber := len(g.Moves())
|
||||
@ -132,6 +135,12 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
}
|
||||
|
||||
g.SetState(state)
|
||||
|
||||
// Notify if game ended
|
||||
if state != core.StateOngoing && state != core.StatePending {
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -165,6 +174,9 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Notify waiting clients about the undo
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()))
|
||||
|
||||
// Delete undone moves from storage if enabled
|
||||
if s.store != nil {
|
||||
remainingMoves := originalMoveCount - count
|
||||
@ -183,6 +195,9 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
// Notify and remove all waiters before deletion
|
||||
s.waiter.RemoveGame(gameID)
|
||||
|
||||
delete(s.games, gameID)
|
||||
return nil
|
||||
}
|
||||
@ -2,7 +2,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/game"
|
||||
"chess/internal/storage"
|
||||
@ -14,15 +18,17 @@ type Service struct {
|
||||
mu sync.RWMutex
|
||||
store *storage.Store // nil if persistence disabled
|
||||
jwtSecret []byte
|
||||
waiter *WaitRegistry // Long-polling notification registry
|
||||
}
|
||||
|
||||
// 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{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
}, nil
|
||||
waiter: NewWaitRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetStorageHealth returns the storage component status
|
||||
@ -36,8 +42,21 @@ func (s *Service) GetStorageHealth() string {
|
||||
return "degraded"
|
||||
}
|
||||
|
||||
// Close cleans up resources
|
||||
func (s *Service) Close() error {
|
||||
// RegisterWait registers a client to wait for game state changes
|
||||
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()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@ -46,8 +65,10 @@ func (s *Service) Close() error {
|
||||
|
||||
// Close storage if enabled
|
||||
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
178
internal/service/waiter.go
Normal 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()
|
||||
}
|
||||
176
test/README.md
176
test/README.md
@ -1,84 +1,146 @@
|
||||
# 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
|
||||
|
||||
- `jq` - JSON processor
|
||||
- `curl` - HTTP client
|
||||
- `sqlite3` - SQLite CLI (for database tests only)
|
||||
- Compiled `chessd` binary in parent directory
|
||||
- `sqlite3` - SQLite CLI (for database tests)
|
||||
- `base64` - Base64 encoder (for JWT tests)
|
||||
- Compiled `chessd` binary in accessible path
|
||||
|
||||
## Test Suites
|
||||
|
||||
### 1. API Functionality Tests (`test-api.sh`)
|
||||
|
||||
Tests all API endpoints, error handling, rate limiting, and game logic.
|
||||
|
||||
**Running the test:**
|
||||
## Running the test server
|
||||
```bash
|
||||
# Start server in development mode (required for tests to pass)
|
||||
../chessd -dev
|
||||
|
||||
# In another terminal, run tests
|
||||
./test-api.sh
|
||||
./run-test-server.sh
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- Game creation and management (HvH, HvC, CvC)
|
||||
- Move validation and execution
|
||||
- Computer move triggering with "cccc"
|
||||
- Undo functionality
|
||||
- Player configuration changes
|
||||
- Rate limiting (dev mode: 20 req/s)
|
||||
- Security hardening and input validation
|
||||
Pass binary path as first argument of the script if it's not placed in current directory `./chessd`.
|
||||
Server will run with '-dev' option, enabling db WAL mode and relaxing rate limiting.
|
||||
Will clean up test database and temporary files, so it's preferred for clean testing.
|
||||
Can be used for all the tests.
|
||||
|
||||
### 2. Database Persistence Tests (`test-db.sh`)
|
||||
**Outdated: test-db.sh and test-db-server.sh currently focus on user operations.**
|
||||
### Pre-configured Users
|
||||
| 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
|
||||
# Terminal 1: Start server with database
|
||||
./run-server-with-db.sh ../chessd
|
||||
# Login as alice
|
||||
curl -X POST http://localhost:8080/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"alice","password":"AlicePass123"}'
|
||||
|
||||
# Terminal 2: Run database tests
|
||||
./test-db.sh
|
||||
|
||||
# When done, press Ctrl+C in Terminal 1
|
||||
# Create authenticated game
|
||||
TOKEN="<jwt-from-login>"
|
||||
curl -X POST http://localhost:8080/games \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"white":{"type":1},"black":{"type":2,"level":10}}'
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- Game and move persistence
|
||||
- Async write buffer behavior
|
||||
- Multi-game isolation
|
||||
- Undo effects on database
|
||||
- WAL mode verification
|
||||
- Foreign key constraints
|
||||
## Test Suite Overview
|
||||
|
||||
## 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:
|
||||
- Relaxed rate limiting (20 req/s instead of 10)
|
||||
- WAL mode for SQLite (better concurrency)
|
||||
## 1. API Functionality Tests (`test-api.sh`)
|
||||
|
||||
2. **Database Tests**: The `run-server-with-db.sh` script automatically:
|
||||
- Creates a temporary test database
|
||||
- Initializes the schema
|
||||
- Cleans up on exit (Ctrl+C)
|
||||
Tests core game mechanics and API endpoints.
|
||||
|
||||
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
|
||||
**Database test failures:** Check that no other instance is using `test.db`
|
||||
**Port conflicts:** Default port is 8080, ensure it's available
|
||||
### Coverage
|
||||
- **Game Creation**: Human vs Human, Human vs Computer, Computer vs Computer
|
||||
- **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
|
||||
- `1` - One or more tests failed
|
||||
Tests user management, authentication, and persistence via API integration.
|
||||
**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
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: run-test-db-server.sh
|
||||
# FILE: test/run-test-server.sh
|
||||
|
||||
set -e
|
||||
|
||||
@ -19,7 +19,8 @@ NC='\033[0m'
|
||||
# Check executable
|
||||
if [ ! -x "$CHESSD_EXEC" ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
@ -91,11 +92,12 @@ echo " Executable: $CHESSD_EXEC"
|
||||
echo " Database: $TEST_DB"
|
||||
echo " Port: $API_PORT"
|
||||
echo " Mode: Development (WAL enabled, relaxed rate limits)"
|
||||
echo " Purpose: Backend for chessd tests"
|
||||
echo " PID File: $PID_FILE"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Instructions:${NC}"
|
||||
echo " 1. Server will start in foreground"
|
||||
echo " 2. Open another terminal and run: ./test-db.sh"
|
||||
echo " 1. Server will run in foreground with test database"
|
||||
echo " 2. Open another terminal and run the test script or manual tests"
|
||||
echo " 3. Press Ctrl+C here when testing is complete"
|
||||
echo ""
|
||||
echo -e "${CYAN}──────────────────────────────────────────────────────────${NC}"
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: test-api.sh
|
||||
# FILE: test/test-api.sh
|
||||
|
||||
# Chess API Robustness Test Suite
|
||||
# Tests the refactored chess API with security hardening
|
||||
@ -132,8 +132,9 @@ fi
|
||||
print_header "Chess API Robustness Test Suite"
|
||||
echo "Server: $BASE_URL"
|
||||
echo "API Version: v1"
|
||||
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} IMPORTANT: Server must be started with -dev flag for tests to pass!${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 "Starting comprehensive tests..."
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: test-db.sh
|
||||
# FILE: test/test-db.sh
|
||||
|
||||
# Database and User Management Test Suite
|
||||
# Tests user operations, authentication, and game persistence
|
||||
# Database & Authentication API Integration Test Suite
|
||||
# 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"
|
||||
API_URL="${BASE_URL}/api/v1"
|
||||
@ -118,29 +121,6 @@ api_request() {
|
||||
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
|
||||
for cmd in jq sqlite3 curl; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
@ -155,10 +135,18 @@ if [ ! -x "$CHESSD_EXEC" ]; then
|
||||
exit 1
|
||||
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
|
||||
print_header "Database & User Management Test Suite"
|
||||
echo "Server: $BASE_URL"
|
||||
echo "Executable: $CHESSD_EXEC"
|
||||
echo "Database: $TEST_DB"
|
||||
echo "Test Database (server-managed): $TEST_DB"
|
||||
echo ""
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
213
test/test-longpoll.sh
Executable file
213
test/test-longpoll.sh
Executable 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! ✓"
|
||||
Reference in New Issue
Block a user