Files
barnard/client_notification_test.go

391 lines
11 KiB
Go

package main
import (
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/gumble/gumbleopenal"
)
// Regression: HTML escaping left terminal control sequences in server text,
// allowing ANSI/OSC sequences to alter the terminal that rendered it.
// Regression: a capture-device open error left the application alive with no
// usable microphone. These errors are fatal and use the post-TUI stderr path.
func TestFatalAudioOpenError(t *testing.T) {
for _, err := range []error{gumbleopenal.ErrMic, gumbleopenal.ErrInputDevice, gumbleopenal.ErrOutputDevice, fmt.Errorf("wrapped: %w", gumbleopenal.ErrMic)} {
if !fatalAudioOpenError(err) {
t.Fatalf("%v was not fatal", err)
}
}
if fatalAudioOpenError(gumbleopenal.ErrState) {
t.Fatal("state error should remain recoverable")
}
}
func TestEscRemovesTerminalControlSequences(t *testing.T) {
got := esc("name\x1b]0;spoof\a\x7f\u202e")
if got != "name]0;spoof" {
t.Fatalf("unsafe terminal text %q", got)
}
}
type testReadCloser struct{ io.Reader }
func (testReadCloser) Close() error { return nil }
// Regression: an EOF from the FIFO was ignored and caused an unbounded busy
// loop. The reader must deliver a final command then close its output.
func TestReadFIFOStopsOnEOF(t *testing.T) {
out := make(chan string)
go readFIFO(testReadCloser{strings.NewReader("command\n")}, out)
if got := <-out; got != "command" {
t.Fatalf("got %q", got)
}
if _, ok := <-out; ok {
t.Fatal("FIFO output remained open after EOF")
}
}
// Regression: sequential substitutions re-expanded placeholders embedded in
// server-provided fields, and a slow notifier blocked callback goroutines.
func TestNotificationExpansionIsSinglePassAndNotifyDoesNotBlock(t *testing.T) {
got := expandNotification("%event %what", []string{"event", "who", "%event"})
if got != "event %event" {
t.Fatalf("unexpected expansion %q", got)
}
b := &Barnard{notifyChannel: make(chan []string, 1)}
b.Notify("one", "", "")
done := make(chan struct{})
go func() { b.Notify("two", "", ""); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Notify blocked on a full queue")
}
}
func TestAudioIntervalDuration(t *testing.T) {
for _, milliseconds := range []int{10, 20, 40, 60} {
got, err := audioIntervalDuration(milliseconds)
if err != nil {
t.Errorf("audioIntervalDuration(%d): %v", milliseconds, err)
continue
}
if got != time.Duration(milliseconds)*time.Millisecond {
t.Errorf("audioIntervalDuration(%d) = %v", milliseconds, got)
}
}
if _, err := audioIntervalDuration(30); err == nil {
t.Fatal("audioIntervalDuration accepted unsupported duration")
}
}
func TestJitterBufferDuration(t *testing.T) {
for _, milliseconds := range []int{0, 20, 40, 60} {
got, err := jitterBufferDuration(milliseconds)
if err != nil {
t.Errorf("jitterBufferDuration(%d): %v", milliseconds, err)
continue
}
if got != time.Duration(milliseconds)*time.Millisecond {
t.Errorf("jitterBufferDuration(%d) = %v", milliseconds, got)
}
}
if _, err := jitterBufferDuration(10); err == nil {
t.Fatal("jitterBufferDuration accepted unsupported duration")
}
}
func TestServerAddressDefaultsPortWithoutBreakingIPv6(t *testing.T) {
for input, want := range map[string]string{
"server": "server:64738",
"server:64739": "server:64739",
"::1": "[::1]:64738",
"[2001:db8::1]": "[2001:db8::1]:64738",
"[2001:db8::1]:9": "[2001:db8::1]:9",
} {
if got := serverAddress(input); got != want {
t.Errorf("serverAddress(%q) = %q, want %q", input, got, want)
}
}
}
func TestConcurrentConnectionStateAccess(t *testing.T) {
b := &Barnard{}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(value bool) {
defer wg.Done()
b.setConnected(value)
b.setTransmitting(value)
_ = b.isConnected()
_ = b.isTransmitting()
}(i%2 == 0)
}
wg.Wait()
}
func TestConcurrentSelectedUserAccess(t *testing.T) {
b := &Barnard{}
user := &gumble.User{Session: 1}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(user *gumble.User) {
defer wg.Done()
b.setSelectedUserValue(user)
_ = b.selectedUserValue()
}(user)
}
wg.Wait()
}
func TestConcurrentMutedChannelAccess(t *testing.T) {
b := &Barnard{MutedChannels: make(map[uint32]bool)}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
b.setChannelMuted(uint32(i%3), i%2 == 0)
_ = b.isChannelMuted(uint32((i + 1) % 3))
}(i)
}
wg.Wait()
}
func TestPublicTextMessageTargetsChannelIDsAndTrees(t *testing.T) {
root := &gumble.Channel{ID: 1, Name: "Root"}
current := &gumble.Channel{ID: 2, Name: "Room", Parent: root}
b := &Barnard{Client: &gumble.Client{Self: &gumble.User{Channel: current}}}
if !b.isPublicTextMessage(&gumble.TextMessageEvent{TextMessage: gumble.TextMessage{Trees: []*gumble.Channel{root}}}) {
t.Fatal("recursive message to an ancestor was not public")
}
other := &gumble.Channel{ID: 3, Name: "Room"}
if b.isPublicTextMessage(&gumble.TextMessageEvent{TextMessage: gumble.TextMessage{Channels: []*gumble.Channel{other}}}) {
t.Fatal("message to a different channel with the same name was public")
}
}
func TestPublicServerMessageDoesNotPanic(t *testing.T) {
channel := &gumble.Channel{ID: 1, Name: "Current"}
b := &Barnard{
Client: &gumble.Client{Self: &gumble.User{Channel: channel}},
notifyChannel: make(chan []string, 1),
}
b.OnTextMessage(&gumble.TextMessageEvent{
Client: b.Client,
TextMessage: gumble.TextMessage{
Channels: []*gumble.Channel{channel},
Message: "server announcement",
},
})
got := <-b.notifyChannel
want := []string{"msg", "Server", "server announcement"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
t.Fatalf("notification = %#v, want %#v", got, want)
}
}
// Regression: reconnect replaced Stream without destroying the old capture
// and renderer resources. Cleanup must be safe for repeated disconnects.
// Regression: user and channel names in the navigation tree bypassed message
// escaping and could still carry terminal control characters.
// Regression: status truncation used byte indexes and could create invalid
// UTF-8 when a non-ASCII user or channel name exceeded the display limit.
func TestTruncateInputStatusPreservesUTF8(t *testing.T) {
got := truncateInputStatus(strings.Repeat("é", 21))
if !utf8.ValidString(got) || utf8.RuneCountInString(got) != 21 {
t.Fatalf("invalid truncation %q", got)
}
}
func TestTreeItemSanitizesServerNames(t *testing.T) {
item := TreeItem{Channel: &gumble.Channel{Name: "\x1b[2Jroom"}}
if got := item.String(); got != "#[2Jroom" {
t.Fatalf("got %q", got)
}
}
func TestCleanupConnectionAudioIsIdempotent(t *testing.T) {
b := &Barnard{}
b.cleanupConnectionAudio()
b.cleanupConnectionAudio()
}
// Tone test mode intentionally does not create an OpenAL stream. Tree
// controls must therefore keep local mute state without trying to update one.
func TestReconnectCancellationIsSafeBeforeStartup(t *testing.T) {
b := &Barnard{}
b.stopReconnects()
if b.reconnectCanceled() {
t.Fatal("nil reconnect channel should not report cancellation")
}
}
func TestReconnectCancellationStopsWaiters(t *testing.T) {
b := &Barnard{reconnectStop: make(chan struct{})}
b.stopReconnects()
if !b.reconnectCanceled() {
t.Fatal("expected reconnect cancellation")
}
b.stopReconnects() // repeated shutdown must not panic
}
func TestUpdateUserGainAllowsToneTestWithoutStream(t *testing.T) {
(&Barnard{ToneTest: true}).updateUserGain(&gumble.User{})
}
func TestToneTestRejectsFilePlayback(t *testing.T) {
(&Barnard{ToneTest: true, Connected: true}).CommandPlayFile(nil, "https://example.invalid/audio")
}
func TestUserChangeNotification(t *testing.T) {
current := &gumble.Channel{ID: 1, Name: "Current"}
other := &gumble.Channel{ID: 2, Name: "Other"}
self := &gumble.User{Session: 1, Name: "Username", Channel: current}
tests := []struct {
name string
user *gumble.User
previous *gumble.Channel
change gumble.UserChangeType
want userChangeNotification
wantOK bool
}{
{
name: "connected to current channel",
user: &gumble.User{Session: 2, Name: "Guest", Channel: current},
change: gumble.UserChangeConnected,
want: userChangeNotification{
event: "join",
who: "Guest",
what: "Current",
line: "Guest joined Current",
},
wantOK: true,
},
{
name: "disconnected from current channel",
user: &gumble.User{Session: 2, Name: "Guest", Channel: current},
previous: current,
change: gumble.UserChangeDisconnected,
want: userChangeNotification{
event: "leave",
who: "Guest",
what: "Current",
line: "Guest left Current",
},
wantOK: true,
},
{
name: "moved into current channel",
user: &gumble.User{Session: 2, Name: "Guest", Channel: current},
previous: other,
change: gumble.UserChangeChannel,
want: userChangeNotification{
event: "join",
who: "Guest",
what: "Current",
line: "Guest joined Current",
},
wantOK: true,
},
{
name: "moved out of current channel",
user: &gumble.User{Session: 2, Name: "Guest", Channel: other},
previous: current,
change: gumble.UserChangeChannel,
want: userChangeNotification{
event: "leave",
who: "Guest",
what: "Current",
line: "Guest left Current",
},
wantOK: true,
},
{
name: "connected to other channel",
user: &gumble.User{Session: 2, Name: "Guest", Channel: other},
change: gumble.UserChangeConnected,
},
{
name: "moved between other channels",
user: &gumble.User{Session: 2, Name: "Guest", Channel: other},
previous: &gumble.Channel{ID: 3, Name: "Elsewhere"},
change: gumble.UserChangeChannel,
},
{
name: "self channel move",
user: self,
previous: other,
change: gumble.UserChangeChannel,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := &Barnard{
Client: &gumble.Client{Self: self},
}
if tt.previous != nil {
b.userChannels = map[uint32]*gumble.Channel{tt.user.Session: tt.previous}
}
got, ok := b.userChangeNotification(&gumble.UserChangeEvent{
Client: b.Client,
Type: tt.change,
User: tt.user,
})
if ok != tt.wantOK {
t.Fatalf("expected ok %v, got %v", tt.wantOK, ok)
}
if got != tt.want {
t.Fatalf("expected %#v, got %#v", tt.want, got)
}
})
}
}
func TestUpdateUserChannel(t *testing.T) {
current := &gumble.Channel{ID: 1, Name: "Current"}
other := &gumble.Channel{ID: 2, Name: "Other"}
user := &gumble.User{Session: 2, Name: "Guest", Channel: current}
b := &Barnard{}
b.updateUserChannel(&gumble.UserChangeEvent{
Type: gumble.UserChangeConnected,
User: user,
})
if got := b.previousUserChannel(user); got != current {
t.Fatalf("expected current channel to be remembered, got %#v", got)
}
user.Channel = other
b.updateUserChannel(&gumble.UserChangeEvent{
Type: gumble.UserChangeChannel,
User: user,
})
if got := b.previousUserChannel(user); got != other {
t.Fatalf("expected other channel to be remembered, got %#v", got)
}
b.updateUserChannel(&gumble.UserChangeEvent{
Type: gumble.UserChangeDisconnected,
User: user,
})
if got := b.previousUserChannel(user); got != nil {
t.Fatalf("expected disconnected user channel to be removed, got %#v", got)
}
}