// ABOUTME: Integration tests for Server API // ABOUTME: Tests server creation, startup, client connections, and streaming package sendspin import ( "encoding/json" "fmt" "net" "testing" "time" "github.com/Sendspin/sendspin-go/pkg/protocol" "github.com/gorilla/websocket" ) // waitForServerPort polls until server.Addr() returns the bound TCP port // after Start runs with Port: 0. Fails the test if the listener isn't // bound within 2s. func waitForServerPort(t *testing.T, server *Server) int { t.Helper() deadline := time.Now().Add(2 * time.Second) for server.Addr() == nil { if time.Now().After(deadline) { t.Fatal("server did not bind within 2s") } time.Sleep(10 * time.Millisecond) } return server.Addr().(*net.TCPAddr).Port } func TestNewServer(t *testing.T) { source := NewTestTone(48000, 2) tests := []struct { name string config ServerConfig expectErr bool }{ { name: "valid config", config: ServerConfig{ Port: 0, Name: "Test Server", Source: source, }, expectErr: false, }, { name: "missing source", config: ServerConfig{ Port: 0, Name: "Test Server", }, expectErr: true, }, { name: "ephemeral port (omitted)", config: ServerConfig{ Name: "Test Server", Source: source, }, expectErr: false, }, { name: "default name", config: ServerConfig{ Port: 0, Source: source, }, expectErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server, err := NewServer(tt.config) if tt.expectErr { if err == nil { t.Error("expected error, got nil") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if server == nil { t.Fatal("expected server to be created") } // NewServer no longer substitutes a default Port — Port: 0 is // honored as "let the OS pick" so tests bind ephemerally. // Name still defaults when omitted. if server.config.Name == "" { t.Error("name should have been set to default") } }) } } func TestServerStartStop(t *testing.T) { source := NewTestTone(48000, 2) server, err := NewServer(ServerConfig{ Port: 0, Name: "Test Server", Source: source, }) if err != nil { t.Fatalf("failed to create server: %v", err) } // Start server in goroutine errChan := make(chan error, 1) go func() { errChan <- server.Start() }() // Wait for listener to bind, then give the rest of Start a moment. waitForServerPort(t, server) time.Sleep(100 * time.Millisecond) // Stop server server.Stop() // Wait for server to stop select { case err := <-errChan: if err != nil { t.Errorf("server error: %v", err) } case <-time.After(5 * time.Second): t.Error("server did not stop within timeout") } } func TestServerClientConnection(t *testing.T) { source := NewTestTone(48000, 2) server, err := NewServer(ServerConfig{ Port: 0, Name: "Test Server", Source: source, Debug: true, }) if err != nil { t.Fatalf("failed to create server: %v", err) } // Start server errChan := make(chan error, 1) go func() { errChan <- server.Start() }() // Wait for listener to bind, then give the rest of Start a moment. port := waitForServerPort(t, server) time.Sleep(200 * time.Millisecond) // Connect as client wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port) conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("failed to connect to server: %v", err) } defer conn.Close() // Send client/hello with versioned roles per spec hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: "test-client-1", Name: "Test Client", Version: 1, SupportedRoles: []string{"player@v1", "metadata@v1"}, PlayerV1Support: &protocol.PlayerV1Support{ SupportedFormats: []protocol.AudioFormat{ { Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 24, }, }, BufferCapacity: 1048576, SupportedCommands: []string{"volume", "mute"}, }, }, } if err := conn.WriteJSON(hello); err != nil { t.Fatalf("failed to send hello: %v", err) } // Read server/hello response var msg protocol.Message if err := conn.ReadJSON(&msg); err != nil { t.Fatalf("failed to read server hello: %v", err) } if msg.Type != "server/hello" { t.Errorf("expected server/hello, got %s", msg.Type) } // Parse server hello helloData, _ := json.Marshal(msg.Payload) var serverHello protocol.ServerHello if err := json.Unmarshal(helloData, &serverHello); err != nil { t.Fatalf("failed to unmarshal server hello: %v", err) } if serverHello.Name != "Test Server" { t.Errorf("expected server name 'Test Server', got %s", serverHello.Name) } // Verify active_roles is present per spec if len(serverHello.ActiveRoles) == 0 { t.Error("expected active_roles to be set") } // Verify connection_reason is present per spec if serverHello.ConnectionReason == "" { t.Error("expected connection_reason to be set") } // Read the three control messages in any order. After the role // dispatcher migration (M5), group/update comes from Group.addClient // (synchronous) while stream/start and server/state come from role // handlers dispatched asynchronously — so the order is not guaranteed. // Audio binary chunks may also arrive interleaved with the control // messages since the streaming ticker runs independently. expectedMsgs := map[string]bool{ "group/update": false, "stream/start": false, "server/state": false, } var firstAudioChunk []byte controlCount := 0 for controlCount < 3 { msgType, rawData, err := conn.ReadMessage() if err != nil { t.Fatalf("failed to read message (have %d of 3 control msgs): %v", controlCount, err) } if msgType == websocket.BinaryMessage { // Audio chunk arrived before all control messages; save the // first one so we can validate it below. if firstAudioChunk == nil { firstAudioChunk = rawData } continue } if err := json.Unmarshal(rawData, &msg); err != nil { t.Fatalf("failed to unmarshal control message: %v", err) } if _, ok := expectedMsgs[msg.Type]; !ok { t.Fatalf("unexpected message type: %s", msg.Type) } expectedMsgs[msg.Type] = true controlCount++ } for msgType, received := range expectedMsgs { if !received { t.Errorf("never received expected %s message", msgType) } } // Read audio chunk — either we already captured one above or read next. var data []byte if firstAudioChunk != nil { data = firstAudioChunk } else { var readMsgType int readMsgType, data, err = conn.ReadMessage() if err != nil { t.Fatalf("failed to read audio chunk: %v", err) } if readMsgType != websocket.BinaryMessage { t.Errorf("expected binary message, got type %d", readMsgType) } } // Verify chunk format: [type:1][timestamp:8][audio_data:N] if len(data) < 9 { t.Errorf("audio chunk too small: %d bytes", len(data)) } // Per spec: audio chunks use message type 4 (player role, slot 0) if data[0] != AudioChunkMessageType { t.Errorf("expected message type %d, got %d", AudioChunkMessageType, data[0]) } // Check that clients list includes our client clients := server.Clients() if len(clients) != 1 { t.Errorf("expected 1 client, got %d", len(clients)) } if clients[0].ID != "test-client-1" { t.Errorf("expected client ID 'test-client-1', got %s", clients[0].ID) } // Close connection conn.Close() // Give server time to handle disconnect time.Sleep(100 * time.Millisecond) // Verify client was removed clients = server.Clients() if len(clients) != 0 { t.Errorf("expected 0 clients after disconnect, got %d", len(clients)) } // Stop server server.Stop() select { case <-errChan: case <-time.After(5 * time.Second): t.Error("server did not stop within timeout") } } func TestServerMultipleClients(t *testing.T) { source := NewTestTone(48000, 2) server, err := NewServer(ServerConfig{ Port: 0, Name: "Test Server", Source: source, }) if err != nil { t.Fatalf("failed to create server: %v", err) } // Start server go server.Start() port := waitForServerPort(t, server) time.Sleep(200 * time.Millisecond) // Connect multiple clients clients := make([]*websocket.Conn, 3) wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port) for i := 0; i < 3; i++ { conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("failed to connect client %d: %v", i, err) } clients[i] = conn // Send hello with versioned roles hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: fmt.Sprintf("test-client-%d", i), Name: fmt.Sprintf("Test Client %d", i), Version: 1, SupportedRoles: []string{"player@v1"}, PlayerV1Support: &protocol.PlayerV1Support{ SupportedFormats: []protocol.AudioFormat{ { Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 24, }, }, BufferCapacity: 1048576, SupportedCommands: []string{"volume", "mute"}, }, }, } if err := conn.WriteJSON(hello); err != nil { t.Fatalf("failed to send hello from client %d: %v", i, err) } // Read server/hello var msg protocol.Message if err := conn.ReadJSON(&msg); err != nil { t.Fatalf("failed to read server hello for client %d: %v", i, err) } } // Give server time to register all clients time.Sleep(100 * time.Millisecond) // Check that all clients are registered serverClients := server.Clients() if len(serverClients) != 3 { t.Errorf("expected 3 clients, got %d", len(serverClients)) } // Close all connections for i, conn := range clients { if err := conn.Close(); err != nil { t.Errorf("failed to close client %d: %v", i, err) } } // Give server time to handle disconnects time.Sleep(100 * time.Millisecond) // Verify all clients were removed serverClients = server.Clients() if len(serverClients) != 0 { t.Errorf("expected 0 clients after disconnect, got %d", len(serverClients)) } // Stop server server.Stop() time.Sleep(100 * time.Millisecond) } // TestServer_GroupReceivesEventsFromRealHandshake is the real wiring // guard for M2: it stands up an actual Server, subscribes to the // default group BEFORE the server starts accepting connections, then // drives a full WebSocket handshake via the gorilla client and asserts // that ClientJoinedEvent and ClientLeftEvent are delivered through the // event bus. A future refactor that moved defaultGroup.addClient out of // handleConnection (or routed it through a different Group) would fail // this test, which is exactly the guarantee the helper-level test cannot // provide. func TestServer_GroupReceivesEventsFromRealHandshake(t *testing.T) { server, err := NewServer(ServerConfig{ Port: 0, Name: "Group Wiring Test", Source: NewTestTone(48000, 2), }) if err != nil { t.Fatalf("NewServer: %v", err) } // Subscribe BEFORE Start so the join event is guaranteed to land. events, unsubscribe := server.Group().Subscribe() defer unsubscribe() errChan := make(chan error, 1) go func() { errChan <- server.Start() }() defer func() { server.Stop() select { case <-errChan: case <-time.After(5 * time.Second): t.Error("server did not stop within timeout") } }() port := waitForServerPort(t, server) time.Sleep(200 * time.Millisecond) conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) if err != nil { t.Fatalf("dial: %v", err) } hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: "wiring-test-client", Name: "Wiring Test Client", Version: 1, SupportedRoles: []string{"player@v1"}, PlayerV1Support: &protocol.PlayerV1Support{ SupportedFormats: []protocol.AudioFormat{ {Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 24}, }, BufferCapacity: 1048576, }, }, } if err := conn.WriteJSON(hello); err != nil { conn.Close() t.Fatalf("write hello: %v", err) } // Wait for the ClientJoinedEvent delivered through the real wiring. select { case evt := <-events: joined, ok := evt.(ClientJoinedEvent) if !ok { conn.Close() t.Fatalf("first event = %T, want ClientJoinedEvent", evt) } if joined.Client.ID() != "wiring-test-client" { t.Errorf("joined Client.ID() = %q, want %q", joined.Client.ID(), "wiring-test-client") } case <-time.After(2 * time.Second): conn.Close() t.Fatal("timed out waiting for ClientJoinedEvent from real handshake") } // Disconnect and assert the matching ClientLeftEvent fires. conn.Close() select { case evt := <-events: left, ok := evt.(ClientLeftEvent) if !ok { t.Fatalf("second event = %T, want ClientLeftEvent", evt) } if left.ClientID != "wiring-test-client" { t.Errorf("left ClientID = %q, want %q", left.ClientID, "wiring-test-client") } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for ClientLeftEvent after disconnect") } } // TestServer_ControllerCommandEndToEnd exercises the full client/command // pipeline: Server + ControllerGroupRole + real WebSocket handshake + // client/command message → OnCommand callback fires. func TestServer_ControllerCommandEndToEnd(t *testing.T) { commandReceived := make(chan string, 1) server, err := NewServer(ServerConfig{ Port: 0, Name: "Controller Test", Source: NewTestTone(48000, 2), }) if err != nil { t.Fatalf("NewServer: %v", err) } ctrl := NewControllerRole(ControllerConfig{ SupportedCommands: []string{"next", "previous"}, OnCommand: func(c *ServerClient, command string) { commandReceived <- command }, }) server.Group().RegisterRole(ctrl) errChan := make(chan error, 1) go func() { errChan <- server.Start() }() defer func() { server.Stop() select { case <-errChan: case <-time.After(5 * time.Second): t.Error("server stop timeout") } }() port := waitForServerPort(t, server) time.Sleep(200 * time.Millisecond) conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: "ctrl-test-client", Name: "Controller Test Client", Version: 1, SupportedRoles: []string{"controller@v1"}, }, } if err := conn.WriteJSON(hello); err != nil { t.Fatalf("write hello: %v", err) } // Drain the server/hello response. var msg protocol.Message if err := conn.ReadJSON(&msg); err != nil { t.Fatalf("read server/hello: %v", err) } // Give the join event time to dispatch to the controller role. time.Sleep(100 * time.Millisecond) // Send a controller command. cmd := protocol.Message{ Type: "client/command", Payload: map[string]interface{}{ "controller": map[string]interface{}{ "command": "next", }, }, } if err := conn.WriteJSON(cmd); err != nil { t.Fatalf("write client/command: %v", err) } select { case got := <-commandReceived: if got != "next" { t.Errorf("command = %q, want %q", got, "next") } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for OnCommand callback") } } // TestServer_ActivateRolesDefaultsToPlayerMetadata confirms backward // compat: when ServerConfig.SupportedRoles is nil, activateRoles still // accepts "player" and "metadata" and rejects unknown families. func TestServer_ActivateRolesDefault(t *testing.T) { s, err := NewServer(ServerConfig{ Port: 0, Name: "test", Source: NewTestTone(48000, 2), }) if err != nil { t.Fatalf("NewServer: %v", err) } got := s.activateRoles([]string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"}) want := map[string]bool{"player@v1": true, "metadata@v1": true} gotSet := make(map[string]bool) for _, r := range got { gotSet[r] = true } if len(gotSet) != len(want) { t.Errorf("activateRoles default = %v, want player+metadata only", got) } for r := range want { if !gotSet[r] { t.Errorf("missing expected role %s in %v", r, got) } } } // TestServer_ActivateRolesFromConfig confirms that explicitly listing // roles in ServerConfig.SupportedRoles controls what gets activated. // Note: NewServer always registers player and metadata GroupRoles on // the default group, so those families are always active via the group // registry regardless of SupportedRoles. This test verifies that // SupportedRoles adds additional families (artwork) beyond those. func TestServer_ActivateRolesFromConfig(t *testing.T) { s, err := NewServer(ServerConfig{ Port: 0, Name: "test", Source: NewTestTone(48000, 2), SupportedRoles: []string{"player", "artwork"}, }) if err != nil { t.Fatalf("NewServer: %v", err) } got := s.activateRoles([]string{"player@v1", "metadata@v1", "artwork@v1"}) gotSet := make(map[string]bool) for _, r := range got { gotSet[r] = true } // metadata is active because NewServer registers MetadataGroupRole // on the default group, which auto-activates it. if !gotSet["metadata@v1"] { t.Error("metadata should be active (registered via GroupRole in NewServer)") } if !gotSet["player@v1"] || !gotSet["artwork@v1"] { t.Errorf("expected player+artwork, got %v", got) } } // TestServer_ActivateRolesFromGroupRegistry confirms that registering a // GroupRole on the server's Group auto-activates that role family even // when it's not in ServerConfig.SupportedRoles. func TestServer_ActivateRolesFromGroupRegistry(t *testing.T) { s, err := NewServer(ServerConfig{ Port: 0, Name: "test", Source: NewTestTone(48000, 2), }) if err != nil { t.Fatalf("NewServer: %v", err) } // Register a controller role — should auto-activate "controller". ctrl := NewControllerRole(ControllerConfig{ SupportedCommands: []string{"next"}, }) s.Group().RegisterRole(ctrl) got := s.activateRoles([]string{"player@v1", "controller@v1"}) gotSet := make(map[string]bool) for _, r := range got { gotSet[r] = true } if !gotSet["player@v1"] { t.Error("player should be active (default)") } if !gotSet["controller@v1"] { t.Error("controller should be active (registered via GroupRole)") } } func TestServerDuplicateClientID(t *testing.T) { source := NewTestTone(48000, 2) server, err := NewServer(ServerConfig{ Port: 0, Name: "Test Server", Source: source, }) if err != nil { t.Fatalf("failed to create server: %v", err) } // Start server go server.Start() port := waitForServerPort(t, server) time.Sleep(200 * time.Millisecond) // Connect first client wsURL := fmt.Sprintf("ws://localhost:%d/sendspin", port) conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("failed to connect first client: %v", err) } defer conn1.Close() hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: "duplicate-id", Name: "First Client", Version: 1, SupportedRoles: []string{"player@v1"}, }, } if err := conn1.WriteJSON(hello); err != nil { t.Fatalf("failed to send hello: %v", err) } // Read server/hello var msg protocol.Message if err := conn1.ReadJSON(&msg); err != nil { t.Fatalf("failed to read server hello: %v", err) } // Try to connect second client with same ID conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { t.Fatalf("failed to connect second client: %v", err) } defer conn2.Close() if err := conn2.WriteJSON(hello); err != nil { t.Fatalf("failed to send hello from second client: %v", err) } // Second client should be rejected - connection should close // Try to read a message - should get an error conn2.SetReadDeadline(time.Now().Add(1 * time.Second)) err = conn2.ReadJSON(&msg) if err == nil { // If we got a message, it should be an error message if msg.Type == "server/error" { // This is expected } else { t.Errorf("expected error or connection close for duplicate ID, got message type: %s", msg.Type) } } // Verify only one client is registered serverClients := server.Clients() if len(serverClients) != 1 { t.Errorf("expected 1 client, got %d", len(serverClients)) } // Stop server server.Stop() time.Sleep(100 * time.Millisecond) } // TestServer_FLACStreamNegotiation verifies that a client advertising // FLAC as its preferred codec receives stream/start with codec="flac" // and a valid codec_header, followed by binary FLAC audio chunks. func TestServer_FLACStreamNegotiation(t *testing.T) { s, err := NewServer(ServerConfig{ Port: 0, Name: "FLAC Test Server", Source: NewTestTone(48000, 2), }) if err != nil { t.Fatalf("NewServer: %v", err) } errChan := make(chan error, 1) go func() { errChan <- s.Start() }() defer func() { s.Stop() select { case <-errChan: case <-time.After(5 * time.Second): t.Error("server stop timeout") } }() port := waitForServerPort(t, s) time.Sleep(200 * time.Millisecond) conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() // Advertise FLAC as first format (simulates --preferred-codec flac) hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: "flac-test-client", Name: "FLAC Test Client", Version: 1, SupportedRoles: []string{"player@v1", "metadata@v1"}, PlayerV1Support: &protocol.PlayerV1Support{ SupportedFormats: []protocol.AudioFormat{ {Codec: "flac", SampleRate: 48000, Channels: 2, BitDepth: 24}, {Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24}, }, BufferCapacity: 1048576, }, }, } if err := conn.WriteJSON(hello); err != nil { t.Fatalf("write hello: %v", err) } // Read messages looking for stream/start with FLAC codec. // The order of messages after server/hello is non-deterministic // (group/update, stream/start, server/state may interleave). var foundStreamStart bool var codecHeader string deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { conn.SetReadDeadline(deadline) msgType, data, err := conn.ReadMessage() if err != nil { t.Fatalf("read: %v", err) } if msgType == websocket.BinaryMessage { // Got a binary audio chunk before finding stream/start if !foundStreamStart { t.Fatal("received binary chunk before stream/start") } // Verify it's a valid audio chunk (at least header + some data) if len(data) < 10 { t.Errorf("audio chunk too small: %d bytes", len(data)) } t.Logf("Received FLAC audio chunk: %d bytes", len(data)) break // Success — we got stream/start + at least one chunk } // Text message — parse the envelope var msg protocol.Message if err := json.Unmarshal(data, &msg); err != nil { t.Fatalf("unmarshal: %v", err) } if msg.Type == "stream/start" { startData, _ := json.Marshal(msg.Payload) var streamStart protocol.StreamStart if err := json.Unmarshal(startData, &streamStart); err != nil { t.Fatalf("unmarshal stream/start: %v", err) } if streamStart.Player == nil { t.Fatal("stream/start has no player info") } if streamStart.Player.Codec != "flac" { t.Errorf("codec = %q, want flac", streamStart.Player.Codec) } if streamStart.Player.CodecHeader == "" { t.Error("codec_header is empty — FLAC requires STREAMINFO") } if streamStart.Player.SampleRate != 48000 { t.Errorf("sample_rate = %d, want 48000", streamStart.Player.SampleRate) } if streamStart.Player.BitDepth != 24 { t.Errorf("bit_depth = %d, want 24", streamStart.Player.BitDepth) } codecHeader = streamStart.Player.CodecHeader foundStreamStart = true t.Logf("stream/start received: codec=flac, codec_header=%d chars (base64)", len(codecHeader)) } } if !foundStreamStart { t.Fatal("never received stream/start with FLAC") } } // TestServer_BufferTrackerLimitsChunks verifies that the server's // BufferTracker prevents buffer overflow. A client advertising a tiny // buffer_capacity should receive fewer chunks than one with a large // capacity, because the server skips sends when the tracker is full. func TestServer_BufferTrackerLimitsChunks(t *testing.T) { s, err := NewServer(ServerConfig{ Port: 0, Name: "Buffer Test", Source: NewTestTone(48000, 2), Debug: true, }) if err != nil { t.Fatalf("NewServer: %v", err) } errChan := make(chan error, 1) go func() { errChan <- s.Start() }() defer func() { s.Stop() select { case <-errChan: case <-time.After(5 * time.Second): t.Error("server stop timeout") } }() port := waitForServerPort(t, s) time.Sleep(200 * time.Millisecond) // Connect with a small buffer capacity (20000 bytes). // At 48kHz stereo 24-bit PCM, one 20ms chunk is ~5760 bytes of audio // plus the 9-byte chunk header (~5769 total). So 20000 bytes fits ~3 // chunks at a time. As playback time advances, PruneConsumed frees // slots, but the tracker still throttles the send rate well below 50 // chunks/second. conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://localhost:%d/sendspin", port), nil) if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() hello := protocol.Message{ Type: "client/hello", Payload: protocol.ClientHello{ ClientID: "buffer-test-client", Name: "Buffer Test Client", Version: 1, SupportedRoles: []string{"player@v1", "metadata@v1"}, PlayerV1Support: &protocol.PlayerV1Support{ SupportedFormats: []protocol.AudioFormat{ {Codec: "pcm", SampleRate: 48000, Channels: 2, BitDepth: 24}, }, BufferCapacity: 20000, // Small — fits ~3 PCM chunks at a time }, }, } if err := conn.WriteJSON(hello); err != nil { t.Fatalf("write hello: %v", err) } // Read messages for 1 second and count binary chunks received. binaryCount := 0 deadline := time.Now().Add(1 * time.Second) for time.Now().Before(deadline) { conn.SetReadDeadline(deadline) msgType, _, err := conn.ReadMessage() if err != nil { break } if msgType == websocket.BinaryMessage { binaryCount++ } } // At 50 chunks/second for 1 second, an unlimited client would get ~50 chunks. // With a 5000-byte capacity, the tracker should limit this significantly. // The exact number depends on timing (PruneConsumed frees slots as // playback time passes), but it should be well under 50. t.Logf("Received %d binary chunks in 1s (unlimited would be ~50)", binaryCount) // We just verify the tracker is working — some chunks should arrive // (the first one always fits), but far fewer than 50. if binaryCount >= 50 { t.Errorf("received %d chunks — buffer tracker doesn't seem to be limiting", binaryCount) } if binaryCount == 0 { t.Error("received 0 chunks — at least the first should have been sent") } }