mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
801fca7cb0
domain.validCandidate hard-requires a non-empty PredictedRTT map, but
CreateQueueTicket persisted an empty one and the only endpoint that
could fill it returned 503 in every real binary, because Service.Probe
was assigned nowhere outside api tests. No client-created ticket could
ever be selected by the matcher. The Godot client had no probe method at
all, so even a wired backend was unreachable from the game.
Four distinct defects had to be fixed for this path to work:
Nothing issued the nonce ProbeProvider was meant to compare against, so
the contract could not be satisfied even in principle. Add
POST /v1/probes/{region}/challenge, backed by a durable single-use
challenge -- durable because any replica may serve the answer for a
challenge another replica issued. RTT is the interval between issuing
and receiving, so no client-reported latency reaches placement.
CreateQueueTicket marshalled a nil map to JSON `null`, a JSONB scalar
rather than an object, and jsonb_set rejects that with "cannot set path
in scalar". RecordProbe would have failed at runtime even once wired.
Persist an object, and normalise non-object values in the update for
rows already written.
A nil ProbeRecorder made the handler report success while persisting
nothing, which silently leaves the ticket unmatchable. That is a
misconfiguration, not a successful probe; it now returns 503.
A successful probe updated PostgreSQL only. The candidate inserted at
enqueue time carries an empty RTT map, and the Redis keyspace has its
TTL continually refreshed, so the stale entry need never repair itself.
Refresh that player's projection after the probe commits.
Client side: add the challenge/answer round trip and have the
matchmaking screen collect evidence before creating a ticket, since
queueing first produces a search that can never match. Probing every
region fully is not required -- placement uses whichever regions
answered -- but queueing with none is refused rather than silently
stalling.
New integration test drives the real enqueue and probe paths and then
asks the actual matcher predicate, rather than hand-building a candidate
the way the unit tests do -- which is exactly why they missed this.
Also make the integration schema reset drop the whole public schema: the
enumerated table list silently broke with each new migration.
560 lines
40 KiB
GDScript
560 lines
40 KiB
GDScript
extends "res://tests/test_case.gd"
|
|
|
|
const ControlPlaneClient = preload("res://scripts/control_plane_client.gd")
|
|
const RankedProfileState = preload("res://scripts/ranked_profile_state.gd")
|
|
|
|
|
|
func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
|
|
assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid")
|
|
assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid")
|
|
assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required")
|
|
assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation")
|
|
assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected")
|
|
|
|
var client := ControlPlaneClient.new()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
|
|
assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected")
|
|
assert_true(ControlPlaneClient.is_valid_web_api_ticket("ticket-value"), "ordinary Steam Web API ticket is accepted")
|
|
assert_true(not ControlPlaneClient.is_valid_web_api_ticket("ticket\nforged"), "ticket header characters are rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_web_api_ticket(""), "empty Steam ticket is rejected")
|
|
assert_true(ControlPlaneClient.is_valid_access_token("session-id:opaque-token"), "opaque session format is accepted")
|
|
assert_true(not ControlPlaneClient.is_valid_access_token(":opaque-token"), "missing session identifier is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_access_token("session-id:token\nforged"), "session header injection is rejected")
|
|
assert_eq(ControlPlaneClient.websocket_url("https://match.example"), "wss://match.example", "TLS control plane uses secure WebSocket")
|
|
assert_eq(ControlPlaneClient.websocket_url("http://127.0.0.1:8080"), "ws://127.0.0.1:8080", "local control plane uses WebSocket")
|
|
assert_eq(ControlPlaneClient.websocket_url("match.example"), "", "unscoped URL cannot become a WebSocket URL")
|
|
var unconfigured := ControlPlaneClient.new()
|
|
assert_eq(unconfigured.connect_event_stream(), ERR_UNAUTHORIZED, "event stream requires an authenticated session")
|
|
|
|
|
|
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
|
|
var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"}
|
|
var normalized := ControlPlaneClient.normalize_ticket(payload)
|
|
assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity")
|
|
assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection")
|
|
assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch")
|
|
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
|
|
|
|
|
|
func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
|
|
var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"})
|
|
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
|
|
assert_eq(ControlPlaneClient.normalize_ticket({"enqueued_at": "not-a-timestamp"})["enqueued_at_unix"], -1, "malformed enqueue time remains visibly invalid")
|
|
assert_eq(ControlPlaneClient.normalize_ticket({"expires_at": 123})["expires_at_unix"], -1, "non-string expiry remains visibly invalid")
|
|
|
|
|
|
func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void:
|
|
assert_true(not ControlPlaneClient.is_session_expired("", 1000), "legacy sessions without an expiry remain compatible")
|
|
assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry")
|
|
assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary")
|
|
assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed")
|
|
assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted")
|
|
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-02-30T12:00:00Z"), "impossible calendar date is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-13-01T12:00:00Z"), "impossible month is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected")
|
|
var valid_session := {"player_id": "player_1234567890", "access_token": "session-id:opaque-token", "expires_at": "2099-08-31T12:00:00Z"}
|
|
assert_true(ControlPlaneClient.is_valid_session_response(valid_session), "future session response is accepted")
|
|
var missing_expiry := valid_session.duplicate()
|
|
missing_expiry.erase("expires_at")
|
|
assert_true(not ControlPlaneClient.is_valid_session_response(missing_expiry), "session without expiry is rejected")
|
|
var malformed_expiry := valid_session.duplicate()
|
|
malformed_expiry["expires_at"] = "tomorrow"
|
|
assert_true(not ControlPlaneClient.is_valid_session_response(malformed_expiry), "malformed session expiry is rejected")
|
|
var expired_session := valid_session.duplicate()
|
|
expired_session["expires_at"] = "2000-01-01T00:00:00Z"
|
|
assert_true(not ControlPlaneClient.is_valid_session_response(expired_session), "expired session response is rejected")
|
|
|
|
|
|
func test_reconfiguration_discards_the_previous_session_expiry() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client.session_expires_at = "1970-01-01T00:00:01Z"
|
|
assert_true(client.configure("https://match.example", "new-session:opaque-token"), "new session configures successfully")
|
|
assert_eq(client.session_expires_at, "", "new credentials do not inherit the old expiry")
|
|
|
|
|
|
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
|
|
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket_123456789", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
|
|
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
|
|
var accepted := envelope.duplicate()
|
|
accepted["state"] = "ACCEPTED"
|
|
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
|
|
for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]:
|
|
var lifecycle := envelope.duplicate()
|
|
lifecycle["state"] = phase
|
|
assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase)
|
|
var bad_state := envelope.duplicate()
|
|
bad_state["state"] = "SECRET"
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
|
|
var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match_1234567890", "server_id": "server_123456789"}
|
|
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
|
|
var short_assignment_id := assignment.duplicate()
|
|
short_assignment_id["server_id"] = "server-1"
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(short_assignment_id), "short assignment server id is rejected")
|
|
assignment.erase("server_id")
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
|
|
var fractional := envelope.duplicate()
|
|
fractional["revision"] = 1.5
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
|
|
var negative := envelope.duplicate()
|
|
negative["revision"] = -1
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
|
|
var malformed_time := envelope.duplicate()
|
|
malformed_time["occurred_at"] = "yesterday"
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp is rejected")
|
|
var short_resource := envelope.duplicate()
|
|
short_resource["resource_id"] = "short"
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(short_resource), "short resource identifier is rejected")
|
|
var unsafe_resource := envelope.duplicate()
|
|
unsafe_resource["resource_id"] = "ticket_123456789/secret"
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected")
|
|
var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"}
|
|
assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted")
|
|
match_state["match_id"] = "different_match_123"
|
|
assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity")
|
|
|
|
|
|
func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds")
|
|
client._operation = "queue_heartbeat"
|
|
var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"}
|
|
client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer())
|
|
assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery")
|
|
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET")
|
|
assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity")
|
|
client.free()
|
|
|
|
|
|
func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
client.player_id = "player_1234567890"
|
|
client.state.begin_queue("ticket_assignment_1", "casual")
|
|
assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies")
|
|
client._operation = "queue_recover"
|
|
var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer())
|
|
assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot")
|
|
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key")
|
|
client.free()
|
|
|
|
|
|
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.state.begin_queue("ticket-reconnect", "casual"), "queue setup succeeds")
|
|
client._operation = "queue_heartbeat"
|
|
client._set_websocket_status("CONNECTED")
|
|
assert_eq(client._pending_resync_resource_id, "ticket-reconnect", "reconnect recovery is retained until the mutation completes")
|
|
client.free()
|
|
|
|
|
|
func test_transient_rest_recovery_failure_does_not_end_matchmaking() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
client.state.begin_queue("ticket_recovery_123", "casual")
|
|
client._operation = "queue_recover"
|
|
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
|
|
assert_eq(client.state.phase, MatchmakingState.QUEUED, "network failure during recovery keeps the active search")
|
|
assert_true(client.state.message.contains("retrying"), "recovery failure remains visible and retryable")
|
|
client._operation = "proposal_recover"
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), "[]".to_utf8_buffer())
|
|
assert_eq(client.state.phase, MatchmakingState.QUEUED, "malformed transient recovery response does not become terminal")
|
|
client.free()
|
|
|
|
|
|
func test_resync_of_terminal_proposal_recovers_the_ticket() -> void:
|
|
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", false), "ticket-terminal-resync", "terminal proposal resync targets the requeued ticket")
|
|
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", true), "proposal-terminal-resync", "open proposal resync retains the proposal target")
|
|
|
|
|
|
func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
|
|
assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable")
|
|
assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable")
|
|
assert_true(ControlPlaneClient.is_retryable_mutation_response(429), "rate limit is retryable")
|
|
assert_true(ControlPlaneClient.is_retryable_mutation_response(503), "server failure is retryable")
|
|
assert_true(not ControlPlaneClient.is_retryable_mutation_response(401), "authentication failure is not blindly replayed")
|
|
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
|
|
|
|
|
|
# multiplayer-next.md 8.43 named "duplicate-action recovery beyond proposals"
|
|
# and "regional outage retry UI" as remaining. Both mechanisms (can_retry_last_mutation /
|
|
# retry_last_mutation, and matchmaking.gd's queue button falling back to them)
|
|
# already existed in the client, but had no test coverage proving the
|
|
# generic (non-proposal) mutation path actually recovers end to end -- only
|
|
# is_retryable_mutation_response's pure classification was covered above.
|
|
func test_generic_mutation_retry_recovers_after_a_transient_failure() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
|
assert_true(client.state.begin_queue("ticket-retry-generic", "casual"), "queue setup succeeds")
|
|
# Simulate what _start_request itself would already have recorded before
|
|
# a real network call was in flight, the same way the pre-existing
|
|
# conflict-handler tests above set _operation directly.
|
|
client._operation = "queue_heartbeat"
|
|
client._last_mutation = {"operation": "queue_heartbeat", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-generic/heartbeat", "payload": {"revision": 0}, "key": "heartbeat-retry-key-123456", "expected_revision": 0}
|
|
assert_true(not client.can_retry_last_mutation(), "a mutation still in flight is never offered as retryable")
|
|
|
|
# A regional outage: the transport itself failed rather than returning a
|
|
# decoded HTTP status -- exactly the "regional outage retry" case. This
|
|
# transition is the actual previously-uncovered boundary: nothing tested
|
|
# that a generic (non-proposal) mutation ever becomes retryable at all,
|
|
# only is_retryable_mutation_response's pure classification above.
|
|
# retry_last_mutation's own dispatch is not exercised here: it reaches
|
|
# HTTPRequest.request(), which needs the node inside a live SceneTree,
|
|
# and test_runner.tscn runs every test method from within its own
|
|
# _ready() while the tree is still being built, so that is out of reach
|
|
# for this harness -- the "not offered at all" boundary below covers the
|
|
# part of retry_last_mutation this environment can exercise safely.
|
|
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
|
|
assert_true(client.can_retry_last_mutation(), "a transport failure on a non-proposal mutation is offered as retryable")
|
|
client.free()
|
|
|
|
|
|
func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
|
assert_true(client.state.begin_queue("ticket-retry-unsafe", "casual"), "queue setup succeeds")
|
|
client._operation = "queue_cancel"
|
|
client._last_mutation = {"operation": "queue_cancel", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-unsafe/cancel", "payload": {}, "key": "cancel-retry-key-123456", "expected_revision": 0}
|
|
# A 409 is a revision/idempotency conflict, not a transient failure --
|
|
# should_recover_queue_after_conflict owns recovering it instead, and a
|
|
# blind resend would replay a mutation whose precondition already failed.
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
|
assert_true(not client.can_retry_last_mutation(), "a conflict response is never offered as a blind retry")
|
|
assert_eq(client.retry_last_mutation(), ERR_INVALID_DATA, "retrying when not offered fails closed rather than resending a stale mutation")
|
|
client.free()
|
|
|
|
|
|
# connect_to_assignment() already existed, fully validated, with its own
|
|
# assignment_connection_started/assignment_connection_failed signals -- but
|
|
# nothing anywhere in the client ever called it. A player reaching the
|
|
# ASSIGNED phase (server confirms the complete roster) with a fetched, fresh
|
|
# assignment would simply sit on "Your match server is ready" forever,
|
|
# because the transport was never actually started. This is the wiring fix,
|
|
# not just new test coverage for existing behavior.
|
|
func test_client_starts_the_transport_once_the_ticket_reaches_assigned() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
|
client.player_id = "player_1234567890"
|
|
assert_true(client.state.begin_queue("ticket-connect-ready", "casual"), "queue setup succeeds")
|
|
|
|
# The assignment fetch (triggered independently, earlier, by
|
|
# ASSIGNMENT_READY) has already completed by the time ASSIGNED arrives --
|
|
# the common case.
|
|
client._operation = "assignment"
|
|
var assignment_payload := {"match_id": "match_connect_1234567890", "server_id": "server_connect_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65500", "join_authorisation": "opaque-join-token"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
|
|
assert_true(client.assignment.available, "assignment fetch applies")
|
|
|
|
var connect_started := [false]
|
|
var connect_failed := [false]
|
|
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
|
|
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
|
|
|
|
client._operation = "queue_recover"
|
|
var ticket_payload := {"ticket_id": "ticket-connect-ready", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_connect_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
|
|
|
# connect_to_assignment() itself calls state.mark_connecting() as part of a
|
|
# successful attempt, so by the time control returns here phase has
|
|
# already advanced past ASSIGNED to CONNECTING -- that advancement is
|
|
# itself the proof the connect was actually attempted.
|
|
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "reaching ASSIGNED with a ready assignment actually started the transport, rather than sitting idle")
|
|
assert_true(connect_started[0] or connect_failed[0], "connect_to_assignment's own signal fired")
|
|
assert_true(client._pending_connect_match_id.is_empty(), "an attempted connect is not left pending")
|
|
|
|
# A duplicate/replayed ASSIGNED event for the same match (e.g. an
|
|
# at-least-once outbox redelivery) must not fire a second connection
|
|
# attempt. Called directly against the guarded function rather than
|
|
# through another full _on_request_completed round-trip: phase has
|
|
# already moved on to CONNECTING, so both of _connect_when_assigned's own
|
|
# guards (phase != ASSIGNED, and the _connect_attempted_match_id match)
|
|
# now independently refuse a second attempt for this match.
|
|
connect_started[0] = false
|
|
connect_failed[0] = false
|
|
client._connect_when_assigned("match_connect_1234567890")
|
|
assert_true(not connect_started[0] and not connect_failed[0], "a duplicate connect attempt for an already-attempted match is not reattempted")
|
|
|
|
NetworkManager.shutdown()
|
|
client.free()
|
|
|
|
|
|
func test_client_defers_the_connect_until_the_assignment_fetch_completes() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
|
assert_true(client.state.begin_queue("ticket-connect-deferred", "casual"), "queue setup succeeds")
|
|
|
|
var connect_started := [false]
|
|
var connect_failed := [false]
|
|
client.assignment_connection_started.connect(func(_a): connect_started[0] = true)
|
|
client.assignment_connection_failed.connect(func(_d): connect_failed[0] = true)
|
|
|
|
# ASSIGNED arrives before the assignment fetch (triggered earlier by
|
|
# ASSIGNMENT_READY) has actually completed -- the ordering the deferred
|
|
# path exists for. client.assignment is still the default, unavailable one.
|
|
client._operation = "queue_recover"
|
|
var ticket_payload := {"ticket_id": "ticket-connect-deferred", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_deferred_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
|
|
|
assert_eq(client.state.phase, MatchmakingState.ASSIGNED, "ticket state machine still reaches ASSIGNED")
|
|
assert_eq(client._pending_connect_match_id, "match_deferred_1234567890", "the connect attempt is deferred until the assignment is actually available")
|
|
assert_true(not connect_started[0] and not connect_failed[0], "no connection attempt is made before the assignment is ready -- nothing to connect to yet")
|
|
client.free()
|
|
|
|
|
|
# Covers §8.43's "failed reconnect UX": connect_to_assignment()'s own
|
|
# synchronous failures previously only emitted assignment_connection_failed,
|
|
# a signal nothing in the client listened to -- state.phase stayed stuck at
|
|
# ASSIGNED, the UI kept showing "Your match server is ready" forever, and
|
|
# there was no way back to a fresh search.
|
|
func test_synchronous_assignment_connection_failure_surfaces_as_a_failed_search() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.state.begin_queue("ticket-connect-unavailable", "casual"), "queue setup succeeds")
|
|
# client.assignment is still the default, unavailable one.
|
|
var err := client.connect_to_assignment()
|
|
assert_eq(err, ERR_UNAUTHORIZED, "connect fails closed when the assignment isn't ready")
|
|
assert_eq(client.state.phase, MatchmakingState.FAILED, "the failure is surfaced as a failed search rather than leaving the UI stuck at ASSIGNED")
|
|
assert_true(client.state.message.to_lower().contains("unavailable") or client.state.message.to_lower().contains("expired"), "the failure detail is retained: %s" % client.state.message)
|
|
client.free()
|
|
|
|
|
|
# The likelier real-world failure than the synchronous one above:
|
|
# NetworkManager.join() returns OK immediately (the attempt started), but the
|
|
# actual ENet handshake fails asynchronously later -- unreachable server,
|
|
# refused connection, ENet's own ~5s connect timeout. This is exactly the gap
|
|
# main_menu.gd's own _on_connection_failed exists to cover for the
|
|
# direct-join flow; nothing covered it for a matchmaking-driven connect.
|
|
func test_async_network_connection_failure_after_assignment_ready_surfaces_as_a_failed_search() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
|
client.player_id = "player_1234567890"
|
|
assert_true(client.state.begin_queue("ticket-connect-asyncfail", "casual"), "queue setup succeeds")
|
|
client._operation = "assignment"
|
|
var assignment_payload := {"match_id": "match_asyncfail_1234567890", "server_id": "server_asyncfail_1234567890", "player_id": "player_1234567890", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "endpoint": "127.0.0.1:65501", "join_authorisation": "opaque-join-token"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(assignment_payload).to_utf8_buffer())
|
|
client._operation = "queue_recover"
|
|
var ticket_payload := {"ticket_id": "ticket-connect-asyncfail", "player_id": "player_1234567890", "playlist": "casual", "revision": 5, "state": "ASSIGNED", "match_id": "match_asyncfail_1234567890", "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2099-08-31T12:00:00Z"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(ticket_payload).to_utf8_buffer())
|
|
assert_eq(client.state.phase, MatchmakingState.CONNECTING, "the transport attempt started")
|
|
|
|
NetworkManager.connection_failed.emit()
|
|
assert_eq(client.state.phase, MatchmakingState.FAILED, "the async handshake failure is surfaced rather than leaving CONNECTING stuck forever")
|
|
|
|
NetworkManager.shutdown()
|
|
client.free()
|
|
|
|
|
|
func test_network_connection_failure_is_ignored_outside_a_matchmaking_driven_connect() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.state.begin_queue("ticket-unrelated-failure", "casual"), "queue setup succeeds")
|
|
# state.phase is QUEUED, not CONNECTING -- this connection_failed belongs
|
|
# to something else (e.g. main_menu.gd's own direct-join flow) and must
|
|
# not be misattributed to matchmaking.
|
|
NetworkManager.connection_failed.emit()
|
|
assert_eq(client.state.phase, MatchmakingState.QUEUED, "an unrelated connection_failed does not fail an active queue search")
|
|
client.free()
|
|
|
|
|
|
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
|
|
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
|
|
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
|
|
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket_1234567890/path"), "path separator is rejected")
|
|
|
|
|
|
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
|
|
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
|
|
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
|
|
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
|
|
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
|
|
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
|
|
|
|
|
|
func test_queue_conflict_response_handler_defers_ticket_recovery() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.state.begin_queue("ticket-handler", "casual"), "queue setup succeeds")
|
|
client._operation = "queue_heartbeat"
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
|
assert_eq(client._pending_resync_resource_id, "ticket-handler", "heartbeat conflict queues ticket recovery")
|
|
client._operation = "queue_cancel"
|
|
client._pending_resync_resource_id = ""
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
|
assert_eq(client._pending_resync_resource_id, "ticket-handler", "cancel conflict queues ticket recovery")
|
|
client.free()
|
|
|
|
|
|
# Covers §8.43's "version-mismatch-specific client messaging": a 426 Upgrade
|
|
# Required on queue_create (the server-side floor added alongside this test)
|
|
# must surface a distinct, actionable message rather than the server's raw
|
|
# generic error string, and must not offer a futile "Retry Search" -- the
|
|
# same client build will fail again identically every time.
|
|
func test_outdated_client_receives_a_distinct_message_and_no_retry_offer() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
|
client._operation = "queue_create"
|
|
client._last_queue_create = {"ticket_id": "ticket-outdated", "playlist": "casual", "client_build": "build-1", "protocol_version": 4, "key": "outdated-key-123456"}
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, HTTPClient.RESPONSE_UPGRADE_REQUIRED, PackedStringArray(), JSON.stringify({"error": "client_outdated"}).to_utf8_buffer())
|
|
assert_eq(client.state.phase, MatchmakingState.FAILED, "outdated client fails the search")
|
|
assert_true(client.state.message.to_lower().contains("update"), "message tells the player to update rather than repeating the raw server error: %s" % client.state.message)
|
|
assert_true(not client.can_retry_queue_create(), "retrying with the same outdated client build is never offered")
|
|
client.free()
|
|
|
|
|
|
func test_rest_responses_reject_malformed_resource_identifiers() -> void:
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
client._operation = "queue_recover"
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"ticket_id": "short", "playlist": "casual", "revision": 0, "state": "QUEUED"}).to_utf8_buffer())
|
|
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed queue response is not projected")
|
|
client._operation = "proposal_recover"
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"proposal_id": "proposal/unsafe", "revision": 0, "state": "OPEN"}).to_utf8_buffer())
|
|
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed proposal response is not projected")
|
|
client.free()
|
|
|
|
|
|
func test_queue_response_requires_the_complete_contract_shape() -> void:
|
|
var valid := {"ticket_id": "ticket_1234567890", "player_id": "player_1234567890", "playlist": "casual", "state": "QUEUED", "revision": 0, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
|
|
assert_true(ControlPlaneClient._valid_queue_response(valid), "complete queue response is accepted")
|
|
var missing_expiry := valid.duplicate()
|
|
missing_expiry.erase("expires_at")
|
|
assert_true(not ControlPlaneClient._valid_queue_response(missing_expiry), "queue response without expiry is rejected")
|
|
var fractional_revision := valid.duplicate()
|
|
fractional_revision["revision"] = 1.5
|
|
assert_true(not ControlPlaneClient._valid_queue_response(fractional_revision), "fractional queue revision is rejected")
|
|
var malformed_player := valid.duplicate()
|
|
malformed_player["player_id"] = "player/unsafe"
|
|
assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected")
|
|
var assigned := valid.duplicate()
|
|
assigned["state"] = "ASSIGNMENT_READY"
|
|
assigned["match_id"] = "match_1234567890"
|
|
assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity")
|
|
var premature_match := valid.duplicate()
|
|
premature_match["match_id"] = "match_1234567890"
|
|
assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity")
|
|
assigned["match_id"] = "match/unsafe"
|
|
assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected")
|
|
var proposed := valid.duplicate()
|
|
proposed["state"] = "PROPOSED"
|
|
proposed["proposal_id"] = "proposal_12345678"
|
|
assert_true(ControlPlaneClient._valid_queue_response(proposed), "recovered proposed ticket carries its proposal lookup identity")
|
|
var client := ControlPlaneClient.new()
|
|
client._ready()
|
|
client.state.begin_queue("ticket_1234567890", "casual")
|
|
client._queue_proposal_if_ready(proposed)
|
|
assert_eq(client._pending_proposal_id, "proposal_12345678", "recovered proposal is queued for authoritative fetch")
|
|
assert_eq(client.state.proposal_id, "proposal_12345678", "recovered proposal identity becomes the active projection")
|
|
client.free()
|
|
|
|
|
|
func test_proposal_response_requires_structured_unique_participants() -> void:
|
|
var base := {"proposal_id": "proposal_1234567890", "expires_at": "2099-08-31T12:00:00Z", "participants": [
|
|
{"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0},
|
|
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
|
|
]}
|
|
assert_true(ControlPlaneClient._valid_proposal_response(base), "structured proposal participants are accepted")
|
|
var duplicate := base.duplicate(true)
|
|
duplicate["participants"][1]["player_id"] = "player_1234567890"
|
|
assert_true(not ControlPlaneClient._valid_proposal_response(duplicate), "duplicate participant identity is rejected")
|
|
var fractional_slot := base.duplicate(true)
|
|
fractional_slot["participants"][0]["slot"] = 0.5
|
|
assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected")
|
|
var malformed_expiry := base.duplicate(true)
|
|
malformed_expiry["expires_at"] = "tomorrow"
|
|
assert_true(not ControlPlaneClient._valid_proposal_response(malformed_expiry), "malformed proposal expiry is rejected")
|
|
var missing_expiry := base.duplicate(true)
|
|
missing_expiry.erase("expires_at")
|
|
assert_true(not ControlPlaneClient._valid_proposal_response(missing_expiry), "missing proposal expiry is rejected")
|
|
assert_true(int(ControlPlaneClient.normalize_proposal(base)["expires_at_unix"]) > 0, "proposal expiry is normalized")
|
|
|
|
|
|
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
|
|
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
|
|
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
|
|
assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer")
|
|
for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]:
|
|
assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe)
|
|
|
|
|
|
func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void:
|
|
var profile := RankedProfileState.new()
|
|
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "season_1234567890"}), "valid profile applies")
|
|
assert_eq(profile.display_text(), "Provisional · 3 ranked games", "provisional status overrides tier presentation")
|
|
assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected")
|
|
assert_true(not profile.available, "unsafe response is not displayed")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "MASTER", "provisional": false}), "unknown tier is rejected")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3.5, "tier": "GOLD", "provisional": false}), "fractional ranked games is rejected")
|
|
|
|
|
|
func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
|
|
var profile := RankedProfileState.new()
|
|
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "season_1234567890", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies")
|
|
assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time")
|
|
assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": "not-a-timestamp"}), "malformed season expiry is rejected")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": 123}), "non-string season expiry is rejected")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "short"}), "short season identifier is rejected")
|
|
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": 123}), "non-string season identifier is rejected")
|
|
|
|
|
|
# The client had no probe support at all, so even with the backend wired a real
|
|
# player could never acquire the RTT evidence the matcher requires.
|
|
func test_probe_region_validation_rejects_unknown_regions() -> void:
|
|
assert_true(ControlPlaneClient.is_valid_probe_region("EU"), "EU is a placement region")
|
|
assert_true(ControlPlaneClient.is_valid_probe_region("NA"), "NA is a placement region")
|
|
for region in ["", "eu", "APAC", "EU/NA", "../EU"]:
|
|
assert_true(not ControlPlaneClient.is_valid_probe_region(region), "rejects %s" % region)
|
|
|
|
|
|
func test_probe_requests_require_a_session() -> void:
|
|
var client = ControlPlaneClient.new()
|
|
client.base_url = "http://127.0.0.1:8080"
|
|
client.access_token = ""
|
|
assert_eq(client.request_probe_challenge("EU"), ERR_UNAUTHORIZED, "probing without a session is refused")
|
|
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", "bG9j"), ERR_UNAUTHORIZED, "answering without a session is refused")
|
|
client.free()
|
|
|
|
|
|
func test_probe_answer_rejects_empty_nonce_or_location() -> void:
|
|
var client = ControlPlaneClient.new()
|
|
client.base_url = "http://127.0.0.1:8080"
|
|
client.access_token = "session-1234567890:token-1234567890"
|
|
assert_eq(client.submit_probe_answer("EU", "", "bG9j"), ERR_INVALID_PARAMETER, "an empty nonce is refused")
|
|
assert_eq(client.submit_probe_answer("EU", "bm9uY2U=", ""), ERR_INVALID_PARAMETER, "an empty location is refused")
|
|
assert_eq(client.request_probe_challenge("APAC"), ERR_INVALID_PARAMETER, "an unknown region is refused")
|
|
client.free()
|
|
|
|
|
|
func test_opaque_location_payload_is_never_empty() -> void:
|
|
# The backend rejects an empty opaque location, and without a Steam runtime
|
|
# there is nothing real to report -- but the RTT the backend measures is
|
|
# what actually drives placement, so the probe must still be answerable.
|
|
var payload := ControlPlaneClient.opaque_location_payload()
|
|
assert_true(not payload.is_empty(), "a probe answer always carries a location blob")
|
|
assert_true(not Marshalls.base64_to_raw(payload).is_empty(), "the location blob is valid base64")
|
|
|
|
|
|
func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void:
|
|
var client = ControlPlaneClient.new()
|
|
client.base_url = "http://127.0.0.1:8080"
|
|
client.access_token = "session-1234567890:token-1234567890"
|
|
var failures: Array = []
|
|
client.request_failed.connect(func(operation: String, _code: int, detail: String): failures.append([operation, detail]))
|
|
client._operation = "probe_challenge_EU"
|
|
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer())
|
|
assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure")
|
|
client.free()
|