package ws_test import ( "context" "encoding/json" "log/slog" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/coder/websocket" "github.com/golang-jwt/jwt/v5" "github.com/ruby-sea-battle/game-server/internal/auth" "github.com/ruby-sea-battle/game-server/internal/game" "github.com/ruby-sea-battle/game-server/internal/protocol" "github.com/ruby-sea-battle/game-server/internal/ws" ) var secret = []byte("integration-secret") const publicAddr = "ws://game.example.com/ws" func gameToken(t *testing.T, matchID string, p1, p2 int64, gameServer string, exp time.Time) string { t.Helper() token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &auth.GameClaims{ MatchID: matchID, Player1ID: p1, Player2ID: p2, GameServer: gameServer, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(exp), }, }).SignedString(secret) if err != nil { t.Fatalf("sign game token: %v", err) } return token } func userToken(t *testing.T, userID int64) string { t.Helper() token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &auth.UserClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, }).SignedString(secret) if err != nil { t.Fatalf("sign user token: %v", err) } return token } func startServerWithManager(t *testing.T, joinTimeout time.Duration) (string, *game.Manager) { t.Helper() manager := game.NewManager(joinTimeout) tw := &testWriter{t: t} log := slog.New(slog.NewTextHandler(tw, nil)) handler := ws.NewHandler(manager, secret, publicAddr, []string{"*"}, log) srv := httptest.NewServer(handler) t.Cleanup(func() { srv.Close() tw.mu.Lock() tw.done = true tw.mu.Unlock() }) return "ws" + strings.TrimPrefix(srv.URL, "http"), manager } func startServer(t *testing.T, joinTimeout time.Duration) (url string) { u, _ := startServerWithManager(t, joinTimeout) return u } type testWriter struct { mu sync.Mutex t *testing.T done bool } func (w *testWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() if !w.done { w.t.Log(strings.TrimSpace(string(p))) } return len(p), nil } func dial(t *testing.T, url string) *websocket.Conn { t.Helper() c, _, err := websocket.Dial(context.Background(), url, nil) if err != nil { t.Fatalf("dial: %v", err) } t.Cleanup(func() { c.Close(websocket.StatusNormalClosure, "") }) return c } func join(t *testing.T, c *websocket.Conn, gt, ut string) { t.Helper() msg, _ := json.Marshal([]any{1, gt, ut}) if err := c.Write(context.Background(), websocket.MessageText, msg); err != nil { t.Fatalf("write JOIN: %v", err) } } func readMsg(t *testing.T, c *websocket.Conn) []any { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, data, err := c.Read(ctx) if err != nil { t.Fatalf("read: %v", err) } var v []any if err := json.Unmarshal(data, &v); err != nil { t.Fatalf("unmarshal %s: %v", data, err) } return v } func TestTwoPlayersStartMatch(t *testing.T) { url := startServer(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-1", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) for i, c := range []*websocket.Conn{c1, c2} { v := readMsg(t, c) if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) { t.Fatalf("player %d: unexpected message %v", i, v) } // ACK the state. msg, _ := json.Marshal([]any{4, 1}) if err := c.Write(context.Background(), websocket.MessageText, msg); err != nil { t.Fatalf("write ACK: %v", err) } } } func TestWrongServerAddressRejected(t *testing.T) { url := startServer(t, time.Minute) gt := gameToken(t, "m-2", 1, 2, "ws://other.example.com/ws", time.Now().Add(time.Hour)) c := dial(t, url) join(t, c, gt, userToken(t, 1)) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := c.Read(ctx) if err == nil { t.Fatal("expected connection to be closed") } if code := websocket.CloseStatus(err); code != 4003 { t.Fatalf("close code = %d, want 4003", code) } } func TestJoinTimeoutEndsMatchAndReplay(t *testing.T) { url := startServer(t, 200*time.Millisecond) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-3", 1, 2, publicAddr, exp) c1 := dial(t, url) join(t, c1, gt, userToken(t, 1)) v := readMsg(t, c1) // END if v[0] != float64(2) || v[1] != float64(1) || v[2] != float64(-1) || v[3] != "join timeout exceeded" { t.Fatalf("unexpected END: %v", v) } // Late join with the same token replays END and closes. c2 := dial(t, url) join(t, c2, gt, userToken(t, 2)) v = readMsg(t, c2) if v[0] != float64(2) || v[2] != float64(-1) { t.Fatalf("unexpected replayed END: %v", v) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if _, _, err := c2.Read(ctx); err == nil { t.Fatal("expected connection to be closed after END replay") } } func TestReconnectDuringGame(t *testing.T) { url := startServer(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-4", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) readMsg(t, c1) // STATE, not acknowledged // Simulate a dropped connection. c1.Close(websocket.StatusAbnormalClosure, "network gone") // Reconnect with the same tokens: unacknowledged STATE is replayed. c1b := dial(t, url) join(t, c1b, gt, userToken(t, 1)) v := readMsg(t, c1b) if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) { t.Fatalf("unexpected replayed message: %v", v) } } func TestReconnectWithExpiredTokenAllowedIfSameToken(t *testing.T) { url := startServer(t, time.Minute) // Short expiration: 1200ms exp := time.Now().Add(1200 * time.Millisecond) gt := gameToken(t, "m-reconnect-exp", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) readMsg(t, c1) // STATE readMsg(t, c2) // STATE // Wait for the game token to expire time.Sleep(1300 * time.Millisecond) // Simulate disconnect c1.Close(websocket.StatusAbnormalClosure, "network gone") // Reconnect with the SAME token (which is now expired) c1b := dial(t, url) join(t, c1b, gt, userToken(t, 1)) v := readMsg(t, c1b) if v[0] != float64(3) || v[1] != float64(1) || v[2] != float64(1) { t.Fatalf("unexpected replayed message on reconnect: %v", v) } // But an expired token for a non-existent match is rejected with 4003 gtExpired := gameToken(t, "m-other", 1, 2, publicAddr, time.Now().Add(-time.Hour)) cOther := dial(t, url) join(t, cOther, gtExpired, userToken(t, 1)) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := cOther.Read(ctx) if err == nil { t.Fatal("expected connection to be rejected") } if code := websocket.CloseStatus(err); code != 4003 { t.Fatalf("close code = %d, want 4003", code) } } func TestDuplicateJoinIgnored(t *testing.T) { url := startServer(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-dup-join", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) readMsg(t, c1) // STATE // Send duplicate JOIN on c1 join(t, c1, gt, userToken(t, 1)) // Send ACK after duplicate JOIN; connection must still be open and functioning ackMsg, _ := json.Marshal([]any{4, 1}) if err := c1.Write(context.Background(), websocket.MessageText, ackMsg); err != nil { t.Fatalf("write ACK: %v", err) } } func TestNonPositiveIDsRejected(t *testing.T) { url := startServer(t, time.Minute) exp := time.Now().Add(time.Hour) // Non-positive Player1ID gtBadP1 := gameToken(t, "m-bad-p1", 0, 2, publicAddr, exp) c := dial(t, url) join(t, c, gtBadP1, userToken(t, 2)) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := c.Read(ctx) if err == nil || websocket.CloseStatus(err) != 4003 { t.Fatalf("expected 4003 for player1_id = 0, got err = %v", err) } // Non-positive UserID gtValid := gameToken(t, "m-valid", 1, 2, publicAddr, exp) c2 := dial(t, url) join(t, c2, gtValid, userToken(t, -1)) ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) defer cancel2() _, _, err = c2.Read(ctx2) if err == nil || websocket.CloseStatus(err) != 4004 { t.Fatalf("expected 4004 for user_id = -1, got err = %v", err) } } func TestManagerShutdownClosesConnectionsGoingAway(t *testing.T) { url, mgr := startServerWithManager(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-shutdown", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) readMsg(t, c1) readMsg(t, c2) errCh := make(chan error, 2) for _, c := range []*websocket.Conn{c1, c2} { go func(conn *websocket.Conn) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, _, err := conn.Read(ctx) errCh <- err }(c) } mgr.Shutdown() for i := 0; i < 2; i++ { err := <-errCh if err == nil { t.Fatalf("client %d: expected connection to be closed", i) } if code := websocket.CloseStatus(err); code != 1001 { t.Fatalf("client %d: close status = %d, want 1001", i, code) } } } func validTestShips() []protocol.Ship { return []protocol.Ship{ // 4 size 1 {Orientation: protocol.OrientationHorizontal, X: 9, Y: 4, Size: 1}, {Orientation: protocol.OrientationVertical, X: 7, Y: 4, Size: 1}, {Orientation: protocol.OrientationHorizontal, X: 5, Y: 4, Size: 1}, {Orientation: protocol.OrientationHorizontal, X: 3, Y: 4, Size: 1}, // 3 size 2 {Orientation: protocol.OrientationHorizontal, X: 0, Y: 4, Size: 2}, {Orientation: protocol.OrientationHorizontal, X: 7, Y: 2, Size: 2}, {Orientation: protocol.OrientationHorizontal, X: 4, Y: 2, Size: 2}, // 2 size 3 {Orientation: protocol.OrientationHorizontal, X: 0, Y: 2, Size: 3}, {Orientation: protocol.OrientationHorizontal, X: 5, Y: 0, Size: 3}, // 1 size 4 {Orientation: protocol.OrientationHorizontal, X: 0, Y: 0, Size: 4}, } } func TestPlaceShipsOverWebSocket(t *testing.T) { url, mgr := startServerWithManager(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-ws-placeships", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) // Both receive StatePlacingShips (seq 1) v1 := readMsg(t, c1) if v1[0] != float64(protocol.OpState) || v1[1] != float64(1) || v1[2] != float64(protocol.StatePlacingShips) { t.Fatalf("c1 unexpected initial state: %v", v1) } v2 := readMsg(t, c2) if v2[0] != float64(protocol.OpState) || v2[1] != float64(1) || v2[2] != float64(protocol.StatePlacingShips) { t.Fatalf("c2 unexpected initial state: %v", v2) } // ACK seq 1 _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1)) // Player 1 sends PlaceShips ships := validTestShips() placeMsg := protocol.BuildPlaceShips(ships) if err := c1.Write(context.Background(), websocket.MessageText, placeMsg); err != nil { t.Fatalf("write PlaceShips c1: %v", err) } // c1 gets StateWaitOpponentReady (3), c2 gets StateOpponentReady (2) v1 = readMsg(t, c1) if v1[0] != float64(protocol.OpState) || v1[1] != float64(2) || v1[2] != float64(protocol.StateWaitOpponentReady) { t.Fatalf("c1 got %v, want StateWaitOpponentReady (3)", v1) } v2 = readMsg(t, c2) if v2[0] != float64(protocol.OpState) || v2[1] != float64(2) || v2[2] != float64(protocol.StateOpponentReady) { t.Fatalf("c2 got %v, want StateOpponentReady (2)", v2) } _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2)) // Player 2 sends PlaceShips if err := c2.Write(context.Background(), websocket.MessageText, placeMsg); err != nil { t.Fatalf("write PlaceShips c2: %v", err) } // Game starts: Player 1 was first, so c1 gets StateYourTurn (4), c2 gets StateWaitOpponentTurn (5) v1 = readMsg(t, c1) if v1[0] != float64(protocol.OpState) || v1[1] != float64(3) || v1[2] != float64(protocol.StateYourTurn) { t.Fatalf("c1 got %v, want StateYourTurn (4)", v1) } v2 = readMsg(t, c2) if v2[0] != float64(protocol.OpState) || v2[1] != float64(3) || v2[2] != float64(protocol.StateWaitOpponentTurn) { t.Fatalf("c2 got %v, want StateWaitOpponentTurn (5)", v2) } _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3)) // Check that the match on the server has the placements stored match := mgr.GetMatch("m-ws-placeships") if match == nil { t.Fatal("match not found on manager") } if len(match.Ships(0)) != 10 || len(match.Ships(1)) != 10 { t.Fatalf("ships not stored on match: p1=%d, p2=%d", len(match.Ships(0)), len(match.Ships(1))) } if _, ok := match.Board(0); !ok { t.Fatal("p1 board not placed") } if _, ok := match.Board(1); !ok { t.Fatal("p2 board not placed") } } func TestFireOverWebSocket(t *testing.T) { url, _ := startServerWithManager(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-ws-fire", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) // Initial StatePlacingShips (seq 1) readMsg(t, c1) readMsg(t, c2) _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(1)) // Both place ships ships := validTestShips() _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships)) readMsg(t, c1) // seq 2: StateWaitOpponentReady readMsg(t, c2) // seq 2: StateOpponentReady _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(2)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships)) readMsg(t, c1) // seq 3: StateYourTurn (p1 starts) readMsg(t, c2) // seq 3: StateWaitOpponentTurn _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3)) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(3)) // 1. Player 1 sends invalid position (-1, 0) _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(-1, 0)) v1 := readMsg(t, c1) // [7, 4, 4] if v1[0] != float64(protocol.OpFireResult) || v1[1] != float64(4) || v1[2] != float64(protocol.FireResultInvalidPosition) { t.Fatalf("c1 invalid position result = %v, want [7, 4, 4]", v1) } _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(4)) // 2. Player 1 hits ship at (0, 0) _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(0, 0)) v1 = readMsg(t, c1) // [7, 5, 2] if v1[0] != float64(protocol.OpFireResult) || v1[1] != float64(5) || v1[2] != float64(protocol.FireResultHit) { t.Fatalf("c1 hit result = %v, want [7, 5, 2]", v1) } _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(5)) // 3. Player 1 misses at (9, 9) _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(9, 9)) v1Fire := readMsg(t, c1) // [7, 6, 1] if v1Fire[0] != float64(protocol.OpFireResult) || v1Fire[1] != float64(6) || v1Fire[2] != float64(protocol.FireResultMiss) { t.Fatalf("c1 miss result = %v, want [7, 6, 1]", v1Fire) } _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(6)) // Turn transitions: c1 gets StateWaitOpponentTurn (5) with seq 7 v1State := readMsg(t, c1) if v1State[0] != float64(protocol.OpState) || v1State[1] != float64(7) || v1State[2] != float64(protocol.StateWaitOpponentTurn) { t.Fatalf("c1 state after miss = %v, want [3, 7, 5]", v1State) } _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildAck(7)) // c2 gets StateYourTurn (4) with seq 4 v2State := readMsg(t, c2) if v2State[0] != float64(protocol.OpState) || v2State[1] != float64(4) || v2State[2] != float64(protocol.StateYourTurn) { t.Fatalf("c2 state after miss = %v, want [3, 4, 4]", v2State) } _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildAck(4)) // 4. Player 1 tries to fire while it's Player 2's turn -> ignored _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(5, 5)) // 5. Player 2 fires at (9, 4) -> single ship sunk _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildFire(9, 4)) v2Fire := readMsg(t, c2) if v2Fire[0] != float64(protocol.OpFireResult) || v2Fire[1] != float64(5) || v2Fire[2] != float64(protocol.FireResultSunk) { t.Fatalf("c2 sunk result = %v, want [7, 5, 3]", v2Fire) } } func TestFireWinOverWebSocket(t *testing.T) { url, _ := startServerWithManager(t, time.Minute) exp := time.Now().Add(time.Hour) gt := gameToken(t, "m-ws-fire-win", 1, 2, publicAddr, exp) c1, c2 := dial(t, url), dial(t, url) join(t, c1, gt, userToken(t, 1)) join(t, c2, gt, userToken(t, 2)) // Initial StatePlacingShips readMsg(t, c1) readMsg(t, c2) // Both place ships ships := validTestShips() _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships)) readMsg(t, c1) readMsg(t, c2) _ = c2.Write(context.Background(), websocket.MessageText, protocol.BuildPlaceShips(ships)) readMsg(t, c1) // StateYourTurn readMsg(t, c2) // StateWaitOpponentTurn // Player 1 destroys all 10 ships of Player 2 for i, ship := range ships { cells := ship.Cells() for j, c := range cells { _ = c1.Write(context.Background(), websocket.MessageText, protocol.BuildFire(c.X, c.Y)) v := readMsg(t, c1) if j < len(cells)-1 { if v[0] != float64(protocol.OpFireResult) || v[2] != float64(protocol.FireResultHit) { t.Fatalf("ship %d cell %d: want Hit, got %v", i, j, v) } } else { if v[0] != float64(protocol.OpFireResult) || v[2] != float64(protocol.FireResultSunk) { t.Fatalf("ship %d cell %d: want Sunk, got %v", i, j, v) } } } } // Player 1 receives END victory v1End := readMsg(t, c1) if v1End[0] != float64(protocol.OpEnd) || v1End[2] != float64(protocol.EndCodeVictory) || v1End[3] != "victory" { t.Fatalf("c1 want END victory, got %v", v1End) } // Player 2 receives END defeat v2End := readMsg(t, c2) if v2End[0] != float64(protocol.OpEnd) || v2End[2] != float64(protocol.EndCodeDefeat) || v2End[3] != "defeat" { t.Fatalf("c2 want END defeat, got %v", v2End) } // Connections should be closed with 1000 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _, _, err1 := c1.Read(ctx) if err1 == nil { t.Fatal("expected c1 connection to be closed") } _, _, err2 := c2.Read(ctx) if err2 == nil { t.Fatal("expected c2 connection to be closed") } // Late reconnect gets END replayed and connection closed c1Late := dial(t, url) join(t, c1Late, gt, userToken(t, 1)) lateEnd := readMsg(t, c1Late) if lateEnd[0] != float64(protocol.OpEnd) || lateEnd[2] != float64(protocol.EndCodeVictory) { t.Fatalf("late join want END victory, got %v", lateEnd) } }