328 lines
8.8 KiB
Go
328 lines
8.8 KiB
Go
// ABOUTME: Tests for the Group event bus plumbing
|
|
// ABOUTME: Fan-out, unsubscribe, and slow-subscriber drop behavior
|
|
package sendspin
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestGroup_PublishSubscribe confirms the basic contract: an event
|
|
// published after Subscribe is delivered to the subscriber's channel.
|
|
func TestGroup_PublishSubscribe(t *testing.T) {
|
|
g := NewGroup("test-group")
|
|
defer g.Close()
|
|
|
|
events, unsubscribe := g.Subscribe()
|
|
defer unsubscribe()
|
|
|
|
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
|
|
|
|
select {
|
|
case evt := <-events:
|
|
joined, ok := evt.(ClientJoinedEvent)
|
|
if !ok {
|
|
t.Fatalf("got %T, want ClientJoinedEvent", evt)
|
|
}
|
|
if joined.Client.ID() != "c1" {
|
|
t.Errorf("Client.ID() = %q, want %q", joined.Client.ID(), "c1")
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("timed out waiting for published event")
|
|
}
|
|
}
|
|
|
|
// TestGroup_FanOut confirms one publish reaches every active subscriber.
|
|
func TestGroup_FanOut(t *testing.T) {
|
|
g := NewGroup("test-group")
|
|
defer g.Close()
|
|
|
|
const subscribers = 3
|
|
chans := make([]<-chan Event, subscribers)
|
|
for i := 0; i < subscribers; i++ {
|
|
ch, unsub := g.Subscribe()
|
|
defer unsub()
|
|
chans[i] = ch
|
|
}
|
|
|
|
g.publish(ClientLeftEvent{ClientID: "c-gone", ClientName: "Gone Client"})
|
|
|
|
var wg sync.WaitGroup
|
|
for i, ch := range chans {
|
|
wg.Add(1)
|
|
go func(idx int, c <-chan Event) {
|
|
defer wg.Done()
|
|
select {
|
|
case evt := <-c:
|
|
if _, ok := evt.(ClientLeftEvent); !ok {
|
|
t.Errorf("subscriber %d got %T, want ClientLeftEvent", idx, evt)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Errorf("subscriber %d timed out", idx)
|
|
}
|
|
}(i, ch)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestGroup_Unsubscribe confirms that after calling the unsubscribe func,
|
|
// further publishes do NOT reach the channel.
|
|
func TestGroup_Unsubscribe(t *testing.T) {
|
|
g := NewGroup("test-group")
|
|
defer g.Close()
|
|
|
|
events, unsubscribe := g.Subscribe()
|
|
|
|
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c1"}})
|
|
<-events // drain the first event
|
|
|
|
unsubscribe()
|
|
|
|
// Publish a second event — the unsubscribed channel should not receive it.
|
|
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "c2"}})
|
|
|
|
select {
|
|
case evt, ok := <-events:
|
|
if ok {
|
|
t.Errorf("received event %v after unsubscribe", evt)
|
|
}
|
|
// Channel closed — also acceptable.
|
|
case <-time.After(50 * time.Millisecond):
|
|
// No event arrived within the window — correct behavior.
|
|
}
|
|
}
|
|
|
|
// TestGroup_SlowSubscriberDoesNotBlockPublisher pins the non-blocking
|
|
// fan-out contract: a subscriber that never reads must not stall events
|
|
// for other subscribers or the publisher itself.
|
|
func TestGroup_SlowSubscriberDoesNotBlockPublisher(t *testing.T) {
|
|
g := NewGroup("test-group")
|
|
defer g.Close()
|
|
|
|
// slow subscriber — we intentionally never read from this.
|
|
_, _ = g.Subscribe()
|
|
|
|
fast, unsubscribeFast := g.Subscribe()
|
|
defer unsubscribeFast()
|
|
|
|
// Overflow the slow subscriber's buffer.
|
|
for i := 0; i < 100; i++ {
|
|
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
|
|
}
|
|
|
|
// The fast subscriber should still have received events.
|
|
received := 0
|
|
timeout := time.After(200 * time.Millisecond)
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-fast:
|
|
received++
|
|
if received >= 32 {
|
|
break loop
|
|
}
|
|
case <-timeout:
|
|
break loop
|
|
}
|
|
}
|
|
if received == 0 {
|
|
t.Error("fast subscriber received zero events while slow one blocked")
|
|
}
|
|
}
|
|
|
|
// TestGroup_IDReturnsConstructorValue just pins the GroupID accessor.
|
|
func TestGroup_IDReturnsConstructorValue(t *testing.T) {
|
|
g := NewGroup("my-group-id")
|
|
defer g.Close()
|
|
if got := g.ID(); got != "my-group-id" {
|
|
t.Errorf("ID() = %q, want %q", got, "my-group-id")
|
|
}
|
|
}
|
|
|
|
// TestGroup_ConcurrentSubscribeClose spawns many Subscribe callers
|
|
// against a concurrent Close and asserts that nothing panics and every
|
|
// returned channel is either pre-closed or closes promptly. Guards the
|
|
// lock discipline against future refactors that could regress the
|
|
// Subscribe-vs-Close ordering.
|
|
func TestGroup_ConcurrentSubscribeClose(t *testing.T) {
|
|
const subscribers = 50
|
|
g := NewGroup("race-bait")
|
|
|
|
var wg sync.WaitGroup
|
|
subsReady := make(chan struct{})
|
|
|
|
for i := 0; i < subscribers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-subsReady
|
|
ch, unsub := g.Subscribe()
|
|
defer unsub()
|
|
|
|
// Drain until the channel closes. If Close races with our
|
|
// Subscribe we should either get a pre-closed channel
|
|
// immediately or see it close after Close completes.
|
|
deadline := time.After(500 * time.Millisecond)
|
|
for {
|
|
select {
|
|
case _, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
case <-deadline:
|
|
t.Error("channel never closed")
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
close(subsReady)
|
|
time.Sleep(5 * time.Millisecond) // let some Subscribes land
|
|
g.Close()
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestGroup_ConcurrentPublishClose races publishes against a close and
|
|
// asserts no panic. A send on a closed channel would surface as a panic
|
|
// recovered by the test runner.
|
|
func TestGroup_ConcurrentPublishClose(t *testing.T) {
|
|
g := NewGroup("race-bait")
|
|
|
|
// Pre-subscribe so there's a channel to fan out to.
|
|
_, unsub := g.Subscribe()
|
|
defer unsub()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
for i := 0; i < 1000; i++ {
|
|
g.publish(ClientJoinedEvent{Client: &ServerClient{id: "flood"}})
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
time.Sleep(2 * time.Millisecond) // let some publishes land
|
|
g.Close()
|
|
<-done
|
|
}
|
|
|
|
// TestGroup_SetPlaybackState_PublishesOnChange confirms that
|
|
// SetPlaybackState publishes a GroupPlaybackStateChangedEvent only on
|
|
// actual transitions, and is a silent no-op on same-state writes. The
|
|
// group's default state is "playing" (per NewGroup), so calling
|
|
// SetPlaybackState("playing") first must produce no event; transitioning
|
|
// to "stopped" must produce exactly one event with OldState="playing".
|
|
func TestGroup_SetPlaybackState_PublishesOnChange(t *testing.T) {
|
|
g := NewGroup("test-group")
|
|
defer g.Close()
|
|
|
|
// Subscribe BEFORE any state writes so we observe every emitted event.
|
|
events, unsubscribe := g.Subscribe()
|
|
defer unsubscribe()
|
|
|
|
// Same-state write: must not publish (default is "playing").
|
|
g.SetPlaybackState("playing")
|
|
|
|
select {
|
|
case evt := <-events:
|
|
t.Fatalf("same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
|
|
case <-time.After(30 * time.Millisecond):
|
|
// Expected: no event.
|
|
}
|
|
|
|
// Real transition: must publish exactly one event.
|
|
g.SetPlaybackState("stopped")
|
|
|
|
select {
|
|
case evt := <-events:
|
|
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
|
if !ok {
|
|
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
|
}
|
|
t.Logf("event=%+v", ps)
|
|
if ps.OldState != "playing" || ps.NewState != "stopped" {
|
|
t.Errorf("event = %+v, want {OldState: playing, NewState: stopped}", ps)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("timed out waiting for GroupPlaybackStateChangedEvent")
|
|
}
|
|
|
|
// Same-state write again: must not publish.
|
|
g.SetPlaybackState("stopped")
|
|
|
|
select {
|
|
case evt := <-events:
|
|
t.Fatalf("repeat same-state SetPlaybackState published unexpected event: %T %+v", evt, evt)
|
|
case <-time.After(30 * time.Millisecond):
|
|
// Expected.
|
|
}
|
|
}
|
|
|
|
// TestGroup_SetPlaybackState_OldStateInEvent pins that successive
|
|
// transitions carry the prior playback state in OldState — not the empty
|
|
// string, not the first-ever value. This guards against future refactors
|
|
// that might forget to capture oldState before mutation.
|
|
func TestGroup_SetPlaybackState_OldStateInEvent(t *testing.T) {
|
|
g := NewGroup("test-group")
|
|
defer g.Close()
|
|
|
|
events, unsubscribe := g.Subscribe()
|
|
defer unsubscribe()
|
|
|
|
g.SetPlaybackState("paused")
|
|
g.SetPlaybackState("playing")
|
|
|
|
// First event: playing → paused
|
|
select {
|
|
case evt := <-events:
|
|
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
|
if !ok {
|
|
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
|
}
|
|
t.Logf("event[0]=%+v", ps)
|
|
if ps.OldState != "playing" || ps.NewState != "paused" {
|
|
t.Errorf("event[0] = %+v, want {playing, paused}", ps)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("timed out on first event")
|
|
}
|
|
|
|
// Second event: paused → playing
|
|
select {
|
|
case evt := <-events:
|
|
ps, ok := evt.(GroupPlaybackStateChangedEvent)
|
|
if !ok {
|
|
t.Fatalf("got %T, want GroupPlaybackStateChangedEvent", evt)
|
|
}
|
|
t.Logf("event[1]=%+v", ps)
|
|
if ps.OldState != "paused" || ps.NewState != "playing" {
|
|
t.Errorf("event[1] = %+v, want {paused, playing}", ps)
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("timed out on second event")
|
|
}
|
|
}
|
|
|
|
// TestGroup_AddClientSendsGroupUpdate confirms that addClient sends
|
|
// a group/update message to the joining client before publishing
|
|
// ClientJoinedEvent.
|
|
func TestGroup_AddClientSendsGroupUpdate(t *testing.T) {
|
|
g := NewGroup("group-123")
|
|
defer g.Close()
|
|
|
|
sc := &ServerClient{
|
|
id: "c1",
|
|
sendChan: make(chan interface{}, 10),
|
|
}
|
|
g.addClient(sc)
|
|
|
|
select {
|
|
case msg := <-sc.sendChan:
|
|
_ = msg // Verifies group/update was sent
|
|
default:
|
|
t.Fatal("addClient did not send group/update")
|
|
}
|
|
}
|