#!/usr/bin/env bash set -euo pipefail # Real end-to-end verification: a real PostgreSQL instance, the real Go # api.Service wired exactly like cmd/control-plane (except for auth -- see # below), and a real headless Godot process driving ControlPlaneClient over # an actual network connection. Every other test of this boundary is either # a Go unit test with a mocked HTTP layer or a GDScript unit test with no # network at all; this is the one place that proves the wire format the two # languages actually agree on, not just that each side's own tests pass. # # Uses server/cmd/testkit-api rather than the real cmd/control-plane binary: # that binary has no way to authenticate a Steam Web API ticket without a # real Steam backend, which this sandbox cannot provide (see # multiplayer-next.md task 8.7). testkit-api is wired identically otherwise # and is never referenced by any Dockerfile stage or Kubernetes manifest -- # see its own file header for why that bypass is confined to a distinctly # named, obviously-not-production binary rather than a flag on the real one. root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$root_dir" godot_bin="${GODOT_BIN:-godot}" container_name="cosmic-clash-control-plane-integration" database="cosmic_clash_test" user="cosmic_clash_test" password="cosmic_clash_test" pg_port="55434" api_port="18099" logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-control-plane.XXXXXX")" assignment_smoke="${ASSIGNMENT_SMOKE:-0}" ranked_smoke="${RANKED_SMOKE:-0}" testkit_pid="" cleanup() { local status=$? if (( status != 0 )); then for log_file in "$logs_dir"/*.log; do [[ -f "$log_file" ]] || continue echo "--- $log_file" >&2 cat "$log_file" >&2 done fi [[ -n "$testkit_pid" ]] && kill "$testkit_pid" 2>/dev/null || true # Belt-and-suspenders after the go-run zombie above: make sure nothing is # left listening on this run's own port before the trap exits. lsof -ti "tcp:${api_port}" 2>/dev/null | xargs -r kill -9 2>/dev/null || true docker rm -f "$container_name" >/dev/null 2>&1 || true echo "Control-plane integration logs: $logs_dir" } trap cleanup EXIT docker rm -f "$container_name" >/dev/null 2>&1 || true docker run --rm -d --name "$container_name" \ -e POSTGRES_DB="$database" \ -e POSTGRES_USER="$user" \ -e POSTGRES_PASSWORD="$password" \ -p "${pg_port}:5432" postgres:17-alpine >/dev/null for attempt in $(seq 1 30); do if docker exec "$container_name" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then break fi if [ "$attempt" = 30 ]; then echo "PostgreSQL did not become ready" >&2 exit 1 fi sleep 1 done dsn="postgres://${user}:${password}@127.0.0.1:${pg_port}/${database}?sslmode=disable" # `go run` wraps the real binary in a build/exec parent whose own PID does # not reliably propagate a `kill` to the child it spawns -- confirmed the # hard way: a prior run's leftover process survived cleanup, kept squatting # on this exact port bound to an already-torn-down PostgreSQL container, and # silently intercepted the NEXT run's connection, turning a real login into # an "http=401 unauthorized" failure with no indication the server it # actually reached was a zombie from a previous run. Build once and run the # real binary directly so its own PID is what gets killed. go -C server build -o "$logs_dir/testkit-api" ./cmd/testkit-api COSMIC_CLASH_POSTGRES_DSN="$dsn" "$logs_dir/testkit-api" --listen="127.0.0.1:${api_port}" --migrations="$root_dir/server/migrations" \ >"$logs_dir/testkit-api.log" 2>&1 & testkit_pid=$! for attempt in $(seq 1 30); do if curl -sSf "http://127.0.0.1:${api_port}/healthz" >/dev/null 2>&1; then break fi if [ "$attempt" = 30 ]; then echo "testkit-api did not become ready" >&2 exit 1 fi sleep 1 done godot_args=(--control-plane-url="http://127.0.0.1:${api_port}") if [ "$assignment_smoke" = "1" ]; then # Seed one complete, player-scoped assignment behind the real API. The fake # Steam provider derives the player ID from the supplied ticket, so this # still exercises authenticated ownership and the PostgreSQL assignment # adapter; no session or assignment state is injected into Godot. assignment_ticket="assignment-smoke-web-api-ticket" assignment_player_id="testkit-$(printf '%s' "$assignment_ticket" | shasum -a 256 | awk '{print substr($1,1,16)}')" docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U "$user" -d "$database" -c " INSERT INTO identities (player_id, steam_id) VALUES ('$assignment_player_id', 'assignment-smoke-steam') ON CONFLICT (player_id) DO NOTHING; INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision) VALUES ('assignment-smoke-ticket', '$assignment_player_id', 'casual', 'ASSIGNMENT_READY', 'smoke-build', 1, now(), now() + interval '1 hour', 1) ON CONFLICT (ticket_id) DO NOTHING; INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, revision) VALUES ('assignment-smoke-match', 'casual', 'ASSIGNMENT_READY', 'EU', 1, 'assignment-smoke-server', 1) ON CONFLICT (match_id) DO NOTHING; INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('assignment-smoke-match', '$assignment_player_id', 'assignment-smoke-ticket', 0, 0) ON CONFLICT (match_id, player_id) DO NOTHING; INSERT INTO assignments (match_id, player_id, allocation_id, server_id, slot, region, client_build, protocol_version, transport, endpoint, join_authorisation, manifest_digest, expires_at, revision) VALUES ('assignment-smoke-match', '$assignment_player_id', 'assignment-smoke-allocation', 'assignment-smoke-server', 0, 'EU', 'smoke-build', 1, 'enet', '127.0.0.1:30001', 'assignment-smoke-join-authorisation', decode('000102030405060708090a0b0c0d0e0f', 'hex'), now() + interval '1 hour', 1) ON CONFLICT (match_id, player_id) DO NOTHING;" godot_args+=(--assignment-match-id="assignment-smoke-match" --steam-ticket="$assignment_ticket") elif [ "$ranked_smoke" = "1" ]; then ranked_ticket="ranked-profile-smoke-web-api-ticket" ranked_player_id="testkit-$(printf '%s' "$ranked_ticket" | shasum -a 256 | awk '{print substr($1,1,16)}')" docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U "$user" -d "$database" -c " INSERT INTO identities (player_id, steam_id) VALUES ('$ranked_player_id', 'ranked-profile-smoke-steam') ON CONFLICT (player_id) DO NOTHING; INSERT INTO ratings (player_id, rating, deviation, volatility, ranked_games, revision) VALUES ('$ranked_player_id', 1600, 120, 0.05, 12, 3) ON CONFLICT (player_id) DO NOTHING;" godot_args+=(--steam-ticket="$ranked_ticket" --ranked-profile-smoke) fi "$godot_bin" --headless --path Game res://tests/control_plane_smoke.tscn -- "${godot_args[@]}" \ >"$logs_dir/godot-client.log" 2>&1 status=$? if [ "$status" -ne 0 ] || ! grep -q "^SMOKE PASS:" "$logs_dir/godot-client.log"; then echo "Control-plane integration FAILED (exit $status)" >&2 cat "$logs_dir/godot-client.log" >&2 exit 1 fi echo "Control-plane integration PASS"