Files
gochat/internal/ws/presence_test.go
T
2026-06-04 15:44:48 +08:00

483 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}