v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 22:35:52 -04:00
1 parent 52ac0fe31a
commit f1ac4cbbe7
71 files changed
+7677 -537

No files matched your search

+44
View File
@@ -80,6 +80,9 @@ curl -X POST http://localhost:8080/api/games \
| API Functionality | `test-api.sh` | Game operations, moves, undo, rate limiting |
| Database & Auth | `test-db.sh` | User registration, login, JWT tokens, persistence |
| Long-Polling | `test-longpoll.sh` | Real-time updates, wait behavior, timeouts |
| End States | `test-endstate.sh` | Checkmate, stalemate, terminal custom positions |
| Replay | `test-replay.sh` | SAN history, PGN export, ETag/304, promotion, FEN validation, cursor-paged listing |
| Draws & Resignation | `test-draw.sh` | Automatic draws, offers (human and computer), resignation and authorization, long-poll wake-up, hand-deleted game row |
| Test Server | `run-test-server.sh` | Pre-populated test environment |
## 1. API Functionality Tests (`test-api.sh`)
@@ -167,3 +170,44 @@ test/test-longpoll.sh
3. **Timeout**: Verify 25-second timeout with valid response
4. **Skip Wait**: Immediate return when moveCount outdated
5. **Disconnection**: Proper cleanup on client disconnect
## Replay Tests (`test-replay.sh`)
Exercises the replay API over HTTP with human-vs-human games, so no engine
search is involved. Each run registers a fresh account, so it can be repeated
against the same server.
```bash
# Terminal 1
test/run-test-server.sh bin/chess-server
# Terminal 2
test/test-replay.sh
```
Covers SAN and results in the history, the PGN attachment (tag order,
movetext, `?ply=`), `ETag`/`If-None-Match`, castling and en passant notation,
promotion (a missing piece is rejected, `g8=N` from the custom position that
reported the web-client bug), rejected starting FENs, and cursor paging with
`status`/`color` filters. `API_DELAY` (seconds, default `0.06`) keeps it under
the dev-mode rate limit.
## Draw and Resignation Tests (`test-draw.sh`)
Plays short human-vs-human lines, switching a side to the computer where the
computer's answer to a draw offer is tested, so the only engine work is two
short evaluations. Each run registers a fresh account and can be repeated
against the same server.
```bash
test/test-draw.sh
```
Covers the automatic draws (K+B v K after a capture, a knight shuffle
repeated three times, the hundredth quiet ply, a drawn starting position),
undo after a draw, resignation (side inference, hot-seat ambiguity, a side
claimed by another user), draw offers between humans (standing offer,
decline, one offer per move, acceptance waking a long-poll, lapse on the
opponent's move), the computer declining early, accepting in a level
position and declining when a queen up, and a game whose row is deleted with
psql (needs `CHESS_TEST_DSN` and `psql`; the server unloads the game and
stays healthy).
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# Draw and resignation test suite: automatic draws (dead material, threefold
# repetition, fifty-move rule), draw offers between humans and to the
# computer, resignation with slot authorization, undo after a result,
# long-poll wake-up on offers, and a game whose row is deleted by hand.
# Requires: curl, jq, and a server from test/run-test-server.sh; psql and
# CHESS_TEST_DSN for the deleted-row case (skipped without them).
BASE_URL=${BASE_URL:-"http://localhost:8080"}
API_URL="${BASE_URL}/api"
# Pause between requests; the dev-mode limiter allows 20 per second.
API_DELAY=${API_DELAY:-0.06}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
section() { echo -e "\n${CYAN}== $1 ==${NC}"; }
ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); }
bad() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); }
skip() { echo -e "${YELLOW} - $1${NC}"; }
check() { # check <description> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi
}
# request <method> <path> [curl args...]: body to $TMP/body, status printed.
request() {
local method=$1 path=$2
shift 2
sleep "$API_DELAY"
: >"$TMP/body"
curl -s -o "$TMP/body" -w '%{http_code}' -X "$method" "$@" "$API_URL$path"
}
json() { jq -r "$1" "$TMP/body"; }
# movetext prints the PGN movetext in $TMP/body on one line (PGN wraps at 80).
movetext() { sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d' | tr '\n' ' ' | sed 's/ $//'; }
post() { # post <path> <json> [token]
local auth=()
[ -n "$3" ] && auth=(-H "Authorization: Bearer $3")
request POST "$1" -H 'Content-Type: application/json' "${auth[@]}" -d "$2"
}
new_game() { # new_game <white type> <black type> [fen]: prints the game ID
local body
body=$(jq -nc --argjson w "$1" --argjson b "$2" --arg fen "${3:-}" \
'{white:{type:$w}, black:{type:$b, level:5, searchTime:100}} + (if $fen == "" then {} else {fen:$fen} end)')
post /games "$body" >/dev/null
json .gameId
}
play() { # play <gameId> <token|""> <uci>...
local id=$1 token=$2 status
shift 2
for move in "$@"; do
status=$(post "/games/$id/moves" "{\"move\":\"$move\"}" "$token")
if [ "$status" != 200 ]; then
bad "move $move: HTTP $status $(cat "$TMP/body")"
return 1
fi
done
}
register() { # register <user> <password>: prints the session token
post /auth/register "{\"username\":\"$1\",\"password\":\"$2\"}" >/dev/null
json .token
}
if ! curl -sf "$BASE_URL/health" >/dev/null; then
echo -e "${RED}Server not reachable at $BASE_URL; start test/run-test-server.sh${NC}"
exit 1
fi
SYMMETRIC="g1f3 g8f6 b1c3 b8c6 e2e3 e7e6 d2d3 d7d6 f1e2 f8e7 c1d2 c8d7 e1g1 e8g8 a2a3 a7a6 h2h3 h7h6 a1b1 a8b8"
QUEEN_UP="e2e4 e7e5 g1f3 d8h4 f3h4 b8c6 h4f3 g8f6 b1c3 f8c5 f1e2 d7d6 e1g1 e8g8 d2d3 c8e6 c1e3 c5e3 f2e3 h7h6"
section "1. Automatic draws"
GAME=$(new_game 1 1 "4k3/8/8/8/8/8/3r4/4KB2 w - - 0 1")
play "$GAME" "" e1d2
check "Kxd2 leaves K+B v K: state" "draw" "$(json .state)"
check "termination" "insufficient_material" "$(json .termination)"
check "no move after the draw" 400 "$(post "/games/$GAME/moves" '{"move":"e8d8"}')"
check "error code" "GAME_OVER" "$(json .code)"
GAME=$(new_game 1 1)
play "$GAME" "" g1f3 g8f6 f3g1 f6g8 g1f3 g8f6 f3g1
check "second occurrence plays on" "ongoing" "$(json .state)"
play "$GAME" "" f6g8
check "third occurrence: threefold" "threefold_repetition" "$(json .termination)"
check "history termination" "threefold_repetition" \
"$(request GET "/games/$GAME/history" >/dev/null; json .termination)"
check "PGN comment" "1. Nf3 Nf6 2. Ng1 Ng8 3. Nf3 Nf6 4. Ng1 Ng8 { Draw by threefold repetition. } 1/2-1/2" \
"$(request GET "/games/$GAME/pgn" >/dev/null; movetext)"
check "undo reopens the game" 200 "$(post "/games/$GAME/undo" '{"count":1}')"
check "state after undo" "ongoing/null" "$(json '.state + "/" + (.termination // "null")')"
GAME=$(new_game 1 1 "4k3/8/8/8/8/8/8/R3K3 w - - 99 80")
play "$GAME" "" a1a2
check "hundredth quiet ply: fifty-move rule" "draw/fifty_move_rule" "$(json '.state + "/" + .termination')"
post /games '{"white":{"type":1},"black":{"type":1},"fen":"4k3/8/8/8/8/8/8/4K3 w - - 0 1"}' >/dev/null
check "bare kings are drawn at creation" "draw/insufficient_material" "$(json '.state + "/" + .termination')"
section "2. Resignation"
GAME=$(new_game 1 2)
play "$GAME" "" e2e4
check "resign infers the one human side" 200 "$(post "/games/$GAME/resign" '{}')"
check "result" "black wins/resignation" "$(json '.state + "/" + .termination')"
check "resigning twice" 400 "$(post "/games/$GAME/resign" '{}')"
check "PGN comment" "1. e4 { Black wins by resignation. } 0-1" \
"$(request GET "/games/$GAME/pgn" >/dev/null; movetext)"
GAME=$(new_game 1 1)
check "hot seat needs a color" 400 "$(post "/games/$GAME/resign" '{}')"
check "bad color" 400 "$(post "/games/$GAME/resign" '{"color":"red"}')"
GAME=$(new_game 2 2)
check "computer-only game cannot resign" 400 "$(post "/games/$GAME/resign" '{}')"
PLAYER="draw$(date +%s)$RANDOM"
TOKEN=$(register "$PLAYER" DrawPass1234)
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && ok "$PLAYER registered" || bad "register: $(cat "$TMP/body")"
GAME=$(new_game 1 1)
play "$GAME" "$TOKEN" e2e4 # claims white
check "anonymous caller cannot resign a claimed side" 403 "$(post "/games/$GAME/resign" '{"color":"w"}')"
check "claimant resigns without naming the side" 200 "$(post "/games/$GAME/resign" '{}' "$TOKEN")"
check "claimant's side lost" "black wins" "$(json .state)"
check "game listed for the claimant" "0-1" "$(request GET "/users/me/games?limit=1" \
-H "Authorization: Bearer $TOKEN" >/dev/null; json '.games[0].pgnResult')"
section "3. Draw offers between humans"
GAME=$(new_game 1 1)
play "$GAME" "" e2e4
check "black offers" "offered/b" "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null; json '.drawOutcome + "/" + .drawOffer')"
check "repeating a standing offer" "offered" "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null; json .drawOutcome)"
check "the offerer cannot accept" 400 "$(post "/games/$GAME/draw" '{"action":"accept","color":"b"}')"
check "white declines" "declined/null" "$(post "/games/$GAME/draw" '{"action":"decline","color":"w"}' >/dev/null; json '.drawOutcome + "/" + (.drawOffer // "null")')"
check "second offer in one move" 409 "$(post "/games/$GAME/draw" '{"action":"offer","color":"b"}')"
play "$GAME" "" e7e5 g1f3
post "/games/$GAME/draw" '{"action":"offer","color":"b"}' >/dev/null
check "offer allowed after a move" "b" "$(json .drawOffer)"
# A waiting client wakes up when the offer is answered.
MOVES=$(json '.moves | length')
START=$(date +%s)
curl -s -o "$TMP/poll" "$API_URL/games/$GAME?wait=true&moveCount=$MOVES" &
POLL=$!
sleep 0.5
post "/games/$GAME/draw" '{"action":"accept","color":"w"}' >/dev/null
check "accepting ends the game" "draw/agreement" "$(json '.state + "/" + .termination')"
wait "$POLL"
ELAPSED=$(($(date +%s) - START))
[ "$ELAPSED" -lt 10 ] && ok "long-poll woke after ${ELAPSED}s" || bad "long-poll waited ${ELAPSED}s"
check "long-poll saw the result" "draw" "$(jq -r .state "$TMP/poll")"
GAME=$(new_game 1 1)
play "$GAME" "" e2e4
post "/games/$GAME/draw" '{"action":"offer","color":"w"}' >/dev/null
play "$GAME" "" e7e5
check "moving instead of answering declines" "null" "$(request GET "/games/$GAME" >/dev/null; json '.drawOffer // "null"')"
check "accepting a lapsed offer" 400 "$(post "/games/$GAME/draw" '{"action":"accept","color":"b"}')"
check "unknown action" 400 "$(post "/games/$GAME/draw" '{"action":"maybe","color":"w"}')"
section "4. Draw offers to the computer"
GAME=$(new_game 1 2)
check "declined before move 10" "declined" "$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json .drawOutcome)"
check "one offer per move" 409 "$(post "/games/$GAME/draw" '{"action":"offer"}')"
# Level position: play 20 plies as humans, then hand Black to the computer.
GAME=$(new_game 1 1)
play "$GAME" "" $SYMMETRIC
request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \
-d '{"white":{"type":1},"black":{"type":2,"level":5,"searchTime":100}}' >/dev/null
check "computer accepts in a level position" "accepted/draw/agreement" \
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state + "/" + .termination')"
# White a queen up: the computer playing White declines.
GAME=$(new_game 1 1)
play "$GAME" "" $QUEEN_UP
request PUT "/games/$GAME/players" -H 'Content-Type: application/json' \
-d '{"white":{"type":2,"level":5,"searchTime":100},"black":{"type":1}}' >/dev/null
check "computer a queen up declines" "declined/ongoing" \
"$(post "/games/$GAME/draw" '{"action":"offer"}' >/dev/null; json '.drawOutcome + "/" + .state')"
section "5. A game whose row is deleted by hand"
if [ -z "$CHESS_TEST_DSN" ] || ! command -v psql >/dev/null; then
skip "CHESS_TEST_DSN or psql missing; skipped"
else
GAME=$(new_game 1 1)
play "$GAME" "" e2e4
request GET "/games/$GAME/history" >/dev/null # flush the writes
psql "$CHESS_TEST_DSN" -qAtc "DELETE FROM games WHERE game_id = '$GAME'" >/dev/null
check "move after the deletion is accepted in memory" 200 "$(post "/games/$GAME/moves" '{"move":"e7e5"}')"
for _ in $(seq 1 20); do
[ "$(request GET "/games/$GAME")" = 404 ] && break
sleep 0.1
done
check "the game is unloaded" 404 "$(request GET "/games/$GAME")"
check "storage stays healthy" "ok" "$(curl -s "$BASE_URL/health" | jq -r .storage)"
fi
echo -e "\n${CYAN}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
[ "$FAIL" -eq 0 ]
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# Replay API test suite: SAN in history, PGN export, ETag revalidation,
# promotion handling, FEN validation, and the cursor-paged game listing.
# Requires: curl, jq, and a server from test/run-test-server.sh. Games are
# played human-vs-human, so no engine search is involved.
BASE_URL=${BASE_URL:-"http://localhost:8080"}
API_URL="${BASE_URL}/api"
# Pause between requests; the dev-mode limiter allows 20 per second.
API_DELAY=${API_DELAY:-0.06}
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
section() { echo -e "\n${CYAN}== $1 ==${NC}"; }
ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); }
bad() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); }
check() { # check <description> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: expected '$2', got '$3'"; fi
}
# request <method> <path> [curl args...]: body to $TMP/body, headers to
# $TMP/headers, prints the status code.
request() {
local method=$1 path=$2
shift 2
sleep "$API_DELAY"
: >"$TMP/body" # curl leaves the file untouched when there is no body (304)
curl -s -o "$TMP/body" -D "$TMP/headers" -w '%{http_code}' -X "$method" "$@" "$API_URL$path"
}
header() { grep -i "^$1:" "$TMP/headers" | head -1 | cut -d' ' -f2- | tr -d '\r'; }
new_game() { # new_game [fen] [token]
local body='{"white":{"type":1},"black":{"type":1}}'
[ -n "$1" ] && body=$(jq -nc --arg fen "$1" '{white:{type:1},black:{type:1},fen:$fen}')
local auth=()
[ -n "$2" ] && auth=(-H "Authorization: Bearer $2")
request POST /games -H 'Content-Type: application/json' "${auth[@]}" -d "$body" >/dev/null
jq -r '.gameId' "$TMP/body"
}
play() { # play <gameId> <token|""> <uci>...
local id=$1 token=$2 status
shift 2
local auth=()
[ -n "$token" ] && auth=(-H "Authorization: Bearer $token")
for move in "$@"; do
status=$(request POST "/games/$id/moves" -H 'Content-Type: application/json' "${auth[@]}" \
-d "{\"move\":\"$move\"}")
if [ "$status" != 200 ]; then
bad "move $move: HTTP $status $(cat "$TMP/body")"
return 1
fi
done
}
register() { # register <user> <password>: prints the session token
request POST /auth/register -H 'Content-Type: application/json' \
-d "{\"username\":\"$1\",\"password\":\"$2\"}" >/dev/null
jq -r '.token' "$TMP/body"
}
if ! curl -sf "$BASE_URL/health" >/dev/null; then
echo -e "${RED}Server not reachable at $BASE_URL; start test/run-test-server.sh${NC}"
exit 1
fi
section "1. Checkmate: SAN, result, termination"
GAME=$(new_game)
play "$GAME" "" f2f3 e7e5 g2g4 d8h4
check "history status" 200 "$(request GET "/games/$GAME/history")"
check "SAN line" "f3 e5 g4 Qh4#" "$(jq -r '[.moves[].san] | join(" ")' "$TMP/body")"
check "pgnResult" "0-1" "$(jq -r .pgnResult "$TMP/body")"
check "termination" "checkmate" "$(jq -r .termination "$TMP/body")"
check "UCI kept" "d8h4" "$(jq -r '.moves[3].moveUci' "$TMP/body")"
section "2. ETag revalidation"
request GET "/games/$GAME/history" >/dev/null
ETAG=$(header ETag)
[ -n "$ETAG" ] && ok "ETag present ($ETAG)" || bad "ETag missing"
check "Cache-Control" "private, no-cache" "$(header Cache-Control)"
check "If-None-Match -> 304" 304 "$(request GET "/games/$GAME/history" -H "If-None-Match: $ETAG")"
check "304 has no body" 0 "$(wc -c <"$TMP/body" | tr -d ' ')"
check "stale ETag -> 200" 200 "$(request GET "/games/$GAME/history" -H 'If-None-Match: "stale"')"
section "3. PGN export"
check "pgn status" 200 "$(request GET "/games/$GAME/pgn")"
check "content type" "application/x-chess-pgn; charset=utf-8" "$(header Content-Type)"
case "$(header Content-Disposition)" in
"attachment; filename=\"chess-"*"-${GAME:0:8}.pgn\"") ok "attachment filename" ;;
*) bad "Content-Disposition: $(header Content-Disposition)" ;;
esac
check "seven tag roster order" "Event Site Date Round White Black Result" \
"$(head -7 "$TMP/body" | sed -E 's/^\[([A-Za-z]+) .*/\1/' | tr '\n' ' ' | sed 's/ $//')"
check "movetext" "1. f3 e5 2. g4 Qh4# { Black wins by checkmate. } 0-1" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
grep -q '^\[Termination "normal"\]$' "$TMP/body" && ok "Termination tag" || bad "Termination tag missing"
PGN_ETAG=$(header ETag)
check "pgn 304" 304 "$(request GET "/games/$GAME/pgn" -H "If-None-Match: $PGN_ETAG")"
check "pgn ?ply=2" 200 "$(request GET "/games/$GAME/pgn?ply=2")"
check "truncated movetext" "1. f3 e5 *" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
check "?ply=5 rejected" 400 "$(request GET "/games/$GAME/pgn?ply=5")"
check "?ply=-1 rejected" 400 "$(request GET "/games/$GAME/pgn?ply=-1")"
check "unknown game" 404 "$(request GET "/games/00000000-0000-4000-8000-000000000000/pgn")"
check "malformed ID" 400 "$(request GET "/games/not-a-uuid/pgn")"
section "4. Castling and en passant"
GAME=$(new_game)
play "$GAME" "" e2e4 d7d5 e4e5 f7f5 e5f6 g8f6 g1f3 e7e6 f1e2 f8d6 e1g1 e8g8
request GET "/games/$GAME/history" >/dev/null
check "SAN line" "e4 d5 e5 f5 exf6 Nxf6 Nf3 e6 Be2 Bd6 O-O O-O" \
"$(jq -r '[.moves[].san] | join(" ")' "$TMP/body")"
check "ongoing pgnResult" "*" "$(jq -r .pgnResult "$TMP/body")"
check "no termination while ongoing" "null" "$(jq -r .termination "$TMP/body")"
section "5. Promotion (the reported position)"
FEN="8/5KP1/5n2/p3k3/1p3p2/1P6/P7/8 w - - 4 55"
GAME=$(new_game "$FEN")
[ "$GAME" != null ] && ok "custom FEN game created" || bad "custom FEN game: $(cat "$TMP/body")"
check "g7g8 without piece rejected" 400 \
"$(request POST "/games/$GAME/moves" -H 'Content-Type: application/json' -d '{"move":"g7g8"}')"
case "$(jq -r .error "$TMP/body")" in
"promotion piece required"*) ok "error names the missing piece" ;;
*) bad "error: $(cat "$TMP/body")" ;;
esac
play "$GAME" "" g7g8n && ok "g7g8n accepted"
request GET "/games/$GAME/history" >/dev/null
check "promotion SAN" "g8=N" "$(jq -r '.moves[0].san' "$TMP/body")"
check "knight on g8" "6N1/5K2/5n2/p3k3/1p3p2/1P6/P7/8 b - - 0 55" "$(jq -r '.moves[0].fenAfterMove' "$TMP/body")"
request GET "/games/$GAME/pgn" >/dev/null
grep -q '^\[SetUp "1"\]$' "$TMP/body" && grep -q "^\[FEN \"$FEN\"\]$" "$TMP/body" \
&& ok "SetUp/FEN tags" || bad "SetUp/FEN tags missing"
check "numbering from move 55" "55. g8=N *" "$(sed -n '/^$/,$p' "$TMP/body" | sed '/^$/d')"
section "6. Invalid starting positions"
for fen in "8/8/8/8/8/8/8/8 w - - 0 1" "4k3/8/8/8/8/8/8/4K2r b - - 0 1" "4k3/8/8/8/8/8/8/P3K3 w - - 0 1"; do
status=$(request POST /games -H 'Content-Type: application/json' \
-d "$(jq -nc --arg fen "$fen" '{white:{type:1},black:{type:1},fen:$fen}')")
check "rejects $fen ($(jq -r .error "$TMP/body"))" 400 "$status"
done
section "7. My games: cursor paging and filters"
# A fresh account per run, so games from earlier runs do not interfere.
PLAYER="replay$(date +%s)$RANDOM"
TOKEN=$(register "$PLAYER" ReplayPass123)
[ -n "$TOKEN" ] && [ "$TOKEN" != null ] && ok "$PLAYER registered" || bad "register failed: $(cat "$TMP/body")"
IDS=()
for i in 1 2 3; do
GAME=$(new_game "" "$TOKEN")
play "$GAME" "$TOKEN" e2e4
IDS=("$GAME" "${IDS[@]}")
done
# The player takes White only; Black's moves are anonymous and leave that
# slot unclaimed.
GAME=$(new_game "" "$TOKEN")
play "$GAME" "$TOKEN" f2f3 && play "$GAME" "" e7e5 && play "$GAME" "$TOKEN" g2g4 && play "$GAME" "" d8h4
IDS=("$GAME" "${IDS[@]}")
SEEN=()
CURSOR=""
for page in 1 2 3; do
path="/users/me/games?limit=2"
[ -n "$CURSOR" ] && path="$path&cursor=$CURSOR"
check "page $page status" 200 "$(request GET "$path" -H "Authorization: Bearer $TOKEN")"
mapfile -t PAGE < <(jq -r '.games[].gameId' "$TMP/body")
SEEN+=("${PAGE[@]}")
CURSOR=$(jq -r '.nextCursor // empty' "$TMP/body")
[ -z "$CURSOR" ] && break
done
check "cursor pages cover the games newest first" "${IDS[*]}" "${SEEN[*]}"
check "finished filter" "${IDS[0]}" "$(request GET "/users/me/games?status=finished" \
-H "Authorization: Bearer $TOKEN" >/dev/null; jq -r '[.games[].gameId] | join(" ")' "$TMP/body")"
check "summary pgnResult" "0-1" "$(jq -r '.games[0].pgnResult' "$TMP/body")"
check "black filter (White only)" 0 "$(request GET "/users/me/games?color=black" \
-H "Authorization: Bearer $TOKEN" >/dev/null; jq '.games | length' "$TMP/body")"
check "bad cursor" 400 "$(request GET "/users/me/games?cursor=@@" -H "Authorization: Bearer $TOKEN")"
check "cursor with offset" 400 "$(request GET "/users/me/games?offset=2&cursor=$(jq -rn '"x"')" \
-H "Authorization: Bearer $TOKEN")"
check "bad status" 400 "$(request GET "/users/me/games?status=paused" -H "Authorization: Bearer $TOKEN")"
check "listing needs auth" 401 "$(request GET "/users/me/games")"
echo -e "\n${CYAN}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
[ "$FAIL" -eq 0 ]