Files
2026-07-20 19:01:03 +08:00

501 lines
13 KiB
Go

//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")
}
}