// ABOUTME: Tests for the MetadataGroupRole implementation // ABOUTME: Verifies metadata state push to joining clients and broadcast paths package sendspin import ( "sync" "testing" "time" "github.com/Sendspin/sendspin-go/pkg/protocol" ) // drainServerStateMessage reads one queued send from a fake client's // sendChan and returns the decoded protocol.Message + ServerStateMessage. // Fails the test if nothing is queued or if the queued payload is not a // server/state message. func drainServerStateMessage(t *testing.T, sc *ServerClient) (protocol.Message, protocol.ServerStateMessage) { t.Helper() select { case raw := <-sc.sendChan: msg, ok := raw.(protocol.Message) if !ok { t.Fatalf("queued send was %T, want protocol.Message", raw) } state, ok := msg.Payload.(protocol.ServerStateMessage) if !ok { t.Fatalf("Message.Payload was %T, want protocol.ServerStateMessage", msg.Payload) } return msg, state case <-time.After(50 * time.Millisecond): t.Fatal("expected a server/state send, none arrived") return protocol.Message{}, protocol.ServerStateMessage{} } } // expectNoSend fails the test if the client received any queued send // within the wait window. Used to assert non-metadata clients don't get // broadcasts. func expectNoSend(t *testing.T, sc *ServerClient, wait time.Duration, label string) { t.Helper() select { case raw := <-sc.sendChan: t.Fatalf("%s: unexpected send queued: %T %+v", label, raw, raw) case <-time.After(wait): // Expected: nothing. } } func TestMetadataRole_OnClientJoinSendsState(t *testing.T) { meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "Track Title", "Artist Name", "Album Name" }, ClockMicros: func() int64 { return 12345 }, }) sc := &ServerClient{ id: "c1", roles: []string{"metadata@v1"}, sendChan: make(chan interface{}, 10), } meta.OnClientJoin(sc) select { case msg := <-sc.sendChan: _ = msg default: t.Fatal("OnClientJoin did not send metadata state") } } func TestMetadataRole_OnClientJoinSkipsNonMetadataClient(t *testing.T) { meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "t", "a", "al" }, ClockMicros: func() int64 { return 0 }, }) sc := &ServerClient{ id: "c1", roles: []string{"player@v1"}, sendChan: make(chan interface{}, 10), } meta.OnClientJoin(sc) select { case <-sc.sendChan: t.Error("should not send metadata to non-metadata client") default: } } func TestMetadataRole_Role(t *testing.T) { meta := NewMetadataRole(MetadataConfig{}) if meta.Role() != "metadata" { t.Errorf("Role() = %q, want %q", meta.Role(), "metadata") } } // TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients // builds a Group with two attached clients (one with the metadata role, // one without), registers a MetadataGroupRole, and invokes // OnPlaybackStateChanged("stopped", "playing"). The metadata client must // receive a server/state with the expected payload; the non-metadata // client must not. func TestMetadataRole_OnPlaybackStateChanged_BroadcastsToMetadataClients(t *testing.T) { g := NewGroup("test-group") defer g.Close() meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "Track Title", "Artist Name", "Album Name" }, ClockMicros: func() int64 { return 42 }, }) g.RegisterRole(meta) metaClient := &ServerClient{ id: "meta-c", name: "metadata client", roles: []string{"metadata@v1"}, sendChan: make(chan interface{}, 10), } playerClient := &ServerClient{ id: "player-c", name: "player client", roles: []string{"player@v1"}, sendChan: make(chan interface{}, 10), } // Attach via addClient so they're members of g.Clients(); drain the // group/update that addClient sends before exercising broadcast. g.addClient(metaClient) g.addClient(playerClient) <-metaClient.sendChan // group/update <-playerClient.sendChan // group/update // Drain the OnClientJoin server/state that fires on the metadata // client (since meta was registered before the client joined). // Sleep briefly so the dispatcher goroutine processes the join. time.Sleep(20 * time.Millisecond) select { case raw := <-metaClient.sendChan: t.Logf("drained join-time send: %+v", raw) default: // Already drained or never sent — fine, this test exercises the // broadcast path, not the join path. } // Direct invocation — independent of event dispatch. meta.OnPlaybackStateChanged("stopped", "playing") msg, state := drainServerStateMessage(t, metaClient) t.Logf("metaClient received: type=%s payload=%+v metadata=%+v", msg.Type, state, state.Metadata) if msg.Type != "server/state" { t.Errorf("Message.Type = %q, want server/state", msg.Type) } if state.Metadata == nil { t.Fatal("state.Metadata is nil") } if state.Metadata.Title == nil || *state.Metadata.Title != "Track Title" { t.Errorf("Title = %v, want \"Track Title\"", state.Metadata.Title) } if state.Metadata.Artist == nil || *state.Metadata.Artist != "Artist Name" { t.Errorf("Artist = %v, want \"Artist Name\"", state.Metadata.Artist) } if state.Metadata.Album == nil || *state.Metadata.Album != "Album Name" { t.Errorf("Album = %v, want \"Album Name\"", state.Metadata.Album) } if state.Metadata.Timestamp != 42 { t.Errorf("Timestamp = %d, want 42", state.Metadata.Timestamp) } expectNoSend(t, playerClient, 30*time.Millisecond, "non-metadata client") } // TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions // asserts that transitions that aren't "* → playing" (excluding // playing→playing) do not broadcast. func TestMetadataRole_OnPlaybackStateChanged_IgnoresNonPlayingTransitions(t *testing.T) { g := NewGroup("test-group") defer g.Close() meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "t", "a", "al" }, ClockMicros: func() int64 { return 0 }, }) g.RegisterRole(meta) sc := &ServerClient{ id: "meta-c", roles: []string{"metadata@v1"}, sendChan: make(chan interface{}, 10), } g.addClient(sc) <-sc.sendChan // group/update // Drain any OnClientJoin send that landed. time.Sleep(20 * time.Millisecond) select { case <-sc.sendChan: default: } cases := []struct { oldState, newState string }{ {"playing", "paused"}, {"playing", "stopped"}, {"stopped", "paused"}, {"playing", "playing"}, // would have been filtered by Group; pin role-level guard too } for _, tc := range cases { t.Logf("trying transition %s -> %s (must not broadcast)", tc.oldState, tc.newState) meta.OnPlaybackStateChanged(tc.oldState, tc.newState) expectNoSend(t, sc, 30*time.Millisecond, tc.oldState+"->"+tc.newState) } // Sanity: stopped -> playing IS broadcast. t.Log("sanity: stopped -> playing must broadcast") meta.OnPlaybackStateChanged("stopped", "playing") msg, state := drainServerStateMessage(t, sc) t.Logf("sanity broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata) } // TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState confirms // that the public BroadcastMetadata method pushes the snapshot to every // metadata-role client unconditionally. func TestMetadataRole_BroadcastMetadata_BroadcastsRegardlessOfState(t *testing.T) { g := NewGroup("test-group") defer g.Close() meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "Updated Title", "Updated Artist", "Updated Album" }, ClockMicros: func() int64 { return 100 }, }) g.RegisterRole(meta) sc1 := &ServerClient{ id: "meta-1", roles: []string{"metadata@v1"}, sendChan: make(chan interface{}, 10), } sc2 := &ServerClient{ id: "meta-2", roles: []string{"metadata@v2"}, // versioned form must still match sendChan: make(chan interface{}, 10), } scPlayer := &ServerClient{ id: "player-1", roles: []string{"player@v1"}, sendChan: make(chan interface{}, 10), } g.addClient(sc1) g.addClient(sc2) g.addClient(scPlayer) <-sc1.sendChan <-sc2.sendChan <-scPlayer.sendChan // Drain join-time server/state on the metadata clients. time.Sleep(20 * time.Millisecond) for _, sc := range []*ServerClient{sc1, sc2} { select { case <-sc.sendChan: default: } } meta.BroadcastMetadata() msg1, state1 := drainServerStateMessage(t, sc1) t.Logf("sc1 (metadata@v1) received: type=%s metadata=%+v", msg1.Type, state1.Metadata) if state1.Metadata == nil || state1.Metadata.Title == nil || *state1.Metadata.Title != "Updated Title" { t.Errorf("sc1: title = %v, want \"Updated Title\"", state1.Metadata) } msg2, state2 := drainServerStateMessage(t, sc2) t.Logf("sc2 (metadata@v2) received: type=%s metadata=%+v", msg2.Type, state2.Metadata) if state2.Metadata == nil || state2.Metadata.Title == nil || *state2.Metadata.Title != "Updated Title" { t.Errorf("sc2: title = %v, want \"Updated Title\"", state2.Metadata) } expectNoSend(t, scPlayer, 30*time.Millisecond, "player-only client") } // TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent is the // end-to-end wiring proof: register the role on a real Group, attach a // metadata-role client, call g.SetPlaybackState("stopped") then // g.SetPlaybackState("playing"), assert the client received the broadcast // via the dispatcher path. func TestMetadataRole_OnPlaybackStateChanged_FiresFromGroupEvent(t *testing.T) { g := NewGroup("test-group") defer g.Close() meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "E2E Title", "E2E Artist", "E2E Album" }, ClockMicros: func() int64 { return 7 }, }) g.RegisterRole(meta) sc := &ServerClient{ id: "meta-c", name: "metadata client", roles: []string{"metadata@v1"}, sendChan: make(chan interface{}, 10), } g.addClient(sc) <-sc.sendChan // group/update // Drain join-time server/state. time.Sleep(20 * time.Millisecond) select { case msg := <-sc.sendChan: t.Logf("drained join-time send: %+v", msg) default: } // Default playback state is "playing"; transition through stopped to // trigger an actual stopped→playing edge. g.SetPlaybackState("stopped") g.SetPlaybackState("playing") // Wait for the dispatcher to deliver, then assert. deadline := time.After(200 * time.Millisecond) for { select { case raw := <-sc.sendChan: msg, ok := raw.(protocol.Message) if !ok { t.Fatalf("queued send was %T, want protocol.Message", raw) } if msg.Type != "server/state" { continue } state, ok := msg.Payload.(protocol.ServerStateMessage) if !ok { t.Fatalf("Message.Payload was %T, want ServerStateMessage", msg.Payload) } t.Logf("E2E broadcast received: type=%s metadata=%+v", msg.Type, state.Metadata) if state.Metadata == nil || state.Metadata.Title == nil || *state.Metadata.Title != "E2E Title" { t.Errorf("E2E broadcast title = %v, want \"E2E Title\"", state.Metadata) } return case <-deadline: t.Fatal("timed out waiting for E2E broadcast via SetPlaybackState") } } } // TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe runs many // parallel BroadcastMetadata calls and asserts no panics, no races, and // every call lands at least one send on the metadata client. The fake // client's sendChan has capacity 200; the test bounds itself to fewer // broadcasts so we don't need to drain in parallel. func TestMetadataRole_BroadcastMetadata_ConcurrentCallsAreSafe(t *testing.T) { g := NewGroup("test-group") defer g.Close() meta := NewMetadataRole(MetadataConfig{ GetMetadata: func() (string, string, string) { return "t", "a", "al" }, ClockMicros: func() int64 { return 0 }, }) g.RegisterRole(meta) sc := &ServerClient{ id: "meta-c", roles: []string{"metadata@v1"}, sendChan: make(chan interface{}, 200), } g.addClient(sc) <-sc.sendChan // Drain any join-time send. time.Sleep(20 * time.Millisecond) select { case <-sc.sendChan: default: } const callers = 10 const perCaller = 5 var wg sync.WaitGroup for i := 0; i < callers; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < perCaller; j++ { meta.BroadcastMetadata() } }() } wg.Wait() count := 0 loop: for { select { case <-sc.sendChan: count++ default: break loop } } t.Logf("concurrent broadcasts queued %d sends (callers=%d, perCaller=%d)", count, callers, perCaller) if count == 0 { t.Error("expected at least one queued send from concurrent broadcasts, got 0") } }