package ws import ( "context" "fmt" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- 测试辅助函数 --- // setupPresenceTest 创建 miniredis 实例、redis.Client、BroadcastRelay 和 PresenceTracker, // 测试结束后需要调用 cleanup。 func setupPresenceTest(t *testing.T) (*miniredis.Miniredis, *redis.Client, *PresenceTracker, *mockHandler, func()) { t.Helper() mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{ Addr: mr.Addr(), }) handler := newMockHandler() relay := NewBroadcastRelay(rdb, handler) tracker := NewPresenceTracker(rdb, relay) cleanup := func() { rdb.Close() mr.Close() } return mr, rdb, tracker, handler, cleanup } // --- PresenceTracker 构造函数测试 --- func TestNewPresenceTracker(t *testing.T) { _, rdb, _, _, cleanup := setupPresenceTest(t) defer cleanup() handler := newMockHandler() relay := NewBroadcastRelay(rdb, handler) tracker := NewPresenceTracker(rdb, relay) assert.NotNil(t, tracker, "PresenceTracker 应成功创建") assert.Equal(t, rdb, tracker.rdb, "Redis 客户端应正确设置") assert.Equal(t, relay, tracker.relay, "BroadcastRelay 应正确设置") } // --- SetAgentOnline 测试 --- func TestSetAgentOnline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() agentID := uint(42) accountID := uint(1) err := tracker.SetAgentOnline(ctx, agentID, accountID) require.NoError(t, err, "SetAgentOnline 应成功执行") // 验证 Redis sorted set 中存在该成员 member := fmt.Sprintf("%d:%d", agentID, accountID) score, err := rdb.ZScore(ctx, RedisKeyPresenceAgents, member).Result() require.NoError(t, err, "应能从 sorted set 中获取成员 score") // score 应是当前 Unix 时间戳 now := float64(time.Now().Unix()) assert.InDelta(t, now, score, 2.0, "score 应接近当前时间戳") // 验证 hash 中存在该成员的状态 status, err := rdb.HGet(ctx, RedisKeyPresenceStatus, member).Result() require.NoError(t, err, "应能从 hash 中获取成员状态") assert.Equal(t, "online", status, "状态应为 online") } func TestSetAgentOnline_MultipleAgents(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置多个 agent 在线 err := tracker.SetAgentOnline(ctx, 1, 10) require.NoError(t, err) err = tracker.SetAgentOnline(ctx, 2, 10) require.NoError(t, err) err = tracker.SetAgentOnline(ctx, 3, 20) require.NoError(t, err) // 验证 sorted set 中有3个成员 count, err := rdb.ZCard(ctx, RedisKeyPresenceAgents).Result() require.NoError(t, err) assert.Equal(t, int64(3), count, "应有3个 agent 在线") } func TestSetAgentOnline_RefreshExisting(t *testing.T) { mr, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 先设置在线 err := tracker.SetAgentOnline(ctx, 5, 1) require.NoError(t, err) // 等一小段时间再刷新 mr.FastForward(2 * time.Second) // 再次设置在线(模拟心跳刷新) err = tracker.SetAgentOnline(ctx, 5, 1) require.NoError(t, err, "刷新已有 agent 应成功") // 验证 score 已更新 member := "5:1" score, err := rdb.ZScore(ctx, RedisKeyPresenceAgents, member).Result() require.NoError(t, err) now := float64(time.Now().Unix()) assert.InDelta(t, now, score, 2.0, "刷新后 score 应更新为当前时间戳") } // --- SetAgentOffline 测试 --- func TestSetAgentOffline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() agentID := uint(42) accountID := uint(1) // 先设置在线 err := tracker.SetAgentOnline(ctx, agentID, accountID) require.NoError(t, err) // 然后设置离线 err = tracker.SetAgentOffline(ctx, agentID, accountID) require.NoError(t, err, "SetAgentOffline 应成功执行") // 验证 sorted set 中不再有该成员 member := fmt.Sprintf("%d:%d", agentID, accountID) _, err = rdb.ZScore(ctx, RedisKeyPresenceAgents, member).Result() assert.Error(t, err, "离线后 sorted set 中不应有该成员") assert.Equal(t, redis.Nil, err, "应返回 redis.Nil") // 验证 hash 中不再有该成员的状态 _, err = rdb.HGet(ctx, RedisKeyPresenceStatus, member).Result() assert.Equal(t, redis.Nil, err, "hash 中不应有该成员的状态") } func TestSetAgentOffline_NotOnline(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置从未在线的 agent 为离线 — 应不会出错 err := tracker.SetAgentOffline(ctx, 99, 1) require.NoError(t, err, "对不存在的 agent 设置离线不应报错") } // --- SetAgentBusy 测试 --- func TestSetAgentBusy(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 先设置在线 err := tracker.SetAgentOnline(ctx, 10, 5) require.NoError(t, err) // 设置为 busy err = tracker.SetAgentBusy(ctx, 10, 5) require.NoError(t, err, "SetAgentBusy 应成功执行") // 验证 hash 中状态为 busy member := "10:5" status, err := rdb.HGet(ctx, RedisKeyPresenceStatus, member).Result() require.NoError(t, err) assert.Equal(t, "busy", status, "状态应为 busy") // 验证仍在 sorted set 中(busy 意味着仍在线) score, err := rdb.ZScore(ctx, RedisKeyPresenceAgents, member).Result() require.NoError(t, err) assert.Greater(t, score, 0.0, "busy agent 应仍在 sorted set 中") } // --- SetContactOnline / SetContactOffline 测试 --- func TestSetContactOnline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() contactID := uint(100) accountID := uint(2) err := tracker.SetContactOnline(ctx, contactID, accountID) require.NoError(t, err, "SetContactOnline 应成功执行") member := fmt.Sprintf("%d:%d", contactID, accountID) score, err := rdb.ZScore(ctx, RedisKeyPresenceContacts, member).Result() require.NoError(t, err) now := float64(time.Now().Unix()) assert.InDelta(t, now, score, 2.0, "score 应接近当前时间戳") status, err := rdb.HGet(ctx, RedisKeyPresenceStatus, member).Result() require.NoError(t, err) assert.Equal(t, "online", status, "状态应为 online") } func TestSetContactOffline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 先设置在线 err := tracker.SetContactOnline(ctx, 100, 2) require.NoError(t, err) // 设置离线 err = tracker.SetContactOffline(ctx, 100, 2) require.NoError(t, err, "SetContactOffline 应成功执行") // 验证离线后成员已被移除 assert.Equal(t, redis.Nil, rdb.ZScore(ctx, RedisKeyPresenceContacts, "100:2").Err(), "离线后 sorted set 中不应有该成员") assert.Equal(t, redis.Nil, rdb.HGet(ctx, RedisKeyPresenceStatus, "100:2").Err(), "hash 中不应有该成员的状态") } // --- GetOnlineAgentsForAccount 测试 --- func TestGetOnlineAgentsForAccount(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置 account=10 的3个 agent 在线 err := tracker.SetAgentOnline(ctx, 1, 10) require.NoError(t, err) err = tracker.SetAgentOnline(ctx, 2, 10) require.NoError(t, err) err = tracker.SetAgentOnline(ctx, 3, 10) require.NoError(t, err) // 设置 account=20 的1个 agent 在线 err = tracker.SetAgentOnline(ctx, 4, 20) require.NoError(t, err) // 获取 account=10 的在线 agent agents, err := tracker.GetOnlineAgentsForAccount(ctx, 10) require.NoError(t, err, "GetOnlineAgentsForAccount 应成功执行") assert.Len(t, agents, 3, "account=10 应有3个在线 agent") // 验证返回的 ID 包含 1, 2, 3 assert.Contains(t, agents, uint(1)) assert.Contains(t, agents, uint(2)) assert.Contains(t, agents, uint(3)) // 获取 account=20 的在线 agent agents, err = tracker.GetOnlineAgentsForAccount(ctx, 20) require.NoError(t, err) assert.Len(t, agents, 1, "account=20 应有1个在线 agent") assert.Contains(t, agents, uint(4)) // 获取无在线 agent 的 account agents, err = tracker.GetOnlineAgentsForAccount(ctx, 30) require.NoError(t, err) assert.Empty(t, agents, "account=30 不应有在线 agent") } func TestGetOnlineAgentsForAccount_Expired(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置 agent 在线 err := tracker.SetAgentOnline(ctx, 1, 10) require.NoError(t, err) // 直接修改 sorted set 的 score 为过去的时间戳,模拟过期 // SetAgentOnline 会设置 score 为当前时间戳,我们需要将它改为一个 // 低于阈值(time.Now().Unix() - PresenceDurationAgentSec)的值 member := "1:10" expiredScore := float64(time.Now().Unix() - PresenceDurationAgentSec - 10) err = rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{Score: expiredScore, Member: member}).Err() require.NoError(t, err, "应能修改 agent score 为过期值") // 同时更新 hash 中的状态时间戳,使状态与 score 一致 err = rdb.HSet(ctx, RedisKeyPresenceStatus, member, "online").Err() require.NoError(t, err) // 获取在线 agent — 过期的 agent 不应被返回 agents, err := tracker.GetOnlineAgentsForAccount(ctx, 10) require.NoError(t, err) assert.Empty(t, agents, "过期 agent 不应在在线列表中") } // --- GetAgentStatus 测试 --- func TestGetAgentStatus(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置 online err := tracker.SetAgentOnline(ctx, 5, 1) require.NoError(t, err) status, err := tracker.GetAgentStatus(ctx, 5, 1) require.NoError(t, err) assert.Equal(t, "online", status, "状态应为 online") // 设置 busy err = tracker.SetAgentBusy(ctx, 5, 1) require.NoError(t, err) status, err = tracker.GetAgentStatus(ctx, 5, 1) require.NoError(t, err) assert.Equal(t, "busy", status, "状态应为 busy") // 设置 offline err = tracker.SetAgentOffline(ctx, 5, 1) require.NoError(t, err) status, err = tracker.GetAgentStatus(ctx, 5, 1) require.NoError(t, err) assert.Equal(t, "offline", status, "离线 agent 状态应为 offline") } func TestGetAgentStatus_UnknownAgent(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 未知 agent — 应返回 offline status, err := tracker.GetAgentStatus(ctx, 999, 1) require.NoError(t, err) assert.Equal(t, "offline", status, "未知 agent 状态应为 offline") } // --- RefreshAgentPresence / RefreshContactPresence 测试 --- func TestRefreshAgentPresence(t *testing.T) { mr, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 先设置在线 err := tracker.SetAgentOnline(ctx, 5, 1) require.NoError(t, err) // 推进一小段时间 mr.FastForward(3 * time.Second) // 刷新(模拟心跳) err = tracker.RefreshAgentPresence(ctx, 5, 1) require.NoError(t, err, "RefreshAgentPresence 应成功执行") member := "5:1" score, err := rdb.ZScore(ctx, RedisKeyPresenceAgents, member).Result() require.NoError(t, err) now := float64(time.Now().Unix()) assert.InDelta(t, now, score, 2.0, "刷新后 score 应更新") } func TestRefreshContactPresence(t *testing.T) { mr, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 先设置在线 err := tracker.SetContactOnline(ctx, 100, 2) require.NoError(t, err) // 推进一小段时间 mr.FastForward(5 * time.Second) // 刷新 err = tracker.RefreshContactPresence(ctx, 100, 2) require.NoError(t, err, "RefreshContactPresence 应成功执行") member := "100:2" score, err := rdb.ZScore(ctx, RedisKeyPresenceContacts, member).Result() require.NoError(t, err) now := float64(time.Now().Unix()) assert.InDelta(t, now, score, 2.0, "刷新后 score 应更新") } // --- CleanupExpired 测试 --- func TestCleanupExpired(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置 agent 和 contact 在线(score 为当前时间戳) err := tracker.SetAgentOnline(ctx, 1, 10) require.NoError(t, err) err = tracker.SetContactOnline(ctx, 100, 10) require.NoError(t, err) // 直接修改 sorted set score 为过期值,模拟 agent 已过期 // agent 过期阈值: score < (time.Now().Unix() - PresenceDurationAgentSec) expiredAgentScore := float64(time.Now().Unix() - PresenceDurationAgentSec - 10) err = rdb.ZAdd(ctx, RedisKeyPresenceAgents, redis.Z{Score: expiredAgentScore, Member: "1:10"}).Err() require.NoError(t, err, "应能修改 agent score 为过期值") // contact 的 score 仍然在有效范围内(当前时间戳),不会被清理 // 执行清理 err = tracker.CleanupExpired(ctx) require.NoError(t, err, "CleanupExpired 应成功执行") // 验证过期 agent 已被清除 agentCount, err := rdb.ZCard(ctx, RedisKeyPresenceAgents).Result() require.NoError(t, err) assert.Equal(t, int64(0), agentCount, "过期 agent 应被清理") // contact 仍在有效期内,不应被清除 contactCount, err := rdb.ZCard(ctx, RedisKeyPresenceContacts).Result() require.NoError(t, err) assert.Equal(t, int64(1), contactCount, "有效期内 contact 不应被清理") // 现在让 contact 也过期 expiredContactScore := float64(time.Now().Unix() - PresenceDurationContactSec - 10) err = rdb.ZAdd(ctx, RedisKeyPresenceContacts, redis.Z{Score: expiredContactScore, Member: "100:10"}).Err() require.NoError(t, err, "应能修改 contact score 为过期值") // 再次清理 err = tracker.CleanupExpired(ctx) require.NoError(t, err) // 验证过期 contact 也被清除 contactCount, err = rdb.ZCard(ctx, RedisKeyPresenceContacts).Result() require.NoError(t, err) assert.Equal(t, int64(0), contactCount, "过期 contact 应被清理") } func TestCleanupExpired_NoExpired(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) defer cleanup() ctx := context.Background() // 设置在线 err := tracker.SetAgentOnline(ctx, 1, 10) require.NoError(t, err) // 不推进时间,直接清理 err = tracker.CleanupExpired(ctx) require.NoError(t, err, "没有过期数据时清理不应报错") } // --- presenceMember / parsePresenceMember 辅助函数测试 --- func TestPresenceMember(t *testing.T) { result := presenceMember(42, 1) assert.Equal(t, "42:1", result, "应生成正确的成员字符串") } func TestParsePresenceMember(t *testing.T) { id, acct := parsePresenceMember("42:1") assert.Equal(t, uint(42), id, "应正确解析 ID") assert.Equal(t, uint(1), acct, "应正确解析 account ID") // 无效格式 id, acct = parsePresenceMember("invalid") assert.Equal(t, uint(0), id, "无效格式应返回0") assert.Equal(t, uint(0), acct, "无效格式应返回0") // 缺少分隔符 id, acct = parsePresenceMember("42") assert.Equal(t, uint(0), id, "缺少分隔符应返回0") }