aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-06-25 07:44:23 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-06-25 07:44:23 -0700
commitbba70d0a2903ab1fff5a2e89cff97cb1f6c7f82a (patch)
treecb8bd5cda76f287358bd7b73292ae024e756c824
parent4912c3a7c20d765692993f6caaa98a94f5ae28cb (diff)
Introduce PresenceTracker interface for online-user tracking
Replace the leaky *redis.Client field on Server with a PresenceTracker interface that receives connect/rename/disconnect lifecycle events. This removes Redis-specific set encoding from hotline/server.go and the session handlers, and drops the redis dependency from the hotline package. The Redis implementation moves to mobius.RedisPresenceTracker, which owns the legacy "login::ip"/"login:nickname:ip" set encoding so existing deployments keep working. The API server reads online users through a new OnlineLister interface and falls back to the in-memory ClientMgr when no presence tracker is configured. Startup clearing of stale online state now happens unconditionally when Redis is configured, not only when the API server is enabled.
-rw-r--r--cmd/mobius-hotline-server/main.go12
-rw-r--r--hotline/ban.go3
-rw-r--r--hotline/presence.go20
-rw-r--r--hotline/server.go32
-rw-r--r--internal/mobius/api.go38
-rw-r--r--internal/mobius/handlers_session.go33
-rw-r--r--internal/mobius/handlers_session_test.go100
-rw-r--r--internal/mobius/redis_presence.go81
-rw-r--r--internal/mobius/redis_presence_test.go127
9 files changed, 380 insertions, 66 deletions
diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go
index ba7924d..5191805 100644
--- a/cmd/mobius-hotline-server/main.go
+++ b/cmd/mobius-hotline-server/main.go
@@ -122,6 +122,7 @@ func main() {
reloaders = append(reloaders, namedReloader{"message board", messageBoard})
// Initialize ban list - use Redis if configured, otherwise use file-based storage
+ var onlineLister mobius.OnlineLister
if *redisAddr != "" {
redisClient := redis.NewClient(&redis.Options{
Addr: *redisAddr,
@@ -135,7 +136,14 @@ func main() {
os.Exit(1)
}
- srv.Redis = redisClient
+ presence := mobius.NewRedisPresenceTracker(redisClient, slogger)
+ // Discard stale online state from a previous run.
+ if err := presence.Clear(ctx); err != nil {
+ slogger.Warn("Failed to clear online users in Redis", "err", err)
+ }
+ srv.Presence = presence
+ onlineLister = presence
+
srv.BanList = mobius.NewRedisBanMgr(redisClient, slogger)
slogger.Debug("Using Redis for ban management", "addr", *redisAddr)
} else {
@@ -195,7 +203,7 @@ func main() {
}
if *apiAddr != "" {
- sh := mobius.NewAPIServer(srv, reloadFunc, slogger, *apiKey)
+ sh := mobius.NewAPIServer(srv, onlineLister, reloadFunc, slogger, *apiKey)
go sh.Serve(*apiAddr)
}
diff --git a/hotline/ban.go b/hotline/ban.go
index 2713fbe..43988ab 100644
--- a/hotline/ban.go
+++ b/hotline/ban.go
@@ -9,13 +9,12 @@ import (
// BanDuration is the length of time for temporary bans.
const BanDuration = 30 * time.Minute
-// Redis key constants for ban management and online user tracking
+// Redis key constants for ban management
const (
RedisKeyBannedIPs = "mobius:banned:ips"
RedisKeyBannedUsers = "mobius:banned:users"
RedisKeyBannedNicknames = "mobius:banned:nicknames"
RedisKeyTempBannedIPs = "mobius:temp_banned:ips:"
- RedisKeyOnline = "mobius:online"
)
type BanMgr interface {
diff --git a/hotline/presence.go b/hotline/presence.go
new file mode 100644
index 0000000..7e41357
--- /dev/null
+++ b/hotline/presence.go
@@ -0,0 +1,20 @@
+package hotline
+
+// PresenceTracker receives best-effort notifications about user session lifecycle
+// events so an external system (e.g. Redis) can maintain a list of online users.
+//
+// It is optional: when a Server has no PresenceTracker, online presence is derived
+// from the in-memory ClientManager instead. Implementations must be safe for
+// concurrent use by multiple goroutines.
+type PresenceTracker interface {
+ // UserConnected is called after a successful login, before the nickname is known.
+ UserConnected(login, ip string)
+
+ // UserRenamed is called when a user's nickname is set or changed. oldNickname is
+ // empty the first time a nickname is set (e.g. the 1.2.3 login flow or TranAgreed).
+ UserRenamed(login, oldNickname, newNickname, ip string)
+
+ // UserDisconnected is called when a user's session ends. nickname is empty if the
+ // user disconnected before setting one.
+ UserDisconnected(login, nickname, ip string)
+}
diff --git a/hotline/server.go b/hotline/server.go
index 8a530c2..020f878 100644
--- a/hotline/server.go
+++ b/hotline/server.go
@@ -16,7 +16,6 @@ import (
"sync"
"time"
- "github.com/redis/go-redis/v9"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/time/rate"
@@ -62,7 +61,9 @@ type Server struct {
MessageBoard io.ReadWriteSeeker
- Redis *redis.Client
+ // Presence optionally records user session lifecycle events for an external online-user
+ // list. When nil, online presence is derived from ClientMgr.
+ Presence PresenceTracker
// TrackerRegistrar handles tracker registration (injectable for testing)
TrackerRegistrar TrackerRegistrar
@@ -133,6 +134,13 @@ func WithTrackerRegistrar(registrar TrackerRegistrar) func(s *Server) {
}
}
+// WithPresenceTracker optionally sets a PresenceTracker to record user session lifecycle events.
+func WithPresenceTracker(p PresenceTracker) func(s *Server) {
+ return func(s *Server) {
+ s.Presence = p
+ }
+}
+
// WithTLS optionally enables TLS support on the specified port.
func WithTLS(tlsConfig *tls.Config, port int) func(s *Server) {
return func(s *Server) {
@@ -577,18 +585,14 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
// transaction ordering and prevents interleaved writes.
go c.writeLoop()
- // TODO: refactor this into a connection manager interface, maybe?
- if s.Redis != nil {
- s.Redis.SAdd(context.Background(), RedisKeyOnline, login+"::"+ipAddr)
+ if s.Presence != nil {
+ s.Presence.UserConnected(login, ipAddr)
}
// Remove the client from the list of connected clients when they disconnect
defer func() {
- if s.Redis != nil {
- s.Redis.SRem(context.Background(), RedisKeyOnline, login+"::"+ipAddr)
- if userName := c.GetUserName(); len(userName) != 0 {
- s.Redis.SRem(context.Background(), RedisKeyOnline, login+":"+string(userName)+":"+ipAddr)
- }
+ if s.Presence != nil {
+ s.Presence.UserDisconnected(login, string(c.GetUserName()), ipAddr)
}
c.Disconnect()
}()
@@ -665,12 +669,8 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser
c.Logger = c.Logger.With("name", string(userName))
c.Logger.Info("Login successful")
- // Update the Redis set with the new information
- if s.Redis != nil {
- // Remove old entry (login::ip)
- s.Redis.SRem(context.Background(), RedisKeyOnline, login+"::"+ipAddr)
- // Add new entry with login, nickname, ip
- s.Redis.SAdd(context.Background(), RedisKeyOnline, login+":"+string(userName)+":"+ipAddr)
+ if s.Presence != nil {
+ s.Presence.UserRenamed(login, "", string(userName), ipAddr)
}
// Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this
diff --git a/internal/mobius/api.go b/internal/mobius/api.go
index cf0a46f..3620cf4 100644
--- a/internal/mobius/api.go
+++ b/internal/mobius/api.go
@@ -8,11 +8,16 @@ import (
"log"
"log/slog"
"net/http"
- "strings"
"github.com/jhalter/mobius/hotline"
)
+// OnlineLister returns the list of currently online users. It is implemented by
+// RedisPresenceTracker; when nil, the APIServer falls back to the in-memory ClientMgr.
+type OnlineLister interface {
+ Online(ctx context.Context) ([]OnlineUser, error)
+}
+
type logResponseWriter struct {
http.ResponseWriter
statusCode int
@@ -37,6 +42,7 @@ func (lrw *logResponseWriter) Write(b []byte) (int, error) {
// It supports user management, banning operations, and server administration.
type APIServer struct {
hlServer *hotline.Server
+ online OnlineLister
logger *slog.Logger
mux *http.ServeMux
apiKey string
@@ -63,9 +69,10 @@ func (srv *APIServer) logMiddleware(next http.Handler) http.Handler {
// NewAPIServer creates a new APIServer instance with the specified configuration.
// It sets up all API routes and middleware.
-func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger, apiKey string) *APIServer {
+func NewAPIServer(hlServer *hotline.Server, online OnlineLister, reloadFunc func(), logger *slog.Logger, apiKey string) *APIServer {
srv := APIServer{
hlServer: hlServer,
+ online: online,
logger: logger,
mux: http.NewServeMux(),
apiKey: apiKey,
@@ -81,14 +88,6 @@ func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logg
srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ShutdownHandler))))
srv.mux.Handle("/api/v1/stats", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.RenderStats))))
- if hlServer.Redis != nil {
- if err := hlServer.Redis.Del(context.Background(), hotline.RedisKeyOnline).Err(); err != nil {
- srv.logger.Warn("Failed to clear online users in Redis", "err", err)
- } else {
- srv.logger.Debug("Cleared online users in Redis on startup")
- }
- }
-
return &srv
}
@@ -97,18 +96,15 @@ func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logg
func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) {
var users []map[string]string
- if srv.hlServer.Redis != nil {
- members, err := srv.hlServer.Redis.SMembers(r.Context(), hotline.RedisKeyOnline).Result()
+ if srv.online != nil {
+ online, err := srv.online.Online(r.Context())
if err == nil {
- for _, m := range members {
- parts := strings.SplitN(m, ":", 3)
- if len(parts) == 3 {
- users = append(users, map[string]string{
- "login": parts[0],
- "nickname": parts[1],
- "ip": parts[2],
- })
- }
+ for _, u := range online {
+ users = append(users, map[string]string{
+ "login": u.Login,
+ "nickname": u.Nickname,
+ "ip": u.IP,
+ })
}
}
} else {
diff --git a/internal/mobius/handlers_session.go b/internal/mobius/handlers_session.go
index f17ffa3..443fed0 100644
--- a/internal/mobius/handlers_session.go
+++ b/internal/mobius/handlers_session.go
@@ -1,7 +1,6 @@
package mobius
import (
- "context"
"encoding/binary"
"io"
"math/big"
@@ -110,19 +109,14 @@ func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hot
login := cc.Account.Login
ip := cc.IP()
- if cc.Server.Redis != nil {
- // Remove old entry (login::ip)
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip)
- // Add new entry with login, nickname, ip
- cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.GetUserName())+":"+ip)
+ if cc.Server.Presence != nil {
+ cc.Server.Presence.UserRenamed(login, "", string(cc.GetUserName()), ip)
}
// Ban check for nickname
if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(string(cc.GetUserName())) {
- if cc.Server.Redis != nil {
- // Remove all possible online entries for this login and IP
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip)
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.GetUserName())+":"+ip)
+ if cc.Server.Presence != nil {
+ cc.Server.Presence.UserDisconnected(login, string(cc.GetUserName()), ip)
}
if err := cc.Server.BanList.Add(ip, nil); err != nil {
cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err)
@@ -275,25 +269,14 @@ func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (re
login := cc.Account.Login
ip := cc.IP()
- if cc.Server.Redis != nil {
- // Remove old entry (login:oldnickname:ip) and (login::ip)
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip)
- if oldNickname != "" {
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+oldNickname+":"+ip)
- }
- // Add new entry
- cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+newNickname+":"+ip)
+ if cc.Server.Presence != nil {
+ cc.Server.Presence.UserRenamed(login, oldNickname, newNickname, ip)
}
// Ban check for nickname
if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(newNickname) {
- if cc.Server.Redis != nil {
- // Remove all possible online entries for this login and IP
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip)
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+newNickname+":"+ip)
- if oldNickname != "" {
- cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+oldNickname+":"+ip)
- }
+ if cc.Server.Presence != nil {
+ cc.Server.Presence.UserDisconnected(login, newNickname, ip)
}
if err := cc.Server.BanList.Add(ip, nil); err != nil {
cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err)
diff --git a/internal/mobius/handlers_session_test.go b/internal/mobius/handlers_session_test.go
index 407b132..bffc81c 100644
--- a/internal/mobius/handlers_session_test.go
+++ b/internal/mobius/handlers_session_test.go
@@ -932,3 +932,103 @@ func TestHandleUserBroadcast(t *testing.T) {
})
}
}
+
+// presenceCall records a single PresenceTracker invocation for assertions.
+type presenceCall struct {
+ method string
+ login string
+ oldNickname string
+ newNickname string
+ nickname string
+ ip string
+}
+
+type fakePresenceTracker struct {
+ calls []presenceCall
+}
+
+func (f *fakePresenceTracker) UserConnected(login, ip string) {
+ f.calls = append(f.calls, presenceCall{method: "UserConnected", login: login, ip: ip})
+}
+
+func (f *fakePresenceTracker) UserRenamed(login, oldNickname, newNickname, ip string) {
+ f.calls = append(f.calls, presenceCall{method: "UserRenamed", login: login, oldNickname: oldNickname, newNickname: newNickname, ip: ip})
+}
+
+func (f *fakePresenceTracker) UserDisconnected(login, nickname, ip string) {
+ f.calls = append(f.calls, presenceCall{method: "UserDisconnected", login: login, nickname: nickname, ip: ip})
+}
+
+func newPresenceTestServer(presence hotline.PresenceTracker) *hotline.Server {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{})
+ return &hotline.Server{
+ TextDecoder: charmap.Macintosh.NewDecoder(),
+ TextEncoder: charmap.Macintosh.NewEncoder(),
+ Config: hotline.Config{BannerFile: "Banner.jpg"},
+ ClientMgr: &m,
+ Presence: presence,
+ }
+}
+
+func TestHandleTranAgreed_NotifiesPresenceTracker(t *testing.T) {
+ presence := &fakePresenceTracker{}
+ cc := &hotline.ClientConn{
+ Account: &hotline.Account{
+ Login: "alice",
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Version: []byte{0, 1},
+ RemoteAddr: "192.168.1.1:12345",
+ Logger: NewTestLogger(),
+ Server: newPresenceTestServer(presence),
+ }
+
+ tr := hotline.NewTransaction(
+ hotline.TranAgreed, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("Alice")),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 0}),
+ )
+
+ HandleTranAgreed(cc, &tr)
+
+ assert.Equal(t, []presenceCall{
+ {method: "UserRenamed", login: "alice", oldNickname: "", newNickname: "Alice", ip: "192.168.1.1"},
+ }, presence.calls)
+}
+
+func TestHandleSetClientUserInfo_NotifiesPresenceTracker(t *testing.T) {
+ presence := &fakePresenceTracker{}
+ cc := &hotline.ClientConn{
+ Account: &hotline.Account{
+ Login: "alice",
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Alice"),
+ RemoteAddr: "192.168.1.1:12345",
+ Logger: NewTestLogger(),
+ Server: newPresenceTestServer(presence),
+ }
+
+ tr := hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("Alice2")),
+ )
+
+ HandleSetClientUserInfo(cc, &tr)
+
+ assert.Equal(t, []presenceCall{
+ {method: "UserRenamed", login: "alice", oldNickname: "Alice", newNickname: "Alice2", ip: "192.168.1.1"},
+ }, presence.calls)
+}
diff --git a/internal/mobius/redis_presence.go b/internal/mobius/redis_presence.go
new file mode 100644
index 0000000..f0eed54
--- /dev/null
+++ b/internal/mobius/redis_presence.go
@@ -0,0 +1,81 @@
+package mobius
+
+import (
+ "context"
+ "log/slog"
+ "strings"
+
+ "github.com/redis/go-redis/v9"
+)
+
+// redisKeyOnline is the Redis set holding currently online users.
+const redisKeyOnline = "mobius:online"
+
+// OnlineUser describes a currently online user as reported by an OnlineLister.
+type OnlineUser struct {
+ Login string `json:"login"`
+ Nickname string `json:"nickname"`
+ IP string `json:"ip"`
+}
+
+// RedisPresenceTracker implements hotline.PresenceTracker backed by a Redis set. Set members
+// use the legacy formats "login::ip" (nickname unknown) and "login:nickname:ip" so existing
+// deployments and tooling continue to work.
+type RedisPresenceTracker struct {
+ client *redis.Client
+ logger *slog.Logger
+}
+
+// NewRedisPresenceTracker creates a new Redis-backed presence tracker.
+func NewRedisPresenceTracker(client *redis.Client, logger *slog.Logger) *RedisPresenceTracker {
+ return &RedisPresenceTracker{client: client, logger: logger}
+}
+
+func (r *RedisPresenceTracker) UserConnected(login, ip string) {
+ if err := r.client.SAdd(context.Background(), redisKeyOnline, login+"::"+ip).Err(); err != nil {
+ r.logger.Warn("Failed to record online user in Redis", "err", err)
+ }
+}
+
+func (r *RedisPresenceTracker) UserRenamed(login, oldNickname, newNickname, ip string) {
+ ctx := context.Background()
+ // Remove the pre-nickname entry and, if present, the previous nickname entry.
+ r.client.SRem(ctx, redisKeyOnline, login+"::"+ip)
+ if oldNickname != "" {
+ r.client.SRem(ctx, redisKeyOnline, login+":"+oldNickname+":"+ip)
+ }
+ if err := r.client.SAdd(ctx, redisKeyOnline, login+":"+newNickname+":"+ip).Err(); err != nil {
+ r.logger.Warn("Failed to update online user in Redis", "err", err)
+ }
+}
+
+func (r *RedisPresenceTracker) UserDisconnected(login, nickname, ip string) {
+ ctx := context.Background()
+ r.client.SRem(ctx, redisKeyOnline, login+"::"+ip)
+ if nickname != "" {
+ r.client.SRem(ctx, redisKeyOnline, login+":"+nickname+":"+ip)
+ }
+}
+
+// Clear removes all online-user entries. Call on startup to discard stale state from a
+// previous run.
+func (r *RedisPresenceTracker) Clear(ctx context.Context) error {
+ return r.client.Del(ctx, redisKeyOnline).Err()
+}
+
+// Online returns the list of currently online users parsed from the Redis set.
+func (r *RedisPresenceTracker) Online(ctx context.Context) ([]OnlineUser, error) {
+ members, err := r.client.SMembers(ctx, redisKeyOnline).Result()
+ if err != nil {
+ return nil, err
+ }
+
+ var users []OnlineUser
+ for _, m := range members {
+ parts := strings.SplitN(m, ":", 3)
+ if len(parts) == 3 {
+ users = append(users, OnlineUser{Login: parts[0], Nickname: parts[1], IP: parts[2]})
+ }
+ }
+ return users, nil
+}
diff --git a/internal/mobius/redis_presence_test.go b/internal/mobius/redis_presence_test.go
new file mode 100644
index 0000000..ac4cd56
--- /dev/null
+++ b/internal/mobius/redis_presence_test.go
@@ -0,0 +1,127 @@
+package mobius
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "testing"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/redis/go-redis/v9"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func isMember(t *testing.T, s *miniredis.Miniredis, member string) bool {
+ t.Helper()
+ // miniredis returns an error (rather than false, like real Redis) when the key is absent,
+ // which happens once the set is emptied.
+ if !s.Exists(redisKeyOnline) {
+ return false
+ }
+ ok, err := s.SIsMember(redisKeyOnline, member)
+ require.NoError(t, err)
+ return ok
+}
+
+func newTestPresenceTracker(t *testing.T) (*RedisPresenceTracker, *miniredis.Miniredis) {
+ t.Helper()
+
+ s, err := miniredis.Run()
+ require.NoError(t, err)
+ t.Cleanup(s.Close)
+
+ client := redis.NewClient(&redis.Options{Addr: s.Addr()})
+ t.Cleanup(func() { _ = client.Close() })
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ return NewRedisPresenceTracker(client, logger), s
+}
+
+func TestRedisPresenceTracker_LegacyMemberFormats(t *testing.T) {
+ // Lock the exact Redis set-member strings so existing deployments keep working.
+ tracker, s := newTestPresenceTracker(t)
+
+ t.Run("UserConnected stores login::ip", func(t *testing.T) {
+ tracker.UserConnected("alice", "192.168.1.1")
+ assert.True(t, isMember(t, s, "alice::192.168.1.1"))
+ })
+
+ t.Run("UserRenamed with no old nickname swaps login::ip for login:nick:ip", func(t *testing.T) {
+ s.FlushAll()
+ tracker.UserConnected("bob", "10.0.0.1")
+ tracker.UserRenamed("bob", "", "Bobby", "10.0.0.1")
+
+ assert.False(t, isMember(t, s, "bob::10.0.0.1"))
+ assert.True(t, isMember(t, s, "bob:Bobby:10.0.0.1"))
+ })
+
+ t.Run("UserRenamed removes the previous nickname entry", func(t *testing.T) {
+ s.FlushAll()
+ tracker.UserRenamed("carol", "", "Carol1", "10.0.0.2")
+ tracker.UserRenamed("carol", "Carol1", "Carol2", "10.0.0.2")
+
+ assert.False(t, isMember(t, s, "carol:Carol1:10.0.0.2"))
+ assert.True(t, isMember(t, s, "carol:Carol2:10.0.0.2"))
+ })
+
+ t.Run("UserDisconnected removes both possible entries", func(t *testing.T) {
+ s.FlushAll()
+ tracker.UserRenamed("dave", "", "Dave", "10.0.0.3")
+ tracker.UserDisconnected("dave", "Dave", "10.0.0.3")
+
+ assert.False(t, isMember(t, s, "dave::10.0.0.3"))
+ assert.False(t, isMember(t, s, "dave:Dave:10.0.0.3"))
+ })
+
+ t.Run("UserDisconnected with empty nickname removes login::ip", func(t *testing.T) {
+ s.FlushAll()
+ tracker.UserConnected("erin", "10.0.0.4")
+ tracker.UserDisconnected("erin", "", "10.0.0.4")
+
+ assert.False(t, isMember(t, s, "erin::10.0.0.4"))
+ })
+}
+
+func TestRedisPresenceTracker_Online(t *testing.T) {
+ tracker, _ := newTestPresenceTracker(t)
+ ctx := context.Background()
+
+ tracker.UserRenamed("alice", "", "Alice", "192.168.1.1")
+ tracker.UserRenamed("bob", "", "Bob", "192.168.1.2")
+
+ users, err := tracker.Online(ctx)
+ require.NoError(t, err)
+ require.Len(t, users, 2)
+
+ // Order is not guaranteed by the Redis set, so index by login.
+ byLogin := map[string]OnlineUser{}
+ for _, u := range users {
+ byLogin[u.Login] = u
+ }
+ assert.Equal(t, OnlineUser{Login: "alice", Nickname: "Alice", IP: "192.168.1.1"}, byLogin["alice"])
+ assert.Equal(t, OnlineUser{Login: "bob", Nickname: "Bob", IP: "192.168.1.2"}, byLogin["bob"])
+}
+
+func TestRedisPresenceTracker_OnlinePreNicknameEntry(t *testing.T) {
+ // A connected-but-not-yet-named user is stored as "login::ip", which splits into three
+ // parts with an empty nickname. The previous api.go logic included it the same way, so
+ // preserve that behavior.
+ tracker, _ := newTestPresenceTracker(t)
+
+ tracker.UserConnected("alice", "192.168.1.1")
+
+ users, err := tracker.Online(context.Background())
+ require.NoError(t, err)
+ require.Len(t, users, 1)
+ assert.Equal(t, OnlineUser{Login: "alice", Nickname: "", IP: "192.168.1.1"}, users[0])
+}
+
+func TestRedisPresenceTracker_Clear(t *testing.T) {
+ tracker, s := newTestPresenceTracker(t)
+
+ tracker.UserRenamed("alice", "", "Alice", "192.168.1.1")
+ require.NoError(t, tracker.Clear(context.Background()))
+
+ assert.False(t, s.Exists(redisKeyOnline))
+}