websocket-relay/internal/hub/hub_edge_cases_test.go
savinmax 18063fb3ef test(hub): add edge case tests for paths, query strings, and room lifecycle
Add comprehensive integration tests covering:
- TestIntegration_PathWithSlashes: nested paths work as distinct rooms
- TestIntegration_QueryStringIgnored: query strings stripped, same room
- TestIntegration_DefaultRoom: bare root path broadcast works
- TestIntegration_ClientDisconnectFromRoom: remaining clients communicate
- TestIntegration_ConcurrentRoomOperations: no races with rapid connect/disconnect

All tests pass with -race flag.

🤖 Assisted by the code-assist SOP
2026-06-13 13:32:55 +02:00

277 lines
8.1 KiB
Go

package hub
import (
"fmt"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// TestIntegration_PathWithSlashes verifies that clients connected to a nested
// path (e.g., /a/b/c) can communicate within the same room.
func TestIntegration_PathWithSlashes(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect two clients to a nested path
conn1 := dialWSPath(t, server, "/a/b/c")
defer conn1.Close()
conn2 := dialWSPath(t, server, "/a/b/c")
defer conn2.Close()
waitForClients(t, h, 2, time.Second)
// Verify they are in the same room
testMsg := "nested path message"
if err := conn1.WriteMessage(websocket.TextMessage, []byte(testMsg)); err != nil {
t.Fatalf("Failed to send message: %v", err)
}
// Both clients should receive it
for i, conn := range []*websocket.Conn{conn1, conn2} {
conn.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Client %d failed to read message: %v", i+1, err)
}
if string(msg) != testMsg {
t.Errorf("Client %d expected %q, got %q", i+1, testMsg, string(msg))
}
}
// Verify a different nested path is a separate room
conn3 := dialWSPath(t, server, "/a/b/d")
defer conn3.Close()
waitForClients(t, h, 3, time.Second)
// Send from /a/b/d
otherMsg := "different path message"
if err := conn3.WriteMessage(websocket.TextMessage, []byte(otherMsg)); err != nil {
t.Fatalf("Failed to send message from /a/b/d: %v", err)
}
// conn3 should get its own message
conn3.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn3.ReadMessage()
if err != nil {
t.Fatalf("Client 3 failed to read own message: %v", err)
}
if string(msg) != otherMsg {
t.Errorf("Client 3 expected %q, got %q", otherMsg, string(msg))
}
// conn1 and conn2 should NOT receive it
for i, conn := range []*websocket.Conn{conn1, conn2} {
conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
_, _, err := conn.ReadMessage()
if err == nil {
t.Errorf("Client %d in /a/b/c should NOT have received message from /a/b/d", i+1)
}
}
}
// TestIntegration_QueryStringIgnored verifies that clients connecting to the
// same path with different query strings are placed in the same room.
func TestIntegration_QueryStringIgnored(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect client 1 to /room?token=abc
conn1 := dialWSPath(t, server, "/room?token=abc")
defer conn1.Close()
// Connect client 2 to /room?token=xyz
conn2 := dialWSPath(t, server, "/room?token=xyz")
defer conn2.Close()
waitForClients(t, h, 2, time.Second)
// They should be in the same room (/room) since query strings are stripped
testMsg := "query string test"
if err := conn1.WriteMessage(websocket.TextMessage, []byte(testMsg)); err != nil {
t.Fatalf("Failed to send message: %v", err)
}
// Both clients should receive the message
for i, conn := range []*websocket.Conn{conn1, conn2} {
conn.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Client %d failed to read message: %v", i+1, err)
}
if string(msg) != testMsg {
t.Errorf("Client %d expected %q, got %q", i+1, testMsg, string(msg))
}
}
// Verify they are actually in the same room (only 1 room exists)
if count := h.RoomCount(); count != 1 {
t.Errorf("Expected 1 room (query strings ignored), got %d", count)
}
}
// TestIntegration_DefaultRoom verifies that clients connecting to the bare
// root path (/) are placed in the default room and can communicate.
func TestIntegration_DefaultRoom(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect clients to "/" (bare root)
conn1 := dialWSPath(t, server, "/")
defer conn1.Close()
conn2 := dialWSPath(t, server, "/")
defer conn2.Close()
waitForClients(t, h, 2, time.Second)
// Verify broadcast works
testMsg := "default room message"
if err := conn1.WriteMessage(websocket.TextMessage, []byte(testMsg)); err != nil {
t.Fatalf("Failed to send message: %v", err)
}
for i, conn := range []*websocket.Conn{conn1, conn2} {
conn.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Client %d failed to read message: %v", i+1, err)
}
if string(msg) != testMsg {
t.Errorf("Client %d expected %q, got %q", i+1, testMsg, string(msg))
}
}
// Verify they are in the same room
if count := h.RoomCount(); count != 1 {
t.Errorf("Expected 1 room for default root path, got %d", count)
}
}
// TestIntegration_ClientDisconnectFromRoom verifies that when one client
// disconnects from a room, the remaining clients can still communicate.
func TestIntegration_ClientDisconnectFromRoom(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect 3 clients to the same room
conn1 := dialWSPath(t, server, "/game")
defer conn1.Close()
conn2 := dialWSPath(t, server, "/game")
defer conn2.Close()
conn3 := dialWSPath(t, server, "/game")
defer conn3.Close()
waitForClients(t, h, 3, time.Second)
// Disconnect client 1
conn1.Close()
waitForClients(t, h, 2, time.Second)
// Remaining 2 clients should still communicate
testMsg := "still alive"
if err := conn2.WriteMessage(websocket.TextMessage, []byte(testMsg)); err != nil {
t.Fatalf("Failed to send message from client 2: %v", err)
}
// conn2 and conn3 should receive the message
for i, conn := range []*websocket.Conn{conn2, conn3} {
conn.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Client %d failed to read message after disconnect: %v", i+2, err)
}
if string(msg) != testMsg {
t.Errorf("Client %d expected %q, got %q", i+2, testMsg, string(msg))
}
}
// Verify the room still exists
if count := h.RoomCount(); count != 1 {
t.Errorf("Expected room to still exist with 2 clients, got %d rooms", count)
}
// Send from client 3 to confirm bidirectional communication
replyMsg := "reply from client 3"
if err := conn3.WriteMessage(websocket.TextMessage, []byte(replyMsg)); err != nil {
t.Fatalf("Failed to send reply from client 3: %v", err)
}
for i, conn := range []*websocket.Conn{conn2, conn3} {
conn.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Client %d failed to read reply: %v", i+2, err)
}
if string(msg) != replyMsg {
t.Errorf("Client %d expected %q, got %q", i+2, replyMsg, string(msg))
}
}
}
// TestIntegration_ConcurrentRoomOperations verifies that rapidly connecting and
// disconnecting clients across multiple rooms concurrently causes no data races.
func TestIntegration_ConcurrentRoomOperations(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
const numRooms = 10
const clientsPerRoom = 5
var wg sync.WaitGroup
// Rapidly connect and disconnect clients across many rooms concurrently
for i := 0; i < numRooms; i++ {
wg.Add(1)
go func(roomIdx int) {
defer wg.Done()
room := fmt.Sprintf("/concurrent-room-%d", roomIdx)
conns := make([]*websocket.Conn, 0, clientsPerRoom)
// Connect all clients in this room
for j := 0; j < clientsPerRoom; j++ {
conn := dialWSPath(t, server, room)
conns = append(conns, conn)
}
// Small delay to allow registration to process
time.Sleep(50 * time.Millisecond)
// Send a message from the first client
if len(conns) > 0 {
msg := fmt.Sprintf("msg from room %d", roomIdx)
conns[0].WriteMessage(websocket.TextMessage, []byte(msg))
}
// Small delay then disconnect all
time.Sleep(50 * time.Millisecond)
for _, conn := range conns {
conn.Close()
}
}(i)
}
wg.Wait()
// Wait for all cleanups to complete
waitForClients(t, h, 0, 5*time.Second)
waitForRooms(t, h, 0, 5*time.Second)
// Verify clean state: no rooms, no clients
if count := h.ClientCount(); count != 0 {
t.Errorf("Expected 0 clients after concurrent operations, got %d", count)
}
if count := h.RoomCount(); count != 0 {
t.Errorf("Expected 0 rooms after concurrent operations, got %d", count)
}
}