//go:build integration package teamspeak_test // Integration tests against a live TeamSpeak 3 server (build tag: integration). // // Run locally: // // docker compose -f docker-compose.integration.yml up -d --wait // TEAMSPEAK_ADDR=127.0.0.1:9987 go test -tags integration ./... -v -timeout 120s // docker compose -f docker-compose.integration.yml down // // In CI the server is provided by the workflow's service container and // TEAMSPEAK_ADDR is set automatically. // // # Notes // // - A single shared client is reused across tests to avoid TS3 anti-flood // protection, which bans IPs that establish too many connections quickly. // - Some commands (clientlist, channellist) require elevated server group // permissions that the default "Guest" group does not have. Those tests // skip automatically instead of failing hard. import ( "bytes" "context" "fmt" "log/slog" "os" "strconv" "strings" "sync" "testing" "time" teamspeak "github.com/honeybbq/teamspeak-go" "github.com/honeybbq/teamspeak-go/crypto" ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- var ( sharedClient *teamspeak.Client sharedOnce sync.Once sharedErr error ) var ( integrationServerPassword = os.Getenv("TEAMSPEAK_SERVER_PASSWORD") integrationDefaultChannel = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL") integrationDefaultChannelPassword = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL_PASSWORD") ) func integrationClientOptions(logger *slog.Logger) []teamspeak.ClientOption { opts := make([]teamspeak.ClientOption, 0, 4) if logger != nil { opts = append(opts, teamspeak.WithLogger(logger)) } if integrationServerPassword != "" { opts = append(opts, teamspeak.WithServerPassword(integrationServerPassword)) } if integrationDefaultChannel != "" { opts = append(opts, teamspeak.WithDefaultChannel(integrationDefaultChannel)) } if integrationDefaultChannelPassword != "" { opts = append(opts, teamspeak.WithDefaultChannelPassword(integrationDefaultChannelPassword)) } return opts } func requireTeamSpeakAddr(t *testing.T) string { t.Helper() addr := os.Getenv("TEAMSPEAK_ADDR") if addr == "" { t.Skip("TEAMSPEAK_ADDR not set — skip integration test (set TEAMSPEAK_ADDR=host:port to enable)") } return addr } func requireSharedClient(t *testing.T) *teamspeak.Client { t.Helper() addr := requireTeamSpeakAddr(t) sharedOnce.Do(func() { id, err := crypto.GenerateIdentity(8) if err != nil { sharedErr = err return } c := teamspeak.NewClient(id, addr, "teamspeak-go-integ", integrationClientOptions(nil)...) if err = c.Connect(); err != nil { sharedErr = err return } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err = c.WaitConnected(ctx); err != nil { sharedErr = err return } sharedClient = c }) if sharedErr != nil { t.Fatalf("shared client setup failed: %v", sharedErr) } return sharedClient } func skipOnPermErr(t *testing.T, err error) { t.Helper() if err != nil && strings.Contains(err.Error(), "insufficient") { t.Skipf("skipping — server returned permission error: %v", err) } } func newConnectedIntegrationClient(t *testing.T, addr string, nicknamePrefix string, logger *slog.Logger) *teamspeak.Client { t.Helper() id, err := crypto.GenerateIdentity(8) if err != nil { t.Fatalf("GenerateIdentity: %v", err) } nickname := nicknamePrefix + strconv.FormatInt(time.Now().UTC().UnixNano()%1_000_000, 10) // TS3 anti-flood may temporarily ban IPs that connect too frequently. // Retry with backoff to handle transient bans in CI. var client *teamspeak.Client for attempt := range 3 { if attempt > 0 { time.Sleep(time.Duration(attempt*5) * time.Second) } client = teamspeak.NewClient(id, addr, nickname, integrationClientOptions(logger)...) if err = client.Connect(); err != nil { continue } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) err = client.WaitConnected(ctx) cancel() if err == nil { break } _ = client.Disconnect() } if err != nil { t.Fatalf("WaitConnected(%s) after retries: %v", nickname, err) } t.Cleanup(func() { _ = client.Disconnect() }) return client } func mapKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } func extractJSONField(s string, needle string) string { idx := strings.Index(s, needle) if idx < 0 { return "" } start := idx + len(needle) end := strings.Index(s[start:], "\"") if end < 0 { return "" } return s[start : start+end] } // --------------------------------------------------------------------------- // Connection // --------------------------------------------------------------------------- func TestIntegration_Connect(t *testing.T) { c := requireSharedClient(t) clid := c.ClientID() if clid == 0 { t.Error("expected non-zero client ID after connect") } t.Logf("connected: clid=%d", clid) } func TestIntegration_ConnectWithOptionalHandshakeAuth(t *testing.T) { c := requireSharedClient(t) t.Logf( "connect auth enabled: serverPassword=%t defaultChannel=%t defaultChannelPassword=%t", integrationServerPassword != "", integrationDefaultChannel != "", integrationDefaultChannelPassword != "", ) if c.ClientID() == 0 { t.Error("expected non-zero client ID after connect") } } func TestIntegration_Disconnect(t *testing.T) { addr := requireTeamSpeakAddr(t) id, err := crypto.GenerateIdentity(8) if err != nil { t.Fatalf("GenerateIdentity: %v", err) } c := teamspeak.NewClient(id, addr, "teamspeak-go-integ-disc", integrationClientOptions(nil)...) if err = c.Connect(); err != nil { t.Fatalf("Connect: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err = c.WaitConnected(ctx); err != nil { t.Fatalf("WaitConnected: %v", err) } disconnected := make(chan error, 1) c.OnDisconnected(func(e error) { disconnected <- e }) if err = c.Disconnect(); err != nil { t.Logf("Disconnect returned (non-fatal): %v", err) } select { case <-disconnected: case <-time.After(5 * time.Second): t.Error("OnDisconnected not fired after Disconnect()") } } // --------------------------------------------------------------------------- // Server queries // --------------------------------------------------------------------------- func TestIntegration_ListClients(t *testing.T) { c := requireSharedClient(t) clients, err := c.ListClients() skipOnPermErr(t, err) if err != nil { t.Fatalf("ListClients: %v", err) } if len(clients) == 0 { t.Fatal("expected at least one client (ourselves)") } ownID := c.ClientID() found := false for _, cl := range clients { if cl.ID == ownID { found = true t.Logf("self: clid=%d nick=%q cid=%d", cl.ID, cl.Nickname, cl.ChannelID) break } } if !found { t.Errorf("own clid=%d not found in clientlist", ownID) } } func TestIntegration_ListChannels(t *testing.T) { c := requireSharedClient(t) channels, err := c.ListChannels() skipOnPermErr(t, err) if err != nil { t.Fatalf("ListChannels: %v", err) } if len(channels) == 0 { t.Fatal("expected at least one channel (default channel)") } t.Logf("channels: %d found, first=%q", len(channels), channels[0].Name) } func TestIntegration_JoinsConfiguredDefaultChannel(t *testing.T) { if integrationDefaultChannel == "" { t.Skip("TEAMSPEAK_DEFAULT_CHANNEL not set") } c := requireSharedClient(t) channels, err := c.ListChannels() skipOnPermErr(t, err) if err != nil { t.Fatalf("ListChannels: %v", err) } clients, err := c.ListClients() skipOnPermErr(t, err) if err != nil { t.Fatalf("ListClients: %v", err) } var self *teamspeak.ClientInfo for i := range clients { if clients[i].ID == c.ClientID() { self = &clients[i] break } } if self == nil { t.Fatal("expected to find ourselves in client list") } var currentChannel *teamspeak.ChannelInfo for i := range channels { if channels[i].ID == self.ChannelID { currentChannel = &channels[i] break } } if currentChannel == nil { t.Fatalf("expected to resolve current channel for cid=%d", self.ChannelID) } if currentChannel.Name != integrationDefaultChannel { t.Fatalf("expected current channel %q, got %q", integrationDefaultChannel, currentChannel.Name) } } func TestIntegration_GetClientInfo(t *testing.T) { c := requireSharedClient(t) info, err := c.GetClientInfo(c.ClientID()) skipOnPermErr(t, err) if err != nil { t.Fatalf("GetClientInfo: %v", err) } if len(info) == 0 { t.Fatal("expected non-empty client info map") } if info["client_nickname"] == "" { t.Errorf("expected client_nickname in clientinfo response; got keys: %v", mapKeys(info)) } t.Logf("clientinfo keys: %v", mapKeys(info)) } // --------------------------------------------------------------------------- // Text messages // --------------------------------------------------------------------------- func TestIntegration_TextPrivateNotifyFields(t *testing.T) { addr := requireTeamSpeakAddr(t) var logBuf bytes.Buffer debugLogger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})) receiver := newConnectedIntegrationClient(t, addr, "rx", debugLogger) sender := newConnectedIntegrationClient(t, addr, "tx", slog.Default()) senderInfo, err := sender.GetClientInfo(sender.ClientID()) if err != nil { t.Fatalf("sender GetClientInfo: %v", err) } senderUID := strings.TrimSpace(senderInfo["client_unique_identifier"]) t.Logf("sender clientinfo keys: %v", mapKeys(senderInfo)) received := make(chan teamspeak.TextMessage, 1) receiver.OnTextMessage(func(msg teamspeak.TextMessage) { select { case received <- msg: default: } }) time.Sleep(500 * time.Millisecond) probeText := fmt.Sprintf("cursor-probe-%d", time.Now().UTC().UnixNano()) if err := sender.SendTextMessage(1, uint64(receiver.ClientID()), probeText); err != nil { t.Fatalf("sender SendTextMessage private: %v", err) } var msg teamspeak.TextMessage select { case msg = <-received: case <-time.After(10 * time.Second): t.Fatalf("timeout waiting for private text notification; logs=%s", logBuf.String()) } if msg.Message != probeText { t.Fatalf("unexpected message text: got %q want %q", msg.Message, probeText) } logs := logBuf.String() t.Logf("receiver logs: %s", logs) if !strings.Contains(logs, "\"name\":\"notifytextmessage\"") { t.Fatalf("expected notifytextmessage in logs, got: %s", logs) } targetNeedle := fmt.Sprintf("\"target\":\"%d\"", receiver.ClientID()) if !strings.Contains(logs, targetNeedle) { t.Fatalf("expected raw notify target %s in logs, got: %s", targetNeedle, logs) } rawInvokerUID := extractJSONField(logs, "\"invokeruid\":\"") if rawInvokerUID == "" { t.Fatalf("expected raw notify invokeruid in logs, got: %s", logs) } if msg.TargetMode != 1 { t.Fatalf("unexpected target mode: got %d want 1", msg.TargetMode) } if msg.TargetID != uint64(receiver.ClientID()) { t.Fatalf("parsed TargetID mismatch: got %d want %d", msg.TargetID, receiver.ClientID()) } expectedInvokerUID := rawInvokerUID if senderUID != "" { expectedInvokerUID = senderUID } if strings.TrimSpace(msg.InvokerUID) != expectedInvokerUID { t.Fatalf("parsed InvokerUID mismatch: got %q want %q", msg.InvokerUID, expectedInvokerUID) } } // --------------------------------------------------------------------------- // Poke // --------------------------------------------------------------------------- func TestIntegration_PokeSendAndReceive(t *testing.T) { addr := requireTeamSpeakAddr(t) sender := newConnectedIntegrationClient(t, addr, "poke-tx", slog.Default()) receiver := newConnectedIntegrationClient(t, addr, "poke-rx", slog.Default()) pokeMsg := fmt.Sprintf("poke-test-%d", time.Now().UTC().UnixNano()) poked := make(chan teamspeak.PokeEvent, 1) receiver.OnPoked(func(e teamspeak.PokeEvent) { select { case poked <- e: default: } }) time.Sleep(500 * time.Millisecond) if err := sender.Poke(receiver.ClientID(), pokeMsg); err != nil { t.Fatalf("Poke: %v", err) } t.Logf("sent poke from clid=%d to clid=%d msg=%q", sender.ClientID(), receiver.ClientID(), pokeMsg) select { case evt := <-poked: t.Logf("poke received: invoker=%q uid=%q msg=%q", evt.InvokerName, evt.InvokerUID, evt.Message) if evt.InvokerID != sender.ClientID() { t.Errorf("InvokerID mismatch: got %d want %d", evt.InvokerID, sender.ClientID()) } if evt.Message != pokeMsg { t.Errorf("Message mismatch: got %q want %q", evt.Message, pokeMsg) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for poke notification") } } func TestIntegration_PokeEmptyMessage(t *testing.T) { addr := requireTeamSpeakAddr(t) sender := newConnectedIntegrationClient(t, addr, "poke-tx2", slog.Default()) receiver := newConnectedIntegrationClient(t, addr, "poke-rx2", slog.Default()) poked := make(chan teamspeak.PokeEvent, 1) receiver.OnPoked(func(e teamspeak.PokeEvent) { select { case poked <- e: default: } }) time.Sleep(500 * time.Millisecond) if err := sender.Poke(receiver.ClientID(), ""); err != nil { t.Fatalf("Poke (empty): %v", err) } t.Logf("sent empty poke from clid=%d to clid=%d", sender.ClientID(), receiver.ClientID()) select { case evt := <-poked: t.Logf("empty poke received: invoker=%q msg=%q", evt.InvokerName, evt.Message) if evt.Message != "" { t.Errorf("expected empty message, got %q", evt.Message) } case <-time.After(10 * time.Second): t.Fatal("timeout waiting for empty poke notification") } }