test(hub): add room-scoped broadcast isolation tests (P03)

Add tests verifying that the broadcast case in Hub.Run() correctly
sends messages only to clients in the same room as the sender:

- TestIntegration_RoomIsolation_MessagesOnlyGoToSameRoom: verifies
  messages from room-a are received by room-a clients and NOT by
  room-b clients
- TestIntegration_RoomIsolation_MultipleRoomsIndependent: verifies
  two rooms operate independently with no message leakage
- TestIntegration_BroadcastToEmptyRoom: verifies graceful handling
  when broadcasting to a non-existent room (no panic, hub remains
  functional)
- TestBroadcastRoomIsolation: unit-level room isolation test using
  the broadcast channel directly

Also adds dialWSWithRoom helper for room-aware WebSocket connections
in integration tests.

🤖 Assisted by the code-assist SOP
This commit is contained in:
savinmax 2026-06-13 13:20:05 +02:00
parent 8eaba398dc
commit 48d47dfc92
2 changed files with 181 additions and 1 deletions

View File

@ -24,7 +24,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *Hub) {
return server, h
}
// helper: dial a WebSocket connection to the test server
// helper: dial a WebSocket connection to the test server (default room)
func dialWS(t *testing.T, server *httptest.Server) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
@ -35,6 +35,17 @@ func dialWS(t *testing.T, server *httptest.Server) *websocket.Conn {
return conn
}
// helper: dial a WebSocket connection to a specific room
func dialWSWithRoom(t *testing.T, server *httptest.Server, room string) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "?room=" + room
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to dial WebSocket (room=%q): %v", room, err)
}
return conn
}
// helper: wait until hub reaches expected client count or timeout
func waitForClients(t *testing.T, h *Hub, expected int, timeout time.Duration) {
t.Helper()
@ -356,3 +367,137 @@ func TestIntegration_LargeMessage(t *testing.T) {
t.Errorf("Expected message length %d, got %d", 64*1024, len(msg))
}
}
func TestIntegration_RoomIsolation_MessagesOnlyGoToSameRoom(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect 2 clients to room-a, 1 client to room-b
connA1 := dialWSWithRoom(t, server, "room-a")
defer connA1.Close()
connA2 := dialWSWithRoom(t, server, "room-a")
defer connA2.Close()
connB1 := dialWSWithRoom(t, server, "room-b")
defer connB1.Close()
waitForClients(t, h, 3, time.Second)
// Send a message from client A1
testMsg := "hello room-a"
if err := connA1.WriteMessage(websocket.TextMessage, []byte(testMsg)); err != nil {
t.Fatalf("Failed to send message: %v", err)
}
// Both room-a clients should receive the message
for i, conn := range []*websocket.Conn{connA1, connA2} {
conn.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Room-a client %d failed to read message: %v", i+1, err)
}
if string(msg) != testMsg {
t.Errorf("Room-a client %d expected %q, got %q", i+1, testMsg, string(msg))
}
}
// Room-b client should NOT receive the message
connB1.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
_, _, err := connB1.ReadMessage()
if err == nil {
t.Fatal("Room-b client should NOT have received a message from room-a")
}
// Timeout error is expected — message was correctly not delivered
}
func TestIntegration_RoomIsolation_MultipleRoomsIndependent(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect clients to two different rooms
connA := dialWSWithRoom(t, server, "alpha")
defer connA.Close()
connB := dialWSWithRoom(t, server, "beta")
defer connB.Close()
waitForClients(t, h, 2, time.Second)
// Send message from room alpha
msgAlpha := "alpha message"
if err := connA.WriteMessage(websocket.TextMessage, []byte(msgAlpha)); err != nil {
t.Fatalf("Failed to send alpha message: %v", err)
}
// Room alpha client receives its own message
connA.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := connA.ReadMessage()
if err != nil {
t.Fatalf("Alpha client failed to read: %v", err)
}
if string(msg) != msgAlpha {
t.Errorf("Alpha client expected %q, got %q", msgAlpha, string(msg))
}
// Send message from room beta
msgBeta := "beta message"
if err := connB.WriteMessage(websocket.TextMessage, []byte(msgBeta)); err != nil {
t.Fatalf("Failed to send beta message: %v", err)
}
// Room beta client receives its own message
connB.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err = connB.ReadMessage()
if err != nil {
t.Fatalf("Beta client failed to read: %v", err)
}
if string(msg) != msgBeta {
t.Errorf("Beta client expected %q, got %q", msgBeta, string(msg))
}
// Verify alpha did NOT receive beta's message
connA.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
_, _, err = connA.ReadMessage()
if err == nil {
t.Fatal("Alpha client should NOT have received beta's message")
}
// Verify beta did NOT receive alpha's message
connB.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
_, _, err = connB.ReadMessage()
if err == nil {
t.Fatal("Beta client should NOT have received alpha's message")
}
}
func TestIntegration_BroadcastToEmptyRoom(t *testing.T) {
server, h := setupTestServer(t)
defer server.Close()
defer h.Shutdown()
// Connect a client to room-a and a separate client to verify hub is functional
connA := dialWSWithRoom(t, server, "room-a")
defer connA.Close()
waitForClients(t, h, 1, time.Second)
// Send directly to broadcast channel targeting a non-existent room.
// This should be handled gracefully (no panic, no delivery).
h.broadcast <- broadcastMsg{room: "non-existent", data: []byte("ghost message")}
// Give the hub time to process
time.Sleep(50 * time.Millisecond)
// Now send a real message to room-a to confirm hub is still functional
h.broadcast <- broadcastMsg{room: "room-a", data: []byte("real message")}
connA.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := connA.ReadMessage()
if err != nil {
t.Fatalf("Room-a client failed to read real message after empty-room broadcast: %v", err)
}
if string(msg) != "real message" {
t.Errorf("Expected %q, got %q", "real message", string(msg))
}
}

View File

@ -245,3 +245,38 @@ func TestUnregisterUnknownConnNoPanic(t *testing.T) {
t.Errorf("Expected 0 clients, got %d", count)
}
}
func TestBroadcastRoomIsolation(t *testing.T) {
h := New(newTestLogger())
go h.Run()
defer h.Shutdown()
connA := dialTestHub(t, h, "room-a")
defer connA.Close()
connB := dialTestHub(t, h, "room-b")
defer connB.Close()
// Allow registers to be processed
time.Sleep(50 * time.Millisecond)
// Send message to room-a via broadcast channel
h.broadcast <- broadcastMsg{room: "room-a", data: []byte("for-a-only")}
// Room-a client should receive the message
connA.SetReadDeadline(time.Now().Add(time.Second))
_, msg, err := connA.ReadMessage()
if err != nil {
t.Fatalf("Room-a client failed to read: %v", err)
}
if string(msg) != "for-a-only" {
t.Errorf("Expected %q, got %q", "for-a-only", string(msg))
}
// Room-b client should NOT receive the message (short timeout)
connB.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
_, _, err = connB.ReadMessage()
if err == nil {
t.Fatal("Room-b client should NOT have received room-a's message")
}
}