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

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