v0.3.0 storage with sqlite3 and pid management added
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,5 @@
|
|||||||
dev
|
dev
|
||||||
log
|
log
|
||||||
bin
|
bin
|
||||||
|
db
|
||||||
build.sh
|
build.sh
|
||||||
|
|||||||
27
README.md
27
README.md
@ -12,21 +12,25 @@
|
|||||||
|
|
||||||
# Chess
|
# 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
|
## Features
|
||||||
|
|
||||||
- RESTful API for chess operations
|
- 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
|
- Human vs human, human vs computer, computer vs computer modes
|
||||||
- Custom FEN position support
|
- Custom FEN position support
|
||||||
- Asynchronous AI move calculation
|
- Asynchronous engine move calculation
|
||||||
- Configurable AI strength and thinking time
|
- Configurable engine strength and thinking time
|
||||||
|
- Optional SQLite persistence with async writes
|
||||||
|
- PID file management for singleton enforcement
|
||||||
|
- Database CLI for storage administration
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.24+
|
- Go 1.24+
|
||||||
- Stockfish chess engine (`stockfish` in PATH)
|
- Stockfish chess engine (`stockfish` in PATH)
|
||||||
|
- SQLite3 (for persistence features)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@ -45,14 +49,17 @@ git clone https://git.lixen.com/lixen/chess
|
|||||||
cd chess
|
cd chess
|
||||||
go build ./cmd/chessd
|
go build ./cmd/chessd
|
||||||
|
|
||||||
# Standard mode (1 request/second/IP)
|
# Standard mode with persistence
|
||||||
./chessd
|
./chessd -storage-path chess.db
|
||||||
|
|
||||||
# Development mode (10 requests/second/IP)
|
# Development mode with PID lock on localhost custom port
|
||||||
./chessd -dev
|
./chessd -dev -pid /tmp/chessd.pid -pid-lock -port 9090
|
||||||
|
|
||||||
# Run tests (requires dev mode)
|
# Database initialization (doesn't run server)
|
||||||
./test/test-api.sh
|
./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.
|
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints.
|
||||||
|
|||||||
131
cmd/chessd/cli/cli.go
Normal file
131
cmd/chessd/cli/cli.go
Normal 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
|
||||||
|
}
|
||||||
@ -2,6 +2,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"chess/cmd/chessd/cli"
|
||||||
|
"chess/internal/http"
|
||||||
|
"chess/internal/processor"
|
||||||
|
"chess/internal/service"
|
||||||
|
"chess/internal/storage"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@ -9,29 +14,69 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chess/internal/http"
|
|
||||||
"chess/internal/processor"
|
|
||||||
"chess/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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
|
// Command-line flags
|
||||||
var (
|
var (
|
||||||
host = flag.String("host", "localhost", "Server host")
|
host = flag.String("host", "localhost", "Server host")
|
||||||
port = flag.Int("port", 8080, "Server port")
|
port = flag.Int("port", 8080, "Server port")
|
||||||
dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
// 1. Initialize the Service (Pure State Manager)
|
// Validate PID flags
|
||||||
svc, err := service.New()
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize service: %v", err)
|
log.Fatalf("Failed to initialize service: %v", err)
|
||||||
}
|
}
|
||||||
defer svc.Close()
|
defer svc.Close()
|
||||||
|
|
||||||
// 2. 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 {
|
||||||
log.Fatalf("Failed to initialize processor: %v", err)
|
log.Fatalf("Failed to initialize processor: %v", err)
|
||||||
@ -42,8 +87,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 3. Initialize the Fiber App/HTTP Handler, injecting the processor
|
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
|
||||||
app := http.NewFiberApp(proc, *dev)
|
app := http.NewFiberApp(proc, svc, *dev)
|
||||||
|
|
||||||
// Server configuration
|
// Server configuration
|
||||||
addr := fmt.Sprintf("%s:%d", *host, *port)
|
addr := fmt.Sprintf("%s:%d", *host, *port)
|
||||||
@ -54,9 +99,14 @@ func main() {
|
|||||||
log.Printf("Listening on: http://%s", addr)
|
log.Printf("Listening on: http://%s", addr)
|
||||||
log.Printf("API Version: v1")
|
log.Printf("API Version: v1")
|
||||||
if *dev {
|
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 {
|
} 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("Endpoints: http://%s/api/v1/games", addr)
|
||||||
log.Printf("Health: http://%s/health", addr)
|
log.Printf("Health: http://%s/health", addr)
|
||||||
|
|||||||
106
cmd/chessd/pid.go
Normal file
106
cmd/chessd/pid.go
Normal 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)
|
||||||
|
}
|
||||||
23
doc/api.md
23
doc/api.md
@ -6,6 +6,25 @@ Content-Type: `application/json` (required for POST/PUT)
|
|||||||
|
|
||||||
## Endpoints
|
## 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
|
### Create Game
|
||||||
`POST /games`
|
`POST /games`
|
||||||
|
|
||||||
@ -29,8 +48,8 @@ Creates new game with specified players.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `type` (integer, required): 1=human, 2=computer
|
- `type` (integer, required): 1=human, 2=computer
|
||||||
- `level` (integer, 0-20): AI skill level for computer players
|
- `level` (integer, 0-20): Engine skill level for computer players
|
||||||
- `searchTime` (integer, 100-10000ms): AI thinking time for computer players
|
- `searchTime` (integer, 100-10000ms): Engine thinking time for computer players
|
||||||
- `fen` (string): Starting position in FEN notation (default: standard position)
|
- `fen` (string): Starting position in FEN notation (default: standard position)
|
||||||
|
|
||||||
**Response (201):**
|
**Response (201):**
|
||||||
|
|||||||
@ -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.
|
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`)
|
### 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
|
### 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
|
- **Game** (`internal/game`): Game state with snapshot history
|
||||||
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
||||||
- **Core** (`internal/core`): Shared types, API models, error constants
|
- **Core** (`internal/core`): Shared types, API models, error constants
|
||||||
|
- **CLI** (`cmd/chessd/cli`): Database management commands
|
||||||
|
|
||||||
## Request Flow
|
## 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
|
5. Callback updates game state via service
|
||||||
6. Client polls for completion
|
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
|
## Concurrency
|
||||||
|
|
||||||
- **HTTP Server**: Fiber handles concurrent connections
|
- **HTTP Server**: Fiber handles concurrent connections
|
||||||
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
|
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
|
||||||
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
|
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
|
||||||
- **Validation Engine**: Single mutex-protected instance for synchronous validation
|
- **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
|
## Data Structures
|
||||||
|
|
||||||
@ -58,4 +78,32 @@ type Snapshot struct {
|
|||||||
Commands encapsulate operations with type and arguments, processed by single Execute method.
|
Commands encapsulate operations with type and arguments, processed by single Execute method.
|
||||||
|
|
||||||
### Player Configuration
|
### Player Configuration
|
||||||
Players identified by UUID, configured with type (human/computer), skill level, and search time.
|
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)
|
||||||
|
)
|
||||||
|
```
|
||||||
@ -21,21 +21,49 @@ go build ./cmd/chessd
|
|||||||
- `-host`: Server host (default: localhost)
|
- `-host`: Server host (default: localhost)
|
||||||
- `-port`: Server port (default: 8080)
|
- `-port`: Server port (default: 8080)
|
||||||
- `-dev`: Development mode with relaxed rate limits
|
- `-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
|
### Modes
|
||||||
```bash
|
```bash
|
||||||
# Production (1 req/s rate limit)
|
# In-memory only
|
||||||
./chessd
|
./chessd
|
||||||
|
|
||||||
# Development (10 req/s rate limit)
|
# With persistence
|
||||||
./chessd -dev
|
./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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
chess/
|
chess/
|
||||||
├── cmd/chessd/ # Entry point
|
├── cmd/chessd/
|
||||||
|
│ ├── main.go # Entry point
|
||||||
|
│ ├── pid.go # PID file management
|
||||||
|
│ └── cli/ # Database CLI
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── board/ # FEN/ASCII operations
|
│ ├── board/ # FEN/ASCII operations
|
||||||
│ ├── core/ # Shared types
|
│ ├── core/ # Shared types
|
||||||
@ -43,28 +71,14 @@ chess/
|
|||||||
│ ├── game/ # Game state
|
│ ├── game/ # Game state
|
||||||
│ ├── http/ # Fiber handlers
|
│ ├── http/ # Fiber handlers
|
||||||
│ ├── processor/ # Command processing
|
│ ├── processor/ # Command processing
|
||||||
│ └── service/ # State management
|
│ ├── service/ # State management
|
||||||
|
│ └── storage/ # SQLite persistence
|
||||||
└── test/ # Test scripts
|
└── test/ # Test scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
See [test documentation](../test/README.md) for details.
|
||||||
# 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
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@ -73,6 +87,20 @@ Test script validates:
|
|||||||
- Worker count: 2 (internal/processor/processor.go)
|
- Worker count: 2 (internal/processor/processor.go)
|
||||||
- Queue capacity: 100 (internal/processor/queue.go)
|
- Queue capacity: 100 (internal/processor/queue.go)
|
||||||
- Min search time: 100ms (internal/processor/processor.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
|
### Validation Rules
|
||||||
- Player type: 1 (human) or 2 (computer)
|
- Player type: 1 (human) or 2 (computer)
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -6,6 +6,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.28.0
|
||||||
github.com/gofiber/fiber/v2 v2.52.9
|
github.com/gofiber/fiber/v2 v2.52.9
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
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=
|
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-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 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
|||||||
@ -4,6 +4,7 @@ package http
|
|||||||
import (
|
import (
|
||||||
"chess/internal/core"
|
"chess/internal/core"
|
||||||
"chess/internal/processor"
|
"chess/internal/processor"
|
||||||
|
"chess/internal/service"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -19,15 +20,16 @@ const rateLimitRate = 10 // req/sec
|
|||||||
|
|
||||||
type HTTPHandler struct {
|
type HTTPHandler struct {
|
||||||
proc *processor.Processor
|
proc *processor.Processor
|
||||||
|
svc *service.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPHandler(proc *processor.Processor) *HTTPHandler {
|
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
|
||||||
return &HTTPHandler{proc: proc}
|
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
|
// Create handler
|
||||||
h := NewHTTPHandler(proc)
|
h := NewHTTPHandler(proc, svc)
|
||||||
|
|
||||||
// Initialize Fiber app
|
// Initialize Fiber app
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
@ -146,11 +148,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
|
|||||||
return c.Status(code).JSON(response)
|
return c.Status(code).JSON(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint with storage status
|
||||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"time": time.Now().Unix(),
|
"time": time.Now().Unix(),
|
||||||
|
"storage": h.svc.GetStorageHealth(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,24 +4,27 @@ package service
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"chess/internal/core"
|
"chess/internal/core"
|
||||||
"chess/internal/game"
|
"chess/internal/game"
|
||||||
|
"chess/internal/storage"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service is a pure state manager for chess games
|
// Service is a pure state manager for chess games with optional persistence
|
||||||
// It has NO knowledge of chess rules or engine interactions
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
games map[string]*game.Game
|
games map[string]*game.Game
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
store *storage.Store // nil if persistence disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new service instance
|
// New creates a new service instance with optional storage
|
||||||
func New() (*Service, error) {
|
func New(store *storage.Store) (*Service, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
games: make(map[string]*game.Game),
|
games: make(map[string]*game.Game),
|
||||||
|
store: store,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +42,25 @@ func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConf
|
|||||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
||||||
|
|
||||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,11 +98,19 @@ func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
|||||||
|
|
||||||
// GenerateGameID creates a new unique game ID
|
// GenerateGameID creates a new unique game ID
|
||||||
func (s *Service) GenerateGameID() string {
|
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
|
// 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 {
|
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@ -97,6 +127,20 @@ 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)
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,8 +158,7 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLastMoveResult stores metadata about the last move (score, depth, etc)
|
// SetLastMoveResult stores metadata about the last move
|
||||||
// Used by processor to track computer move evaluations
|
|
||||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 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
|
// DeleteGame removes a game from memory
|
||||||
@ -155,12 +210,29 @@ func (s *Service) DeleteGame(gameID string) error {
|
|||||||
return nil
|
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 {
|
func (s *Service) Close() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
// Clear all games
|
// Clear all games
|
||||||
s.games = make(map[string]*game.Game)
|
s.games = make(map[string]*game.Game)
|
||||||
|
|
||||||
|
// Close storage if enabled
|
||||||
|
if s.store != nil {
|
||||||
|
return s.store.Close()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
63
internal/storage/schema.go
Normal file
63
internal/storage/schema.go
Normal 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
316
internal/storage/storage.go
Normal 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
83
test/README.md
Executable 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
71
test/run-server-with-db.sh
Executable 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
|
||||||
@ -581,7 +581,7 @@ GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|||||||
print_header "SECTION 8: Player Configuration"
|
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" \
|
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 10, "searchTime": 500}}')
|
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 10, "searchTime": 500}}')
|
||||||
|
|||||||
502
test/test-db.sh
Executable file
502
test/test-db.sh
Executable 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
|
||||||
Reference in New Issue
Block a user