Files
tiennm99 55abdb36c1 refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
2026-09-28 15:19:59 +07:00

204 lines
6.6 KiB
Go

package wsapi
import (
"io"
"net/http"
"testing"
"time"
)
// TestReadyzFlipsOnDrain: /healthz is liveness and never moves; /readyz is
// what a load balancer should stop trusting once the process has started
// shutting down.
func TestReadyzFlipsOnDrain(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{})
httpURL := "http" + url[len("ws"):]
resp, err := http.Get(httpURL + "/readyz")
if err != nil {
t.Fatalf("GET /readyz: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("before draining: status = %d, want 200", resp.StatusCode)
}
api.StartDraining()
resp, err = http.Get(httpURL + "/readyz")
if err != nil {
t.Fatalf("GET /readyz: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("while draining: status = %d, want 503", resp.StatusCode)
}
resp, err = http.Get(httpURL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("healthz while draining: status = %d, want 200 (liveness must not move)", resp.StatusCode)
}
}
// TestDrainRefusesNewRooms: a creator past the drain point is told the server
// is restarting, not that it is full — a full room might free up, a draining
// one never will.
func TestDrainRefusesNewRooms(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{})
api.StartDraining()
c := dial(t, url)
c.hello("Người thử")
c.createRoom()
if got := c.await("error").GetError().GetCode(); got != "server_restarting" {
t.Errorf("error code = %q, want server_restarting", got)
}
}
// TestLiveGameCountTracksGamesNotLobbies is the invariant a drain waits on:
// an empty room, or one sitting in its lobby, costs a restart nothing, and
// must not be counted the way a running game is.
func TestLiveGameCountTracksGamesNotLobbies(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{TurnLimit: 200 * time.Millisecond})
host, guest, _ := pvpLobby(t, url)
if got := api.LiveGameCount(); got != 0 {
t.Fatalf("a lobby with nobody playing counted as %d live games, want 0", got)
}
agreeAndStart(host, guest)
_, waits, _ := awaitLead(t, host, guest)
if got := api.LiveGameCount(); got != 1 {
t.Fatalf("live games = %d after starting, want 1", got)
}
// Nobody submits anything: the lead's own turn runs out the clock, which
// is enough to end a two-seat game.
waits.await("game_over")
settle()
if got := api.LiveGameCount(); got != 0 {
t.Errorf("live games = %d after the game ended, want 0", got)
}
}
// TestDrainRefusesStartGameOnAnExistingLobby covers what newRegisteredRoom's
// own drain check cannot: a lobby that existed before the drain decision has
// no further room-creation call to refuse, so beginGame itself has to know.
func TestDrainRefusesStartGameOnAnExistingLobby(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{})
host, guest, _ := pvpLobby(t, url)
guest.setReady(true)
host.await("room_state")
api.StartDraining()
host.startGame()
if got := host.await("error").GetError().GetCode(); got != "server_restarting" {
t.Errorf("error code = %q, want server_restarting", got)
}
}
// TestDrainRefusesQuickMatchAutoStart is C3's other beginGame call site: the
// second seat filling after the drain decision must not start a game either,
// even though nobody sent StartGame for it to refuse.
func TestDrainRefusesQuickMatchAutoStart(t *testing.T) {
h := &hub{}
h.draining.Store(true)
first := &session{id: "a", ctx: t.Context(), out: make(chan []byte, 8)}
second := &session{id: "b", ctx: t.Context(), out: make(chan []byte, 8)}
r := &room{hub: h, dict: chainDict(), turnLimit: time.Second, graceFor: time.Minute}
r.handleCreate(createInput{sess: first, autoStart: true})
r.handleJoin(joinInput{sess: second})
if r.engine != nil {
t.Error("quick match auto-started a game after the drain decision")
}
}
// TestDrainLetsALiveGameFinish is the other half of C3: draining must not cut
// a game that was already running short, which is the very outcome it exists
// to avoid.
func TestDrainLetsALiveGameFinish(t *testing.T) {
api, url := newTestServer(t, chainDict(), Config{TurnLimit: 200 * time.Millisecond})
lead, waits, _ := pvpGame(t, url)
api.StartDraining()
// Nobody submits anything: the lead's own turn runs out the clock, which
// is enough to end a two-seat game, same as TestLiveGameCountTracksGamesNotLobbies.
waits.await("game_over")
lead.await("game_over")
if got := api.LiveGameCount(); got != 0 {
t.Errorf("live games = %d after the game ended during a drain, want 0", got)
}
}
// TestVersionEndpoint: GET /version answers with exactly the string the
// server was configured with, in plain text, so a deploy check can diff it
// against what was just built without parsing anything.
func TestVersionEndpoint(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{Version: "v1.2.3-test"})
httpURL := "http" + url[len("ws"):]
resp, err := http.Get(httpURL + "/version")
if err != nil {
t.Fatalf("GET /version: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if got := string(body); got != "v1.2.3-test" {
t.Errorf("/version = %q, want %q", got, "v1.2.3-test")
}
}
// TestVersionEndpointDefaultsWhenUnset: a test server, like a plain `go run`,
// never sets Config.Version, and /version must still answer something rather
// than an empty body a deploy check would misread as broken.
func TestVersionEndpointDefaultsWhenUnset(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
httpURL := "http" + url[len("ws"):]
resp, err := http.Get(httpURL + "/version")
if err != nil {
t.Fatalf("GET /version: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if got := string(body); got != defaultVersion {
t.Errorf("/version = %q, want the default %q", got, defaultVersion)
}
}
// TestDebugVarsNotOnPublicMux: expvar's counters are process-global, but
// /debug/vars is only ever supposed to answer on the separate address
// NOITU_DEBUG_ADDR names. The public mux this test dials must never carry it.
func TestDebugVarsNotOnPublicMux(t *testing.T) {
_, url := newTestServer(t, chainDict(), Config{})
httpURL := "http" + url[len("ws"):]
resp, err := http.Get(httpURL + "/debug/vars")
if err != nil {
t.Fatalf("GET /debug/vars: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("/debug/vars on the public mux = %d, want 404 (it must live on NOITU_DEBUG_ADDR alone)", resp.StatusCode)
}
}