mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 17:42:29 +00:00
test(multiplayer): add a real Go+Postgres+Godot end-to-end integration test
Every existing test of the client/control-plane boundary is either a Go unit test with a mocked HTTP layer or a GDScript unit test with no network at all (multiplayer-next.md 8.40's own evidence names "live multi-process control-plane/game verification" as remaining). Nothing before this actually ran the real compiled Go binary, a real PostgreSQL instance, and a real headless Godot process talking real HTTP to each other -- and it immediately found a real bug (previous commit). server/cmd/testkit-api is a new, deliberately separate, clearly-marked test-only binary wired identically to cmd/control-plane except for SteamLogin: cmd/control-plane has no way to authenticate against a real Steam Web API from this sandbox (task 8.7's own documented blocker), so testkit-api accepts any non-empty ticket string and derives a deterministic identity instead. This bypass is confined to its own binary -- never a flag on cmd/control-plane, never referenced by any Dockerfile stage or Kubernetes manifest -- specifically so it can't become a footgun on the real one. Game/tests/control_plane_smoke.gd drives the real ControlPlaneClient autoload through login -> queue_create -> heartbeat against a real server and prints SMOKE PASS/FAIL, matching the existing net_smoke.gd convention. scripts/verify_control_plane_integration.sh orchestrates both sides (real postgres:17-alpine, the built testkit-api binary, the Godot client) end to end. Two real bugs surfaced building this, both fixed and re-verified, not just the target bug: the smoke script's own use of `go run` left a zombie process that survived cleanup and squatting on its port corrupted the NEXT run with a misleading "http=401 unauthorized" (now builds and runs a real binary directly, plus a belt-and-suspenders port-kill in cleanup); and calling heartbeat() synchronously from within a request_succeeded handler produced a spurious "Busy" because ControlPlaneClient's own internal resync (see previous commit) was still in flight -- the test now waits for ControlPlaneClient to go idle via a real Timer (call_deferred alone floods the message queue without ever yielding a frame for the in-flight request to complete). Verified stable across 3 consecutive full runs: real PostgreSQL container up, migrations applied, testkit-api built and started, real headless Godot client round-tripping login/queue/heartbeat, clean teardown with no leftover processes, containers, or bound ports each time.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
extends Node
|
||||
|
||||
# Real end-to-end smoke test for ControlPlaneClient against a REAL running
|
||||
# control-plane HTTP server backed by REAL PostgreSQL -- proving the actual
|
||||
# wire format (GDScript's HTTPRequest/JSON on one side, the real compiled Go
|
||||
# api.Service on the other) is compatible, not just that each side's own unit
|
||||
# tests pass in isolation. Every other ControlPlaneClient test in this repo
|
||||
# is either pure parsing/validation logic or drives the client against a
|
||||
# mock; nothing before this exercised a real network round trip end to end
|
||||
# (multiplayer-next.md 8.40's own evidence names this "live... verification"
|
||||
# as remaining).
|
||||
#
|
||||
# Run against scripts/verify_control_plane_integration.sh's server/cmd/testkit-api
|
||||
# instance -- see that script's own header for why a separate, clearly-marked
|
||||
# test-only binary exists rather than a flag on the real cmd/control-plane:
|
||||
#
|
||||
# godot --headless --path Game res://tests/control_plane_smoke.tscn -- \
|
||||
# --control-plane-url=http://127.0.0.1:PORT
|
||||
#
|
||||
# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1.
|
||||
|
||||
const TIMEOUT_SECONDS := 10.0
|
||||
|
||||
var _finished := false
|
||||
var _ticket_id := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var control_plane_url := ""
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--control-plane-url="):
|
||||
control_plane_url = arg.substr("--control-plane-url=".length())
|
||||
if control_plane_url.is_empty():
|
||||
_finish(false, "missing --control-plane-url")
|
||||
return
|
||||
# A syntactically valid but semantically meaningless placeholder token:
|
||||
# configure() validates format eagerly, but real auth doesn't exist until
|
||||
# login_steam()'s response overwrites it below. There is no other way to
|
||||
# set base_url alone.
|
||||
if not ControlPlaneClient.configure(control_plane_url, "0:0"):
|
||||
_finish(false, "configure() rejected a valid-looking base URL")
|
||||
return
|
||||
|
||||
_ticket_id = "smoke-ticket-%d" % Time.get_unix_time_from_system()
|
||||
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
|
||||
ControlPlaneClient.request_failed.connect(_on_request_failed)
|
||||
|
||||
var web_api_ticket := "smoke-web-api-ticket-%d" % Time.get_ticks_usec()
|
||||
var err := ControlPlaneClient.login_steam(web_api_ticket)
|
||||
if err != OK:
|
||||
_finish(false, "login_steam() failed to start: %s" % error_string(err))
|
||||
return
|
||||
print("SMOKE: logging in against %s..." % control_plane_url)
|
||||
|
||||
var timer := Timer.new()
|
||||
timer.wait_time = TIMEOUT_SECONDS
|
||||
timer.one_shot = true
|
||||
timer.timeout.connect(func(): _finish(false, "timed out after %.1fs" % TIMEOUT_SECONDS))
|
||||
add_child(timer)
|
||||
timer.start()
|
||||
|
||||
|
||||
func _on_request_succeeded(operation: String, payload: Dictionary) -> void:
|
||||
if _finished:
|
||||
return
|
||||
match operation:
|
||||
"steam_session":
|
||||
print("SMOKE: logged in as %s, creating a queue ticket..." % ControlPlaneClient.player_id)
|
||||
var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1)
|
||||
if err != OK:
|
||||
_finish(false, "queue_create() failed to start: %s" % error_string(err))
|
||||
"queue_create":
|
||||
if payload.get("ticket_id", "") != _ticket_id or payload.get("state", "") != "QUEUED":
|
||||
_finish(false, "unexpected queue_create payload: %s" % payload)
|
||||
return
|
||||
# apply_ticket_update (called for every "queue_"-prefixed response,
|
||||
# including this one) can itself decide the ticket needs a resync
|
||||
# and fire off a recover_queue() call -- a real, existing part of
|
||||
# MatchmakingState's own state machine, not something this test
|
||||
# controls. Wait for ControlPlaneClient to go idle before sending
|
||||
# the next request rather than assuming queue_create was the only
|
||||
# thing in flight.
|
||||
print("SMOKE: ticket %s QUEUED at revision %d, heartbeating once idle..." % [_ticket_id, ControlPlaneClient.state.revision])
|
||||
_send_heartbeat_once_idle()
|
||||
"queue_recover":
|
||||
pass # Expected background resync; the idle-wait above handles it.
|
||||
"queue_heartbeat":
|
||||
if int(payload.get("revision", -1)) <= 0:
|
||||
_finish(false, "heartbeat did not advance the revision: %s" % payload)
|
||||
return
|
||||
_finish(true, "login -> queue_create -> heartbeat all round-tripped against a real server")
|
||||
|
||||
|
||||
func _send_heartbeat_once_idle() -> void:
|
||||
if not ControlPlaneClient._operation.is_empty():
|
||||
# call_deferred alone floods the message queue without ever letting a
|
||||
# real frame (and therefore the in-flight HTTP request) actually
|
||||
# process -- a real Timer yields to the engine between checks.
|
||||
var poll := get_tree().create_timer(0.05)
|
||||
poll.timeout.connect(_send_heartbeat_once_idle)
|
||||
return
|
||||
var err := ControlPlaneClient.heartbeat(_ticket_id, ControlPlaneClient.state.revision)
|
||||
if err != OK:
|
||||
_finish(false, "heartbeat() failed to start: %s" % error_string(err))
|
||||
|
||||
|
||||
func _on_request_failed(operation: String, http_code: int, detail: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail])
|
||||
|
||||
|
||||
func _finish(passed: bool, detail: String) -> void:
|
||||
if _finished:
|
||||
return
|
||||
_finished = true
|
||||
if passed:
|
||||
print("SMOKE PASS: %s" % detail)
|
||||
get_tree().quit(0)
|
||||
else:
|
||||
print("SMOKE FAIL: %s" % detail)
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/control_plane_smoke.gd" id="1_cps"]
|
||||
|
||||
[node name="ControlPlaneSmoke" type="Node"]
|
||||
script = ExtResource("1_cps")
|
||||
Reference in New Issue
Block a user