Files
barnard/admin_test.go
T
Brandon McGintyandClaude Opus 5 872149c977 run all terminal work on the UI goroutine and lock shared client state
Route network and audio callbacks through a bounded UI queue.
Protocol handlers, the audio thread, and key handlers all drew to
termbox widgets directly, which is a data race against the render
loop. postUI is now the only path from a callback to a widget, work is
dropped during shutdown, and the queue never blocks the caller.

Guard the mutable client state with mutexes.
Connection and transmission flags, the selected user, the muted
channel set, and the audio stream pointer were each read and written
from at least two goroutines. Lookups that walk the client's user and
channel maps take the client lock and copy what they need.

Snapshot the display string when a tree item is built.
Tree items held live pointers and formatted themselves during
rendering, so a user removed between rebuild and draw was dereferenced
on the render path.

Cancel reconnect retries on shutdown and release audio before
reconnecting.
Quitting during a retry left the goroutine sleeping until its timer
expired, and a reconnect built a second OpenAL stream on top of the
first. Cleanup is idempotent so repeated disconnect events are safe.

Make UI shutdown idempotent and join the event poller.
Close could be called more than once, and the polling goroutine could
be left trying to deliver an event after Run had returned. Run now
also reports a termbox initialization failure instead of returning nil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:42:52 -04:00

120 lines
3.6 KiB
Go

package main
import (
"testing"
"git.stormux.org/storm/barnard/gumble/gumble"
"git.stormux.org/storm/barnard/uiterm"
)
// Regression: admin lookup helpers read Client.Users and Client.Channels while
// TCP handlers could mutate those maps.
func TestAdminLookupUsesClientSnapshot(t *testing.T) {
user := &gumble.User{Session: 7, Name: "Guest"}
channel := &gumble.Channel{ID: 4, Name: "Room"}
b := &Barnard{Client: &gumble.Client{Users: gumble.Users{7: user}, Channels: gumble.Channels{4: channel}}}
if b.findUser("guest") != user || b.findUser("7") != user {
t.Fatal("user lookup failed")
}
if b.findChannel("room") != channel || b.findChannel("4") != channel {
t.Fatal("channel lookup failed")
}
}
func TestParseToggleState(t *testing.T) {
tests := []struct {
name string
text string
current bool
want bool
wantOK bool
}{
{name: "toggle true", text: "toggle", current: true, want: false, wantOK: true},
{name: "empty toggles", text: "", current: false, want: true, wantOK: true},
{name: "on", text: "on", current: false, want: true, wantOK: true},
{name: "off", text: "off", current: true, want: false, wantOK: true},
{name: "bad", text: "maybe", current: true, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseToggleState(tt.text, tt.current)
if ok != tt.wantOK {
t.Fatalf("expected ok %v, got %v", tt.wantOK, ok)
}
if ok && got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestParsePermission(t *testing.T) {
tests := []struct {
text string
want gumble.Permission
}{
{text: "write", want: gumble.PermissionWrite},
{text: "mute_deafen", want: gumble.PermissionMuteDeafen},
{text: "kick", want: gumble.PermissionKick},
{text: "register_self", want: gumble.PermissionRegisterSelf},
}
for _, tt := range tests {
got, ok := parsePermission(tt.text)
if !ok {
t.Fatalf("expected %s to parse", tt.text)
}
if got != tt.want {
t.Fatalf("expected %v, got %v", tt.want, got)
}
}
if _, ok := parsePermission("bogus"); ok {
t.Fatal("expected bogus permission to fail")
}
}
func TestPermissionList(t *testing.T) {
got := permissionList(gumble.PermissionWrite | gumble.PermissionBan)
if got != "write,ban" {
t.Fatalf("expected write,ban, got %q", got)
}
}
// Regression: negative manual-ban minutes were cast to an unsigned protocol
// duration, turning a rejected short ban into an extremely long one.
func TestManualBanDurationRejectsNegativeMinutes(t *testing.T) {
if _, err := manualBanDuration("-1"); err == nil {
t.Fatal("negative duration was accepted")
}
if _, err := manualBanDuration("9223372036854775807"); err == nil {
t.Fatal("overflowing duration was accepted")
}
got, err := manualBanDuration("15")
if err != nil || got != 15*60*1000000000 {
t.Fatalf("got %v, %v", got, err)
}
}
func TestAdminEscapeInputs(t *testing.T) {
if !isAdminEscapeKey(uiterm.KeyEsc) {
t.Fatal("expected escape key to close admin menu")
}
if !isAdminEscapeKey(uiterm.KeyAltEsc) {
t.Fatal("expected alt escape key to close admin menu")
}
if !isAdminEscapeKey(uiterm.KeyAltArrowUp) {
t.Fatal("expected escape-prefixed up arrow to close admin menu")
}
if !isAdminEscapeKey(uiterm.KeyAltArrowDown) {
t.Fatal("expected escape-prefixed down arrow to close admin menu")
}
if isAdminEscapeKey(uiterm.KeyEnter) {
t.Fatal("expected enter key not to close admin menu")
}
if !isAdminEscapeCharacter(rune(uiterm.KeyEsc)) {
t.Fatal("expected escape character to close admin menu")
}
if isAdminEscapeCharacter('x') {
t.Fatal("expected non-escape character not to close admin menu")
}
}