v0.3.0 storage with sqlite3 and pid management added

This commit is contained in:
2025-10-30 09:52:23 -04:00
parent 0ad608293e
commit b79900b1bf
18 changed files with 1570 additions and 69 deletions

1
.gitignore vendored
View File

@ -2,4 +2,5 @@
dev
log
bin
db
build.sh

View File

@ -12,21 +12,25 @@
# Chess
Go backend server providing a RESTful API for chess gameplay. Integrates Stockfish engine for move validation and AI opponents.
Go backend server providing a RESTful API for chess gameplay. Integrates Stockfish engine for move validation and computer opponents.
## Features
- RESTful API for chess operations
- Stockfish engine integration for validation and AI
- Stockfish engine integration for validation
- Human vs human, human vs computer, computer vs computer modes
- Custom FEN position support
- Asynchronous AI move calculation
- Configurable AI strength and thinking time
- Asynchronous engine move calculation
- Configurable engine strength and thinking time
- Optional SQLite persistence with async writes
- PID file management for singleton enforcement
- Database CLI for storage administration
## Requirements
- Go 1.24+
- Stockfish chess engine (`stockfish` in PATH)
- SQLite3 (for persistence features)
### Installation
@ -45,14 +49,17 @@ git clone https://git.lixen.com/lixen/chess
cd chess
go build ./cmd/chessd
# Standard mode (1 request/second/IP)
./chessd
# Standard mode with persistence
./chessd -storage-path chess.db
# Development mode (10 requests/second/IP)
./chessd -dev
# Development mode with PID lock on localhost custom port
./chessd -dev -pid /tmp/chessd.pid -pid-lock -port 9090
# Run tests (requires dev mode)
./test/test-api.sh
# Database initialization (doesn't run server)
./chessd db init -path chess.db
# Query stored games (doesn't run server)
./chessd db query -path chess.db -gameId "*"
```
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints.

131
cmd/chessd/cli/cli.go Normal file
View File

@ -0,0 +1,131 @@
// FILE: cmd/chessd/cli/cli.go
package cli
import (
"chess/internal/storage"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
)
// Run is the entry point for the CLI mini-app
func Run(args []string) error {
if len(args) == 0 {
return fmt.Errorf("subcommand required: init, delete, or query")
}
switch args[0] {
case "init":
return runInit(args[1:])
case "delete":
return runDelete(args[1:])
case "query":
return runQuery(args[1:])
default:
return fmt.Errorf("unknown subcommand: %s", args[0])
}
}
func runInit(args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to create store: %w", err)
}
defer store.Close()
if err := store.InitDB(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
fmt.Printf("Database initialized at: %s\n", *path)
return nil
}
func runDelete(args []string) error {
fs := flag.NewFlagSet("delete", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
if err := store.DeleteDB(); err != nil {
return fmt.Errorf("failed to delete database: %w", err)
}
fmt.Printf("Database deleted: %s\n", *path)
return nil
}
func runQuery(args []string) error {
fs := flag.NewFlagSet("query", flag.ContinueOnError)
path := fs.String("path", "", "Database file path (required)")
gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)")
playerID := fs.String("playerId", "", "Player ID to filter (optional, * for all)")
if err := fs.Parse(args); err != nil {
return err
}
if *path == "" {
return fmt.Errorf("database path required")
}
store, err := storage.NewStore(*path, false)
if err != nil {
return fmt.Errorf("failed to open store: %w", err)
}
defer store.Close()
games, err := store.QueryGames(*gameID, *playerID)
if err != nil {
return fmt.Errorf("query failed: %w", err)
}
if len(games) == 0 {
fmt.Println("No games found")
return nil
}
// Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tStart Time")
fmt.Fprintln(w, strings.Repeat("-", 80))
for _, g := range games {
whiteInfo := fmt.Sprintf("%s (T%d)", g.WhitePlayerID[:8], g.WhiteType)
blackInfo := fmt.Sprintf("%s (T%d)", g.BlackPlayerID[:8], g.BlackType)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
g.GameID[:8]+"...",
whiteInfo,
blackInfo,
g.StartTimeUTC.Format("2006-01-02 15:04:05"),
)
}
w.Flush()
fmt.Printf("\nFound %d game(s)\n", len(games))
return nil
}

View File

@ -2,6 +2,11 @@
package main
import (
"chess/cmd/chessd/cli"
"chess/internal/http"
"chess/internal/processor"
"chess/internal/service"
"chess/internal/storage"
"flag"
"fmt"
"log"
@ -9,29 +14,69 @@ import (
"os/signal"
"syscall"
"time"
"chess/internal/http"
"chess/internal/processor"
"chess/internal/service"
)
func main() {
// Check for CLI database commands
if len(os.Args) > 1 && os.Args[1] == "db" {
if err := cli.Run(os.Args[2:]); err != nil {
log.Fatalf("CLI error: %v", err)
}
os.Exit(0)
}
// 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)")
host = flag.String("host", "localhost", "Server host")
port = flag.Int("port", 8080, "Server port")
dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)")
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
pidPath = flag.String("pid", "", "Optional path to write PID file")
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
)
flag.Parse()
// 1. Initialize the Service (Pure State Manager)
svc, err := service.New()
// Validate PID flags
if *pidLock && *pidPath == "" {
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
}
// Manage PID file if requested
if *pidPath != "" {
cleanup, err := managePIDFile(*pidPath, *pidLock)
if err != nil {
log.Fatalf("Failed to manage PID file: %v", err)
}
defer cleanup()
log.Printf("PID file created at: %s (lock: %v)", *pidPath, *pidLock)
}
// 1. Initialize Storage (optional)
var store *storage.Store
if *storagePath != "" {
log.Printf("Initializing persistent storage at: %s", *storagePath)
var err error
store, err = storage.NewStore(*storagePath, *dev) // CHANGED: Added *dev parameter
if err != nil {
log.Fatalf("Failed to initialize storage: %v", err)
}
defer func() {
if err := store.Close(); err != nil {
log.Printf("Warning: failed to close storage cleanly: %v", err)
}
}()
} else {
log.Printf("Persistent storage disabled (use -storage-path to enable)")
}
// 2. Initialize the Service with optional storage
svc, err := service.New(store)
if err != nil {
log.Fatalf("Failed to initialize service: %v", err)
}
defer svc.Close()
// 2. Initialize the Processor (Orchestrator), injecting the service
// 3. Initialize the Processor (Orchestrator), injecting the service
proc, err := processor.New(svc)
if err != nil {
log.Fatalf("Failed to initialize processor: %v", err)
@ -42,8 +87,8 @@ func main() {
}
}()
// 3. Initialize the Fiber App/HTTP Handler, injecting the processor
app := http.NewFiberApp(proc, *dev)
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
app := http.NewFiberApp(proc, svc, *dev)
// Server configuration
addr := fmt.Sprintf("%s:%d", *host, *port)
@ -54,9 +99,14 @@ func main() {
log.Printf("Listening on: http://%s", addr)
log.Printf("API Version: v1")
if *dev {
log.Printf("Rate Limit: 10 requests/second per IP (DEV MODE)")
log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)")
} else {
log.Printf("Rate Limit: 1 request/second per IP")
log.Printf("Rate Limit: 10 requests/second per IP")
}
if *storagePath != "" {
log.Printf("Storage: Enabled (%s)", *storagePath)
} else {
log.Printf("Storage: Disabled")
}
log.Printf("Endpoints: http://%s/api/v1/games", addr)
log.Printf("Health: http://%s/health", addr)

106
cmd/chessd/pid.go Normal file
View File

@ -0,0 +1,106 @@
// FILE: cmd/chessd/pid.go
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
)
// managePIDFile creates and manages a PID file with optional locking.
// Returns a cleanup function that must be called on exit.
func managePIDFile(path string, lock bool) (func(), error) {
// Open/create PID file with exclusive create first attempt
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if !os.IsExist(err) {
return nil, fmt.Errorf("cannot create PID file: %w", err)
}
// File exists - check if stale
if lock {
if err := checkStalePID(path); err != nil {
return nil, err
}
}
// Reopen for writing (truncate existing content)
file, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return nil, fmt.Errorf("cannot open PID file: %w", err)
}
}
// Acquire exclusive lock if requested
if lock {
if err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
file.Close()
if errors.Is(err, syscall.EWOULDBLOCK) {
return nil, fmt.Errorf("cannot acquire lock: another instance is running")
}
return nil, fmt.Errorf("lock failed: %w", err)
}
}
// Write current PID
pid := os.Getpid()
if _, err = fmt.Fprintf(file, "%d\n", pid); err != nil {
file.Close()
os.Remove(path)
return nil, fmt.Errorf("cannot write PID: %w", err)
}
// Sync to ensure PID is written
if err = file.Sync(); err != nil {
file.Close()
os.Remove(path)
return nil, fmt.Errorf("cannot sync PID file: %w", err)
}
// Return cleanup function
cleanup := func() {
if lock {
// Release lock explicitly, file close works too
syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
}
file.Close()
os.Remove(path)
}
return cleanup, nil
}
// checkStalePID reads an existing PID file and checks if the process is running
func checkStalePID(path string) error {
// Try to read existing PID
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("cannot read existing PID file: %w", err)
}
pidStr := string(data)
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
if err != nil {
// Corrupted PID file
return fmt.Errorf("corrupted PID file (contains: %q)", pidStr)
}
// Check if process exists using kill(0), never errors on Unix
proc, _ := os.FindProcess(pid)
// Send signal 0 to check if process exists
if err = proc.Signal(syscall.Signal(0)); err != nil {
// Process doesn't exist or we don't have permission
if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
return fmt.Errorf("stale PID file found for defunct process %d", pid)
}
// Process exists but we can't signal it (different user?)
return fmt.Errorf("process %d exists but cannot verify ownership: %v", pid, err)
}
// Process is running
return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid)
}

View File

@ -6,6 +6,25 @@ Content-Type: `application/json` (required for POST/PUT)
## Endpoints
### Health Check
`GET /health`
Returns server status.
**Response (200):**
```json
{
"status": "healthy",
"time": 1699123456,
"storage": "ok"
}
```
Storage states:
- `"disabled"` - No storage path configured
- `"ok"` - Database operational
- `"degraded"` - Write failures detected, operating memory-only
### Create Game
`POST /games`
@ -29,8 +48,8 @@ Creates new game with specified players.
```
- `type` (integer, required): 1=human, 2=computer
- `level` (integer, 0-20): AI skill level for computer players
- `searchTime` (integer, 100-10000ms): AI thinking time for computer players
- `level` (integer, 0-20): Engine skill level for computer players
- `searchTime` (integer, 100-10000ms): Engine thinking time for computer players
- `fen` (string): Starting position in FEN notation (default: standard position)
**Response (201):**

View File

@ -9,13 +9,17 @@ Fiber web server handling HTTP requests/responses. Implements routing, rate limi
Central command handler containing business logic. Single `Execute(Command)` entry point decouples transport from logic. Uses synchronous UCI engine for validation, asynchronous EngineQueue for computer moves.
### Service Layer (`internal/service`)
In-memory state storage without chess logic. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, and player configuration.
In-memory state storage without chess logic. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, and player configuration. Coordinates with storage layer for optional persistence.
### Storage Layer (`internal/storage`)
Optional SQLite persistence with async write pattern. Buffered channel (1000 ops) processes writes sequentially in background. 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
- **Board** (`internal/board`): FEN parsing and ASCII generation
- **Core** (`internal/core`): Shared types, API models, error constants
- **CLI** (`cmd/chessd/cli`): Database management commands
## Request Flow
@ -35,12 +39,28 @@ In-memory state storage without chess logic. Thread-safe game map protected by R
5. Callback updates game state via service
6. Client polls for completion
## Persistence Flow
### Write Operations
1. Service layer calls storage method (RecordNewGame, RecordMove, DeleteUndoneMoves)
2. Operation queued to buffered channel (non-blocking)
3. Writer goroutine processes queue sequentially
4. Transactions ensure atomicity
5. Failures trigger degradation to memory-only mode
### Query Operations
1. CLI invokes Store.QueryGames with filters
2. Direct database read (no queue)
3. Results formatted as tabular output
## Concurrency
- **HTTP Server**: Fiber handles concurrent connections
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
- **Validation Engine**: Single mutex-protected instance for synchronous validation
- **Storage Writer**: Single goroutine processes write queue sequentially
- **PID Lock**: File-based exclusive lock prevents multiple instances
## Data Structures
@ -59,3 +79,31 @@ Commands encapsulate operations with type and arguments, processed by single Exe
### Player Configuration
Players identified by UUID, configured with type (human/computer), skill level, and search time.
### Storage Schema
```sql
games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT,
white_player_id TEXT,
white_type INTEGER,
white_level INTEGER,
white_search_time INTEGER,
black_player_id TEXT,
black_type INTEGER,
black_level INTEGER,
black_search_time INTEGER,
start_time_utc DATETIME
)
moves (
move_id INTEGER PRIMARY KEY,
game_id TEXT,
move_number INTEGER,
move_uci TEXT,
fen_after_move TEXT,
player_color TEXT,
move_time_utc DATETIME,
FOREIGN KEY (game_id) REFERENCES games(game_id)
)
```

View File

@ -21,21 +21,49 @@ go build ./cmd/chessd
- `-host`: Server host (default: localhost)
- `-port`: Server port (default: 8080)
- `-dev`: Development mode with relaxed rate limits
- `-storage-path`: SQLite database file path (enables persistence)
- `-pid`: PID file path for process tracking
- `-pid-lock`: Enable exclusive locking (requires -pid)
### Modes
```bash
# Production (1 req/s rate limit)
# In-memory only
./chessd
# Development (10 req/s rate limit)
./chessd -dev
# With persistence
./chessd -storage-path ./db/chess.db
# Singleton enforcement (requires same PID file path across instances)
./chessd -pid /var/run/chessd.pid -pid-lock
# Development with all features
./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid
```
## Database Management
### CLI Commands
```bash
# Initialize database schema
./chessd db init -path chess.db
# Query games
./chessd db query -path chess.db [-gameId ID] [-playerId ID]
# Delete database
./chessd db delete -path chess.db
```
Query parameters accept `*` for all records (default if omitted) or specific IDs for filtering.
## Project Structure
```
chess/
├── cmd/chessd/ # Entry point
├── cmd/chessd/
│ ├── main.go # Entry point
│ ├── pid.go # PID file management
│ └── cli/ # Database CLI
├── internal/
│ ├── board/ # FEN/ASCII operations
│ ├── core/ # Shared types
@ -43,28 +71,14 @@ chess/
│ ├── game/ # Game state
│ ├── http/ # Fiber handlers
│ ├── processor/ # Command processing
── service/ # State management
── service/ # State management
│ └── storage/ # SQLite persistence
└── test/ # Test scripts
```
## Testing
```bash
# Unit tests
go test ./...
# API tests (requires dev mode)
./chessd -dev &
./test/test-api.sh
```
Test script validates:
- Basic CRUD operations
- Computer move triggering ("cccc" mechanism)
- Pending state protection
- Rate limiting
- Input validation
- Error handling
See [test documentation](../test/README.md) for details.
## Configuration
@ -73,6 +87,20 @@ Test script validates:
- Worker count: 2 (internal/processor/processor.go)
- Queue capacity: 100 (internal/processor/queue.go)
- Min search time: 100ms (internal/processor/processor.go)
- Write queue: 1000 operations (internal/storage/storage.go)
- DB connections: 25 max, 5 idle (internal/storage/storage.go)
### Storage Configuration
- WAL mode enabled in development for concurrency
- Foreign key constraints enforced
- Async write pattern with 2-second drain on shutdown
- Degradation to memory-only on write failures
### PID Management
- Singleton enforcement requires same PID file path - all instances must use the same -pid value
- Stale PID detection via signal 0 checking
- Exclusive file locking with LOCK_EX|LOCK_NB
- Automatic cleanup on graceful shutdown
### Validation Rules
- Player type: 1 (human) or 2 (computer)

1
go.mod
View File

@ -6,6 +6,7 @@ require (
github.com/go-playground/validator/v10 v10.28.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.32
)
require (

4
go.sum
View File

@ -6,8 +6,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuh
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -32,6 +30,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

View File

@ -4,6 +4,7 @@ package http
import (
"chess/internal/core"
"chess/internal/processor"
"chess/internal/service"
"fmt"
"strings"
"time"
@ -19,15 +20,16 @@ const rateLimitRate = 10 // req/sec
type HTTPHandler struct {
proc *processor.Processor
svc *service.Service
}
func NewHTTPHandler(proc *processor.Processor) *HTTPHandler {
return &HTTPHandler{proc: proc}
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, devMode bool) *fiber.App {
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc)
h := NewHTTPHandler(proc, svc)
// Initialize Fiber app
app := fiber.New(fiber.Config{
@ -146,11 +148,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
return c.Status(code).JSON(response)
}
// Health check endpoint
// Health check endpoint with storage status
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"time": time.Now().Unix(),
"status": "healthy",
"time": time.Now().Unix(),
"storage": h.svc.GetStorageHealth(),
})
}

View File

@ -4,24 +4,27 @@ package service
import (
"fmt"
"sync"
"time"
"chess/internal/core"
"chess/internal/game"
"chess/internal/storage"
"github.com/google/uuid"
)
// Service is a pure state manager for chess games
// It has NO knowledge of chess rules or engine interactions
// Service is a pure state manager for chess games with optional persistence
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
store *storage.Store // nil if persistence disabled
}
// New creates a new service instance
func New() (*Service, error) {
// New creates a new service instance with optional storage
func New(store *storage.Store) (*Service, error) {
return &Service{
games: make(map[string]*game.Game),
store: store,
}, nil
}
@ -39,6 +42,25 @@ func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConf
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
// Persist if storage enabled
if s.store != nil {
record := storage.GameRecord{
GameID: id,
InitialFEN: initialFEN,
WhitePlayerID: whitePlayer.ID,
WhiteType: int(whitePlayer.Type),
WhiteLevel: whitePlayer.Level,
WhiteSearchTime: whitePlayer.SearchTime,
BlackPlayerID: blackPlayer.ID,
BlackType: int(blackPlayer.Type),
BlackLevel: blackPlayer.Level,
BlackSearchTime: blackPlayer.SearchTime,
StartTimeUTC: time.Now().UTC(),
}
s.store.RecordNewGame(record)
}
return nil
}
@ -76,11 +98,19 @@ func (s *Service) GetGame(gameID string) (*game.Game, error) {
// GenerateGameID creates a new unique game ID
func (s *Service) GenerateGameID() string {
return uuid.New().String()
s.mu.RLock()
defer s.mu.RUnlock()
// Ensure UUID uniqueness (handle potential conflicts)
for {
id := uuid.New().String()
if _, exists := s.games[id]; !exists {
return id
}
}
}
// ApplyMove adds a validated move to the game history
// The processor has already validated this move and calculated the new FEN
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
s.mu.Lock()
defer s.mu.Unlock()
@ -97,6 +127,20 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
// Add the new position to game history
g.AddSnapshot(newFEN, moveUCI, nextTurn)
// Persist if storage enabled
if s.store != nil {
moveNumber := len(g.Moves())
record := storage.MoveRecord{
GameID: gameID,
MoveNumber: moveNumber,
MoveUCI: moveUCI,
FENAfterMove: newFEN,
PlayerColor: currentTurn.String(),
MoveTimeUTC: time.Now().UTC(),
}
s.store.RecordMove(record)
}
return nil
}
@ -114,8 +158,7 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
return nil
}
// SetLastMoveResult stores metadata about the last move (score, depth, etc)
// Used by processor to track computer move evaluations
// SetLastMoveResult stores metadata about the last move
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
s.mu.Lock()
defer s.mu.Unlock()
@ -139,7 +182,19 @@ func (s *Service) UndoMoves(gameID string, count int) error {
return fmt.Errorf("game not found: %s", gameID)
}
return g.UndoMoves(count)
originalMoveCount := len(g.Moves())
if err := g.UndoMoves(count); err != nil {
return err
}
// Delete undone moves from storage if enabled
if s.store != nil {
remainingMoves := originalMoveCount - count
s.store.DeleteUndoneMoves(gameID, remainingMoves)
}
return nil
}
// DeleteGame removes a game from memory
@ -155,12 +210,29 @@ func (s *Service) DeleteGame(gameID string) error {
return nil
}
// Close cleans up resources (currently a no-op as no engine to close)
// GetStorageHealth returns the storage component status
func (s *Service) GetStorageHealth() string {
if s.store == nil {
return "disabled"
}
if s.store.IsHealthy() {
return "ok"
}
return "degraded"
}
// Close cleans up resources
func (s *Service) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
// Clear all games
s.games = make(map[string]*game.Game)
// Close storage if enabled
if s.store != nil {
return s.store.Close()
}
return nil
}

View File

@ -0,0 +1,63 @@
// FILE: internal/storage/schema.go
package storage
import "time"
// GameRecord represents a row in the games table
type GameRecord struct {
GameID string `db:"game_id"`
InitialFEN string `db:"initial_fen"`
WhitePlayerID string `db:"white_player_id"`
WhiteType int `db:"white_type"`
WhiteLevel int `db:"white_level"`
WhiteSearchTime int `db:"white_search_time"`
BlackPlayerID string `db:"black_player_id"`
BlackType int `db:"black_type"`
BlackLevel int `db:"black_level"`
BlackSearchTime int `db:"black_search_time"`
StartTimeUTC time.Time `db:"start_time_utc"`
}
// MoveRecord represents a row in the moves table
type MoveRecord struct {
MoveID int64 `db:"move_id"`
GameID string `db:"game_id"`
MoveNumber int `db:"move_number"`
MoveUCI string `db:"move_uci"`
FENAfterMove string `db:"fen_after_move"`
PlayerColor string `db:"player_color"` // "w" or "b"
MoveTimeUTC time.Time `db:"move_time_utc"`
}
// Schema defines the SQLite database structure
const Schema = `
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,
white_player_id TEXT NOT NULL,
white_type INTEGER NOT NULL,
white_level INTEGER NOT NULL DEFAULT 0,
white_search_time INTEGER NOT NULL DEFAULT 1000,
black_player_id TEXT NOT NULL,
black_type INTEGER NOT NULL,
black_level INTEGER NOT NULL DEFAULT 0,
black_search_time INTEGER NOT NULL DEFAULT 1000,
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS moves (
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id TEXT NOT NULL,
move_number INTEGER NOT NULL,
move_uci TEXT NOT NULL,
fen_after_move TEXT NOT NULL,
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')),
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE(game_id, move_number)
);
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
`

316
internal/storage/storage.go Normal file
View File

@ -0,0 +1,316 @@
// FILE: internal/storage/storage.go
package storage
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"sync"
"sync/atomic"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Store handles SQLite database operations with async writes
type Store struct {
db *sql.DB
path string
writeChan chan func(*sql.Tx) error
healthStatus atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewStore creates a new storage instance with async writer
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable WAL mode in development for better concurrency
if devMode {
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
ctx, cancel := context.WithCancel(context.Background())
s := &Store{
db: db,
path: dataSourceName,
writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes
ctx: ctx,
cancel: cancel,
}
// Initialize health as true
s.healthStatus.Store(true)
// Start async writer
s.wg.Add(1)
go s.writerLoop()
return s, nil
}
// writerLoop processes async write operations
func (s *Store) writerLoop() {
defer s.wg.Done()
for {
select {
case <-s.ctx.Done():
// Drain remaining writes with timeout
deadline := time.After(2 * time.Second)
for {
select {
case fn := <-s.writeChan:
if s.healthStatus.Load() {
s.executeWrite(fn)
}
case <-deadline:
return
default:
return
}
}
case fn := <-s.writeChan:
// Skip if already degraded
if !s.healthStatus.Load() {
continue
}
s.executeWrite(fn)
}
}
}
// executeWrite runs a transactional write operation
func (s *Store) executeWrite(fn func(*sql.Tx) error) {
tx, err := s.db.Begin()
if err != nil {
log.Printf("Storage degraded: failed to begin transaction: %v", err)
s.healthStatus.Store(false)
return
}
if err := fn(tx); err != nil {
tx.Rollback()
log.Printf("Storage degraded: write operation failed: %v", err)
s.healthStatus.Store(false)
return
}
if err := tx.Commit(); err != nil {
log.Printf("Storage degraded: failed to commit: %v", err)
s.healthStatus.Store(false)
return
}
}
// RecordNewGame asynchronously records a new game
func (s *Store) RecordNewGame(record GameRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO games (
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := tx.Exec(query,
record.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
record.StartTimeUTC,
)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping game record")
return nil
}
}
// RecordMove asynchronously records a move
func (s *Store) RecordMove(record MoveRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO moves (
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
) VALUES (?, ?, ?, ?, ?, ?)`
_, err := tx.Exec(query,
record.GameID, record.MoveNumber, record.MoveUCI,
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping move record")
return nil
}
}
// DeleteUndoneMoves asynchronously deletes moves after undo
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
_, err := tx.Exec(query, gameID, afterMoveNumber)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping undo operation")
return nil
}
}
// IsHealthy returns the current health status
func (s *Store) IsHealthy() bool {
return s.healthStatus.Load()
}
// Close gracefully closes the database connection
func (s *Store) Close() error {
// Signal writer to stop
s.cancel()
// Wait for writer with timeout
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
// Writer finished cleanly
case <-time.After(2 * time.Second):
log.Printf("Warning: storage writer shutdown timeout, some writes may be lost")
}
if s.db != nil {
return s.db.Close()
}
return nil
}
// InitDB creates the database schema
func (s *Store) InitDB() error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(Schema); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
return tx.Commit()
}
// DeleteDB removes the database file
func (s *Store) DeleteDB() error {
// Close connection first
if err := s.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
// ☣ DESTRUCTIVE: Removes database file
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to delete database file: %w", err)
}
return nil
}
// QueryGames retrieves games with optional filtering
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
query := `SELECT
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
FROM games WHERE 1=1`
var args []interface{}
// Handle gameID filtering
if gameID != "" && gameID != "*" {
query += " AND game_id = ?"
args = append(args, gameID)
}
// Handle playerID filtering
if playerID != "" && playerID != "*" {
query += " AND (white_player_id = ? OR black_player_id = ?)"
args = append(args, playerID, playerID)
}
query += " ORDER BY start_time_utc DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var games []GameRecord
for rows.Next() {
var g GameRecord
err := rows.Scan(
&g.GameID, &g.InitialFEN,
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
&g.StartTimeUTC,
)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
games = append(games, g)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration failed: %w", err)
}
return games, nil
}

83
test/README.md Executable file
View File

@ -0,0 +1,83 @@
# Chess API Test Suite
This directory contains comprehensive test suites for the Chess API server, covering both API functionality and database persistence.
## Prerequisites
- `jq` - JSON processor
- `curl` - HTTP client
- `sqlite3` - SQLite CLI (for database tests only)
- Compiled `chessd` binary in parent directory
## Test Suites
### 1. API Functionality Tests (`test-api.sh`)
Tests all API endpoints, error handling, rate limiting, and game logic.
**Running the test:**
```bash
# Start server in development mode (required for tests to pass)
../chessd -dev
# In another terminal, run tests
./test-api.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
### 2. Database Persistence Tests (`test-db.sh`)
Tests database storage, async writes, and data integrity.
**Running the test:**
```bash
# Terminal 1: Start server with database
./run-server-with-db.sh ../chessd
# Terminal 2: Run database tests
./test-db.sh
# When done, press Ctrl+C in Terminal 1
```
**Coverage:**
- Game and move persistence
- Async write buffer behavior
- Multi-game isolation
- Undo effects on database
- WAL mode verification
- Foreign key constraints
## Important Notes
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)
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)
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.
## Troubleshooting
**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
## Exit Codes
- `0` - All tests passed
- `1` - One or more tests failed
Check colored output for detailed pass/fail information for each test case.

71
test/run-server-with-db.sh Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
# FILE: run-server-with-db.sh
set -e
# Check for argument
if [ $# -ne 1 ]; then
echo "Usage: $0 <path_to_chessd_executable>"
exit 1
fi
CHESSD_EXEC="$1"
TEST_DB="test.db"
PID_FILE="/tmp/chessd_test.pid"
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# Cleanup function
cleanup() {
echo -e "\n${YELLOW}Cleaning up...${NC}"
# Kill server if PID file exists
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo "Stopping chessd server (PID: $PID)"
kill "$PID" 2>/dev/null || true
sleep 0.5
kill -9 "$PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
# Clean up database files
echo "Removing test database files..."
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm"
echo -e "${GREEN}Cleanup complete${NC}"
}
# Set up trap for cleanup on exit
trap cleanup EXIT SIGINT SIGTERM
# Clean slate - remove any existing test DB files
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm"
# Initialize database
echo -e "${CYAN}Initializing test database...${NC}"
"$CHESSD_EXEC" db init -path "$TEST_DB"
# Start server
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}Starting chessd server with database persistence${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo "Executable: $CHESSD_EXEC"
echo "Database: $TEST_DB"
echo "Mode: Development (WAL enabled)"
echo ""
echo -e "${YELLOW}Instructions:${NC}"
echo "1. Open another terminal and run: ./test-db.sh"
echo "2. Press Ctrl+C here when testing is complete"
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo ""
# Start chessd in foreground with dev mode and storage
"$CHESSD_EXEC" -dev -storage-path "$TEST_DB" -port 8080

View File

@ -581,7 +581,7 @@ GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
print_header "SECTION 8: Player Configuration"
# ==============================================================================
test_case "8.1: Create Game with AI Configuration"
test_case "8.1: Create Game with Engine Configuration"
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 10, "searchTime": 500}}')

502
test/test-db.sh Executable file
View File

@ -0,0 +1,502 @@
#!/usr/bin/env bash
# FILE: test-db.sh
# Database Persistence Test Suite for Chess API
# Tests async writes, persistence, and database integrity
BASE_URL="http://localhost:8080"
API_URL="${BASE_URL}/api/v1"
TEST_DB="test.db"
API_DELAY=${API_DELAY:-50}
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m'
# Test counters
PASS=0
FAIL=0
# Helper functions
print_header() {
echo -e "\n${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo -e "${CYAN}$1${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
}
test_case() {
echo -e "\n${YELLOW}▶ TEST: $1${NC}"
sleep 0.0$API_DELAY
}
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++))
return 0
else
echo -e "${RED}$test_name: Expected HTTP $expected, got $actual${NC}"
((FAIL++))
return 1
fi
}
assert_json_field() {
local json=$1
local field=$2
local expected=$3
local test_name=$4
local actual=$(echo "$json" | jq -r "$field" 2>/dev/null)
if [ "$actual" = "$expected" ]; then
echo -e "${GREEN}$test_name: $field = '$actual'${NC}"
((PASS++))
return 0
else
echo -e "${RED}$test_name: Expected $field = '$expected', got '$actual'${NC}"
((FAIL++))
return 1
fi
}
assert_db_record() {
local sql_query=$1
local expected=$2
local test_name=$3
local actual=$(sqlite3 "$TEST_DB" "$sql_query" 2>/dev/null)
if [ "$actual" = "$expected" ]; then
echo -e "${GREEN}$test_name: DB query returned '$actual'${NC}"
((PASS++))
return 0
else
echo -e "${RED}$test_name: Expected '$expected', got '$actual'${NC}"
echo -e "${RED} Query: $sql_query${NC}"
((FAIL++))
return 1
fi
}
api_request() {
local method=$1
local url=$2
shift 2
curl -s "$@" -X "$method" "$url"
local status=$?
sleep 0.0$API_DELAY
return $status
}
wait_for_state() {
local game_id=$1
local target_state=$2
local max_attempts=${3:-20}
local attempt=0
while [ $attempt -lt $max_attempts ]; do
local response=$(api_request GET "$API_URL/games/$game_id")
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
echo -e "${RED}Error: $cmd is required but not installed${NC}"
exit 1
fi
done
# Check database exists
if [ ! -f "$TEST_DB" ]; then
echo -e "${RED}Error: Test database '$TEST_DB' not found${NC}"
echo "Make sure the server is running with: ./run-server-with-db.sh"
exit 1
fi
# Start tests
print_header "Database Persistence Test Suite"
echo "Server: $BASE_URL"
echo "Database: $TEST_DB"
echo "Mode: Development with WAL"
echo ""
# ==============================================================================
print_header "SECTION 1: Storage Health & Basic Persistence"
# ==============================================================================
test_case "1.1: Storage Health Check"
RESPONSE=$(api_request GET "$BASE_URL/health")
assert_json_field "$RESPONSE" '.storage' "ok" "Storage is healthy"
test_case "1.2: Database Schema Verification"
TABLE_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('games', 'moves');" 2>/dev/null)
if [ "$TABLE_COUNT" = "2" ]; then
echo -e "${GREEN} ✓ Database schema verified: games and moves tables exist${NC}"
((PASS++))
else
echo -e "${RED} ✗ Database schema incomplete: expected 2 tables, found $TABLE_COUNT${NC}"
((FAIL++))
fi
test_case "1.3: Game Creation Persistence"
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 5, "searchTime": 100}}')
GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
if [ -n "$GAME_ID" ] && [ "$GAME_ID" != "null" ]; then
echo " Game ID: $GAME_ID"
sleep 0.5 # Allow async write
assert_db_record \
"SELECT COUNT(*) FROM games WHERE game_id = '$GAME_ID';" \
"1" \
"Game record created"
assert_db_record \
"SELECT black_player_id FROM games WHERE game_id = '$GAME_ID';" \
"$BLACK_ID" \
"Black player ID matches"
assert_db_record \
"SELECT black_level FROM games WHERE game_id = '$GAME_ID';" \
"5" \
"Black AI level persisted"
assert_db_record \
"SELECT initial_fen FROM games WHERE game_id = '$GAME_ID';" \
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" \
"Starting FEN persisted"
fi
# ==============================================================================
print_header "SECTION 2: Move Persistence & Undo"
# ==============================================================================
test_case "2.1: Human Move Persistence"
if [ -n "$GAME_ID" ]; then
RESPONSE=$(api_request POST "$API_URL/games/$GAME_ID/moves" \
-H "Content-Type: application/json" \
-d '{"move": "e2e4"}')
sleep 0.5 # Allow async write
assert_db_record \
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
"1" \
"Move record created"
assert_db_record \
"SELECT move_uci FROM moves WHERE game_id = '$GAME_ID' AND move_number = 1;" \
"e2e4" \
"Move UCI notation stored"
assert_db_record \
"SELECT player_color FROM moves WHERE game_id = '$GAME_ID' AND move_number = 1;" \
"w" \
"Move color recorded"
fi
test_case "2.2: Computer Move Persistence"
if [ -n "$GAME_ID" ]; then
# Trigger computer move
api_request POST "$API_URL/games/$GAME_ID/moves" \
-H "Content-Type: application/json" \
-d '{"move": "cccc"}' > /dev/null
wait_for_state "$GAME_ID" "!pending"
sleep 0.5 # Allow async write
assert_db_record \
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
"2" \
"Computer move persisted"
COMPUTER_MOVE=$(sqlite3 "$TEST_DB" "SELECT move_uci FROM moves WHERE game_id = '$GAME_ID' AND move_number = 2;" 2>/dev/null)
if [ -n "$COMPUTER_MOVE" ] && [ "$COMPUTER_MOVE" != "e2e4" ]; then
echo -e "${GREEN} ✓ Computer move stored: $COMPUTER_MOVE${NC}"
((PASS++))
else
echo -e "${RED} ✗ Computer move not properly stored${NC}"
((FAIL++))
fi
fi
test_case "2.3: Undo Move Database Effect"
if [ -n "$GAME_ID" ]; then
# Undo last move
api_request POST "$API_URL/games/$GAME_ID/undo" \
-H "Content-Type: application/json" \
-d '{"count": 1}' > /dev/null
sleep 0.5 # Allow async write
assert_db_record \
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
"1" \
"Undo removed move from DB"
# Undo again
api_request POST "$API_URL/games/$GAME_ID/undo" \
-H "Content-Type: application/json" \
-d '{"count": 1}' > /dev/null
sleep 0.5
assert_db_record \
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
"0" \
"All moves removed after undo"
fi
# ==============================================================================
print_header "SECTION 3: Complex Game Scenarios"
# ==============================================================================
test_case "3.1: Custom FEN Game Persistence"
CUSTOM_FEN="r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 4 4"
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d "{\"white\": {\"type\": 2, \"searchTime\": 100}, \"black\": {\"type\": 2, \"searchTime\": 100}, \"fen\": \"$CUSTOM_FEN\"}")
FEN_GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
if [ -n "$FEN_GAME_ID" ] && [ "$FEN_GAME_ID" != "null" ]; then
sleep 0.5
assert_db_record \
"SELECT initial_fen FROM games WHERE game_id = '$FEN_GAME_ID';" \
"$CUSTOM_FEN" \
"Custom FEN persisted correctly"
# Clean up
api_request DELETE "$API_URL/games/$FEN_GAME_ID" > /dev/null
fi
test_case "3.2: Multiple Games Isolation"
# Create two games
RESPONSE1=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 1}, "black": {"type": 1}}')
GAME_ID1=$(echo "$RESPONSE1" | jq -r '.gameId' 2>/dev/null)
RESPONSE2=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 2, "searchTime": 100}, "black": {"type": 1}}')
GAME_ID2=$(echo "$RESPONSE2" | jq -r '.gameId' 2>/dev/null)
if [ -n "$GAME_ID1" ] && [ -n "$GAME_ID2" ]; then
sleep 0.5
# Make moves in first game
api_request POST "$API_URL/games/$GAME_ID1/moves" \
-H "Content-Type: application/json" \
-d '{"move": "d2d4"}' > /dev/null
api_request POST "$API_URL/games/$GAME_ID1/moves" \
-H "Content-Type: application/json" \
-d '{"move": "d7d5"}' > /dev/null
sleep 0.5
assert_db_record \
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID1';" \
"2" \
"Game 1 has 2 moves"
assert_db_record \
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID2';" \
"0" \
"Game 2 has 0 moves (isolation)"
# Clean up
api_request DELETE "$API_URL/games/$GAME_ID1" > /dev/null
api_request DELETE "$API_URL/games/$GAME_ID2" > /dev/null
fi
# ==============================================================================
print_header "SECTION 4: Foreign Key Constraints & Cascade"
# ==============================================================================
test_case "4.1: Cascade Delete Verification"
# Create game with moves
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 1}, "black": {"type": 1}}')
CASCADE_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
if [ -n "$CASCADE_ID" ] && [ "$CASCADE_ID" != "null" ]; then
# Make several moves
api_request POST "$API_URL/games/$CASCADE_ID/moves" \
-H "Content-Type: application/json" -d '{"move": "e2e4"}' > /dev/null
api_request POST "$API_URL/games/$CASCADE_ID/moves" \
-H "Content-Type: application/json" -d '{"move": "e7e5"}' > /dev/null
api_request POST "$API_URL/games/$CASCADE_ID/moves" \
-H "Content-Type: application/json" -d '{"move": "g1f3"}' > /dev/null
sleep 0.5
MOVE_COUNT_BEFORE=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$CASCADE_ID';" 2>/dev/null)
echo -e "${BLUE} Moves before delete: $MOVE_COUNT_BEFORE${NC}"
# Delete game
api_request DELETE "$API_URL/games/$CASCADE_ID" > /dev/null
sleep 0.5
# Note: Game deletion is handled in memory, DB records remain
# This is by design - games table persists for history
assert_db_record \
"SELECT COUNT(*) FROM games WHERE game_id = '$CASCADE_ID';" \
"1" \
"Game record persists in DB (by design)"
fi
# ==============================================================================
print_header "SECTION 5: Async Write Buffer Behavior"
# ==============================================================================
test_case "5.1: Rapid Write Buffering"
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 1}, "black": {"type": 1}}')
BUFFER_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
if [ -n "$BUFFER_ID" ] && [ "$BUFFER_ID" != "null" ]; then
# Rapid fire a sequence of legal moves without waiting
echo -e "${BLUE} Sending 10 rapid, legal moves...${NC}"
moves=("e2e4" "e7e5" "g1f3" "b8c6" "f1b5" "a7a6" "b5c6" "d7c6" "e1g1" "f7f6")
for move in "${moves[@]}"; do
api_request POST "$API_URL/games/$BUFFER_ID/moves" \
-H "Content-Type: application/json" -d "{\"move\": \"$move\"}" > /dev/null
done
# Immediate check (may show partial writes)
IMMEDIATE_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$BUFFER_ID';" 2>/dev/null)
echo -e "${BLUE} Immediate move count: $IMMEDIATE_COUNT${NC}"
# Wait for async writes to complete
sleep 1
FINAL_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$BUFFER_ID';" 2>/dev/null)
if [ "$FINAL_COUNT" = "10" ]; then
echo -e "${GREEN} ✓ All 10 moves persisted after buffer flush${NC}"
((PASS++))
else
echo -e "${RED} ✗ Expected 10 moves, found $FINAL_COUNT${NC}"
((FAIL++))
fi
# Clean up
api_request DELETE "$API_URL/games/$BUFFER_ID" > /dev/null
fi
# ==============================================================================
print_header "SECTION 6: Database Query Endpoints"
# ==============================================================================
test_case "6.1: CLI Query Tool Integration"
# Create identifiable game
RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 15, "searchTime": 200}}')
QUERY_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
if [ -n "$QUERY_ID" ] && [ "$QUERY_ID" != "null" ]; then
sleep 0.5
# Query using partial game ID (first 8 chars)
PARTIAL_ID="${QUERY_ID:0:8}"
FOUND=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM games WHERE game_id LIKE '$PARTIAL_ID%';" 2>/dev/null)
if [ "$FOUND" = "1" ]; then
echo -e "${GREEN} ✓ Game queryable by partial ID${NC}"
((PASS++))
else
echo -e "${RED} ✗ Game not found with partial ID query${NC}"
((FAIL++))
fi
# Verify player type storage
assert_db_record \
"SELECT white_type || ',' || black_type FROM games WHERE game_id = '$QUERY_ID';" \
"1,2" \
"Player types stored correctly"
# Clean up
api_request DELETE "$API_URL/games/$QUERY_ID" > /dev/null
fi
# ==============================================================================
print_header "SECTION 7: Storage Degradation Handling"
# ==============================================================================
test_case "7.1: Storage Health After Normal Operations"
# Check that storage remains healthy after all operations
RESPONSE=$(api_request GET "$BASE_URL/health")
assert_json_field "$RESPONSE" '.storage' "ok" "Storage still healthy after tests"
test_case "7.2: WAL Mode Verification"
JOURNAL_MODE=$(sqlite3 "$TEST_DB" "PRAGMA journal_mode;" 2>/dev/null)
if [ "$JOURNAL_MODE" = "wal" ]; then
echo -e "${GREEN} ✓ Database is in WAL mode${NC}"
((PASS++))
else
echo -e "${RED} ✗ Database not in WAL mode: $JOURNAL_MODE${NC}"
((FAIL++))
fi
# Check WAL file exists
if [ -f "${TEST_DB}-wal" ]; then
echo -e "${GREEN} ✓ WAL file exists${NC}"
((PASS++))
else
echo -e "${YELLOW} ⚠ WAL file not found (may be checkpointed)${NC}"
fi
# ==============================================================================
print_header "Test Summary"
# ==============================================================================
TOTAL=$((PASS + FAIL))
SUCCESS_RATE=0
if [ $TOTAL -gt 0 ]; then
SUCCESS_RATE=$(( (PASS * 100) / TOTAL ))
fi
echo -e "\n${CYAN}══════════════════════════════════════${NC}"
echo -e "${GREEN}✓ Passed: $PASS${NC}"
echo -e "${RED}✗ Failed: $FAIL${NC}"
echo -e "${CYAN}──────────────────────────────────────${NC}"
echo -e "Total Tests: $TOTAL"
echo -e "Success Rate: ${SUCCESS_RATE}%"
echo -e "${CYAN}══════════════════════════════════════${NC}"
if [ $FAIL -eq 0 ]; then
echo -e "\n${GREEN}🎉 All database tests passed!${NC}"
exit 0
else
echo -e "\n${RED}⚠️ Some tests failed. Review the output above.${NC}"
exit 1
fi