diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-06-25 07:44:23 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-06-25 07:44:23 -0700 |
| commit | bba70d0a2903ab1fff5a2e89cff97cb1f6c7f82a (patch) | |
| tree | cb8bd5cda76f287358bd7b73292ae024e756c824 | |
| parent | 4912c3a7c20d765692993f6caaa98a94f5ae28cb (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.go | 12 | ||||
| -rw-r--r-- | hotline/ban.go | 3 | ||||
| -rw-r--r-- | hotline/presence.go | 20 | ||||
| -rw-r--r-- | hotline/server.go | 32 | ||||
| -rw-r--r-- | internal/mobius/api.go | 38 | ||||
| -rw-r--r-- | internal/mobius/handlers_session.go | 33 | ||||
| -rw-r--r-- | internal/mobius/handlers_session_test.go | 100 | ||||
| -rw-r--r-- | internal/mobius/redis_presence.go | 81 | ||||
| -rw-r--r-- | internal/mobius/redis_presence_test.go | 127 |
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)) +} |