228 lines
6.8 KiB
Go
228 lines
6.8 KiB
Go
// ABOUTME: Tests for the ServerClient exported accessor surface
|
|
// ABOUTME: Guards the shape the Group/GroupRole layer will build on in M2+
|
|
package sendspin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Sendspin/sendspin-go/pkg/protocol"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// TestServerClient_Accessors confirms that the six exported accessors on
|
|
// ServerClient return the values the struct was constructed with. These are
|
|
// the methods the future Group/GroupRole layer will depend on, so their
|
|
// shape is load-bearing — adding a test now pins it.
|
|
func TestServerClient_Accessors(t *testing.T) {
|
|
sc := &ServerClient{
|
|
id: "client-abc",
|
|
name: "Living Room",
|
|
roles: []string{"player@v1", "metadata@v1"},
|
|
}
|
|
|
|
if got := sc.ID(); got != "client-abc" {
|
|
t.Errorf("ID() = %q, want %q", got, "client-abc")
|
|
}
|
|
if got := sc.Name(); got != "Living Room" {
|
|
t.Errorf("Name() = %q, want %q", got, "Living Room")
|
|
}
|
|
roles := sc.Roles()
|
|
if len(roles) != 2 || roles[0] != "player@v1" || roles[1] != "metadata@v1" {
|
|
t.Errorf("Roles() = %v, want [player@v1 metadata@v1]", roles)
|
|
}
|
|
}
|
|
|
|
// TestServerClient_RolesReturnsCopy guards the defensive-copy contract.
|
|
// Mutating the returned slice must not affect a subsequent Roles() call.
|
|
func TestServerClient_RolesReturnsCopy(t *testing.T) {
|
|
sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}}
|
|
|
|
first := sc.Roles()
|
|
first[0] = "tampered"
|
|
|
|
second := sc.Roles()
|
|
if second[0] != "player@v1" {
|
|
t.Errorf("Roles() returned aliased slice: got %q after mutation, want %q", second[0], "player@v1")
|
|
}
|
|
}
|
|
|
|
// TestServerClient_HasRole covers both exact matches and versioned matches
|
|
// (e.g., "player" should match "player@v1"). This mirrors the existing
|
|
// Server.hasRole behavior, which the accessor replaces.
|
|
func TestServerClient_HasRole(t *testing.T) {
|
|
sc := &ServerClient{roles: []string{"player@v1", "metadata@v1"}}
|
|
|
|
cases := []struct {
|
|
role string
|
|
want bool
|
|
}{
|
|
{"player", true},
|
|
{"player@v1", true},
|
|
{"metadata", true},
|
|
{"controller", false},
|
|
{"artwork", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := sc.HasRole(tc.role); got != tc.want {
|
|
t.Errorf("HasRole(%q) = %v, want %v", tc.role, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestServerClient_SendBufferFull guards the back-pressure behavior: Send
|
|
// must not block when the buffered sendChan is full. Returning an error
|
|
// lets the caller decide whether to drop, log, or disconnect.
|
|
func TestServerClient_SendBufferFull(t *testing.T) {
|
|
sc := &ServerClient{
|
|
sendChan: make(chan interface{}, 1),
|
|
}
|
|
|
|
if err := sc.Send("server/state", map[string]string{"a": "b"}); err != nil {
|
|
t.Fatalf("first Send should succeed, got %v", err)
|
|
}
|
|
if err := sc.Send("server/state", map[string]string{"c": "d"}); err == nil {
|
|
t.Error("second Send to full buffer should return error, got nil")
|
|
}
|
|
}
|
|
|
|
// TestServerClient_SendBinaryBufferFull is the same back-pressure check
|
|
// for the binary path (audio chunks go through here).
|
|
func TestServerClient_SendBinaryBufferFull(t *testing.T) {
|
|
sc := &ServerClient{
|
|
sendChan: make(chan interface{}, 1),
|
|
}
|
|
if err := sc.SendBinary([]byte{0x01}); err != nil {
|
|
t.Fatalf("first SendBinary should succeed, got %v", err)
|
|
}
|
|
if err := sc.SendBinary([]byte{0x02}); err == nil {
|
|
t.Error("second SendBinary to full buffer should return error, got nil")
|
|
}
|
|
}
|
|
|
|
// TestServerClient_StateAccessors confirms that State/Volume/Muted/Codec
|
|
// return the mutable playback fields under the client's RWMutex. These
|
|
// are the fields the M2 Group event bus carries in ClientStateChangedEvent,
|
|
// so their shape is load-bearing for M3's role handlers.
|
|
func TestServerClient_StateAccessors(t *testing.T) {
|
|
sc := &ServerClient{
|
|
state: "synchronized",
|
|
volume: 72,
|
|
muted: true,
|
|
codec: "opus",
|
|
}
|
|
|
|
if got := sc.State(); got != "synchronized" {
|
|
t.Errorf("State() = %q, want %q", got, "synchronized")
|
|
}
|
|
if got := sc.Volume(); got != 72 {
|
|
t.Errorf("Volume() = %d, want 72", got)
|
|
}
|
|
if got := sc.Muted(); got != true {
|
|
t.Errorf("Muted() = %v, want true", got)
|
|
}
|
|
if got := sc.Codec(); got != "opus" {
|
|
t.Errorf("Codec() = %q, want %q", got, "opus")
|
|
}
|
|
}
|
|
|
|
// TestServerClient_StateAccessorsConcurrent is a light race-detector bait.
|
|
// Running Write/Read in parallel under -race should flag any missing lock.
|
|
func TestServerClient_StateAccessorsConcurrent(t *testing.T) {
|
|
sc := &ServerClient{state: "synchronized", volume: 50}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
for i := 0; i < 1000; i++ {
|
|
sc.mu.Lock()
|
|
sc.volume = i % 100
|
|
sc.mu.Unlock()
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
for i := 0; i < 1000; i++ {
|
|
_ = sc.Volume()
|
|
_ = sc.State()
|
|
}
|
|
<-done
|
|
}
|
|
|
|
// TestNewServerClientFromConn_SendAndClose confirms the constructor +
|
|
// writer + Close lifecycle works: messages enqueued via Send arrive on
|
|
// the WebSocket, and Close stops the writer without panic.
|
|
func TestNewServerClientFromConn_SendAndClose(t *testing.T) {
|
|
// Use an in-process WebSocket pair via httptest.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
upgrader := websocket.Upgrader{}
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Read the one message we expect.
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
t.Errorf("server read: %v", err)
|
|
return
|
|
}
|
|
var msg protocol.Message
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
t.Errorf("unmarshal: %v", err)
|
|
return
|
|
}
|
|
if msg.Type != "server/hello" {
|
|
t.Errorf("got type %q, want server/hello", msg.Type)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
wsURL := "ws" + srv.URL[len("http"):]
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
sc := NewServerClientFromConn(conn, "test-id", "Test", []string{"player@v1"}, nil)
|
|
|
|
if err := sc.Send("server/hello", map[string]string{"name": "test"}); err != nil {
|
|
t.Fatalf("Send: %v", err)
|
|
}
|
|
|
|
// Give the writer time to flush.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
sc.Close()
|
|
// Double-close should not panic.
|
|
sc.Close()
|
|
}
|
|
|
|
// TestCreateAudioChunk confirms the exported helper produces the
|
|
// correct binary frame format.
|
|
func TestCreateAudioChunk(t *testing.T) {
|
|
chunk := CreateAudioChunk(1000000, []byte{0xAA, 0xBB})
|
|
if chunk[0] != AudioChunkMessageType {
|
|
t.Errorf("type byte = %d, want %d", chunk[0], AudioChunkMessageType)
|
|
}
|
|
if len(chunk) != 9+2 {
|
|
t.Errorf("len = %d, want 11", len(chunk))
|
|
}
|
|
}
|
|
|
|
// TestCreateArtworkChunk confirms channel mapping and frame format.
|
|
func TestCreateArtworkChunk(t *testing.T) {
|
|
chunk := CreateArtworkChunk(2, 5000000, []byte{0xFF})
|
|
expectedType := byte(protocol.ArtworkChannel0MessageType + 2)
|
|
if chunk[0] != expectedType {
|
|
t.Errorf("type byte = %d, want %d", chunk[0], expectedType)
|
|
}
|
|
if len(chunk) != protocol.BinaryMessageHeaderSize+1 {
|
|
t.Errorf("len = %d, want %d", len(chunk), protocol.BinaryMessageHeaderSize+1)
|
|
}
|
|
}
|