571 lines
19 KiB
Go
571 lines
19 KiB
Go
// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。
|
||
package service_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||
roomv1 "hyapp/api/proto/room/v1"
|
||
walletv1 "hyapp/api/proto/wallet/v1"
|
||
"hyapp/pkg/tencentim"
|
||
roomservice "hyapp/services/room-service/internal/room/service"
|
||
"hyapp/services/room-service/internal/router"
|
||
"hyapp/services/room-service/internal/storage/memory"
|
||
)
|
||
|
||
// fakeWallet 模拟 wallet-service 扣费成功路径。
|
||
type fakeWallet struct{}
|
||
|
||
// DebitGift 返回可预测账单回执和礼物总价值,测试只关注 room-service 状态变更。
|
||
func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||
return &walletv1.DebitGiftResponse{
|
||
BillingReceiptId: "receipt_" + req.GetCommandId(),
|
||
TotalGiftValue: int64(req.GetGiftCount()) * req.GetGiftUnitValue(),
|
||
BalanceAfter: 1000,
|
||
}, nil
|
||
}
|
||
|
||
// fakeSyncPublisher 记录 room-service 同步推给腾讯云 IM 的房间事件。
|
||
type fakeSyncPublisher struct {
|
||
// fail 为 true 时模拟腾讯云 IM PublishRoomEvent 失败。
|
||
fail bool
|
||
// events 保存已尝试同步发布的事件。
|
||
events []tencentim.RoomEvent
|
||
}
|
||
|
||
// EnsureRoomGroup 模拟腾讯云 IM 群组存在。
|
||
func (p *fakeSyncPublisher) EnsureRoomGroup(context.Context, string, int64) error {
|
||
return nil
|
||
}
|
||
|
||
// PublishRoomEvent 记录事件,并按 fail 开关返回错误。
|
||
func (p *fakeSyncPublisher) PublishRoomEvent(_ context.Context, event tencentim.RoomEvent) error {
|
||
p.events = append(p.events, event)
|
||
if p.fail {
|
||
return context.DeadlineExceeded
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// fakeOutboxPublisher 记录 outbox worker 补偿投递的事件信封。
|
||
type fakeOutboxPublisher struct {
|
||
// envelopes 保存补偿发布过的 protobuf 信封。
|
||
envelopes []*roomeventsv1.EventEnvelope
|
||
}
|
||
|
||
// PublishOutboxEvent 记录补偿事件,模拟外部消费者投递成功。
|
||
func (p *fakeOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
|
||
p.envelopes = append(p.envelopes, envelope)
|
||
return nil
|
||
}
|
||
|
||
// mutableClock 让 presence 刷新和 stale 清理测试不依赖真实时间。
|
||
type mutableClock struct {
|
||
now time.Time
|
||
}
|
||
|
||
func (c *mutableClock) Now() time.Time {
|
||
return c.now
|
||
}
|
||
|
||
// newRoomService 组装带内存 repository/directory 的 room-service,便于测试 Room Cell 语义。
|
||
func newRoomService(nodeID string, snapshotEveryN int64, directory router.Directory, repository *memory.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher) *roomservice.Service {
|
||
return roomservice.New(roomservice.Config{
|
||
NodeID: nodeID,
|
||
LeaseTTL: 10 * time.Second,
|
||
RankLimit: 20,
|
||
SnapshotEveryN: snapshotEveryN,
|
||
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
|
||
}
|
||
|
||
// newRoomServiceWithClock 允许测试直接控制房间命令时间和 stale presence 阈值。
|
||
func newRoomServiceWithClock(nodeID string, snapshotEveryN int64, directory router.Directory, repository *memory.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher, usedClock *mutableClock, staleAfter time.Duration) *roomservice.Service {
|
||
return roomservice.New(roomservice.Config{
|
||
NodeID: nodeID,
|
||
LeaseTTL: 10 * time.Second,
|
||
RankLimit: 20,
|
||
SnapshotEveryN: snapshotEveryN,
|
||
PresenceStaleAfter: staleAfter,
|
||
Clock: usedClock,
|
||
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
|
||
}
|
||
|
||
// TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。
|
||
func TestRoomLifecycleGiftAndGuards(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
outboxPublisher := &fakeOutboxPublisher{}
|
||
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
|
||
|
||
roomID := "room-1001"
|
||
|
||
// 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。
|
||
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if !createResp.GetResult().GetApplied() {
|
||
t.Fatalf("CreateRoom should apply")
|
||
}
|
||
|
||
// JoinRoom 只进入 room-service 业务 presence,不涉及腾讯云 IM 连接态。
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
// MicUp 验证 Room Cell 内麦位占用和版本变更。
|
||
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
SeatNo: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
|
||
if micResp.GetSeatNo() != 1 {
|
||
t.Fatalf("MicUp seat mismatch: got %d", micResp.GetSeatNo())
|
||
}
|
||
|
||
// SendGift 先走 fakeWallet,再更新热度和房间内本地礼物榜。
|
||
giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "rose",
|
||
GiftCount: 2,
|
||
GiftUnitValue: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("SendGift failed: %v", err)
|
||
}
|
||
|
||
if got := giftResp.GetRoomHeat(); got != 20 {
|
||
t.Fatalf("room heat mismatch: got %d want 20", got)
|
||
}
|
||
|
||
if len(giftResp.GetGiftRank()) != 1 || giftResp.GetGiftRank()[0].GetUserId() != 1 {
|
||
t.Fatalf("gift rank mismatch: %+v", giftResp.GetGiftRank())
|
||
}
|
||
|
||
if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_gift_sent" {
|
||
t.Fatalf("expected room_gift_sent sync publish, got %+v", syncPublisher.events)
|
||
}
|
||
|
||
// MuteUser 后 CheckSpeakPermission 必须拒绝外部 IM 的公屏发送请求。
|
||
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
Muted: true,
|
||
}); err != nil {
|
||
t.Fatalf("MuteUser failed: %v", err)
|
||
}
|
||
|
||
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
|
||
RoomId: roomID,
|
||
UserId: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
|
||
t.Fatalf("unexpected speak guard response: %+v", speakResp)
|
||
}
|
||
|
||
// KickUser 后 VerifyRoomPresence 必须拒绝外部 IM 的进群请求。
|
||
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
}); err != nil {
|
||
t.Fatalf("KickUser failed: %v", err)
|
||
}
|
||
|
||
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
|
||
RoomId: roomID,
|
||
UserId: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("VerifyRoomPresence failed: %v", err)
|
||
}
|
||
|
||
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
|
||
t.Fatalf("unexpected presence guard response: %+v", presenceResp)
|
||
}
|
||
}
|
||
|
||
// TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent 验证重连只刷新 presence,不重复广播进房。
|
||
func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
svc := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-repeat-join"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("first JoinRoom failed: %v", err)
|
||
}
|
||
beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined")
|
||
|
||
usedClock.now = usedClock.now.Add(5 * time.Second)
|
||
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("repeat JoinRoom failed: %v", err)
|
||
}
|
||
|
||
user := findRoomUser(joinResp.GetRoom(), 2)
|
||
if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() {
|
||
t.Fatalf("last_seen was not refreshed: user=%+v now=%d", user, usedClock.now.UnixMilli())
|
||
}
|
||
if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents {
|
||
t.Fatalf("repeat JoinRoom should not create duplicate join outbox: before=%d after=%d", beforeJoinEvents, after)
|
||
}
|
||
}
|
||
|
||
// TestLeaveRoomReleasesSeatAndBlocksGuards 验证离房同时移除 presence 和麦位占用。
|
||
func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-leave-seat"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
|
||
leaveResp, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)})
|
||
if err != nil {
|
||
t.Fatalf("LeaveRoom failed: %v", err)
|
||
}
|
||
if findRoomUser(leaveResp.GetRoom(), 2) != nil {
|
||
t.Fatalf("left user should not remain in presence: %+v", leaveResp.GetRoom().GetOnlineUsers())
|
||
}
|
||
if seat := findSeat(leaveResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
|
||
t.Fatalf("leave should release seat: %+v", seat)
|
||
}
|
||
|
||
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "not_in_room" {
|
||
t.Fatalf("unexpected speak response after leave: %+v", speakResp)
|
||
}
|
||
}
|
||
|
||
// TestRoomRecoveryAfterLeaseTakeover 验证 lease 过期后新节点通过 snapshot + command log 恢复房间。
|
||
func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
syncPublisherA := &fakeSyncPublisher{}
|
||
outboxPublisher := &fakeOutboxPublisher{}
|
||
serviceA := newRoomService("node-a", 2, directory, repository, syncPublisherA, outboxPublisher)
|
||
|
||
roomID := "room-recover"
|
||
|
||
// node-a 先创建并推进房间状态,snapshotEveryN=2 会让恢复需要结合快照和命令日志。
|
||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "rose",
|
||
GiftCount: 1,
|
||
GiftUnitValue: 10,
|
||
}); err != nil {
|
||
t.Fatalf("SendGift failed: %v", err)
|
||
}
|
||
|
||
// 强制过期模拟 node-a 故障后 Redis lease 失效。
|
||
directory.ForceExpire(roomID)
|
||
|
||
// node-b 收到命令时应先接管 lease,再恢复 Room Cell,而不是返回 room not found。
|
||
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
|
||
|
||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 3),
|
||
Role: "audience",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom on takeover node failed: %v", err)
|
||
}
|
||
|
||
if got := joinResp.GetRoom().GetHeat(); got != 10 {
|
||
t.Fatalf("recovered heat mismatch: got %d want 10", got)
|
||
}
|
||
|
||
if len(joinResp.GetRoom().GetGiftRank()) != 1 || joinResp.GetRoom().GetGiftRank()[0].GetUserId() != 1 {
|
||
t.Fatalf("recovered rank mismatch: %+v", joinResp.GetRoom().GetGiftRank())
|
||
}
|
||
|
||
lease, exists, err := directory.Lookup(ctx, roomID)
|
||
if err != nil || !exists {
|
||
t.Fatalf("Lookup failed: %v exists=%v", err, exists)
|
||
}
|
||
|
||
if lease.NodeID != "node-b" {
|
||
t.Fatalf("lease takeover failed: owner=%s", lease.NodeID)
|
||
}
|
||
}
|
||
|
||
// TestGuardRecoveryReplaysCommandsAfterSnapshot 验证无内存 Cell 的守卫查询会补回快照后的 command log。
|
||
func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
serviceA := newRoomService("node-a", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-guard-recover"
|
||
|
||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
if _, err := serviceA.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
Muted: true,
|
||
}); err != nil {
|
||
t.Fatalf("MuteUser failed: %v", err)
|
||
}
|
||
|
||
// 新服务没有内存 Cell;CheckSpeakPermission 必须从 v2 快照后回放 v3 禁言命令。
|
||
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
speakResp, err := serviceB.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
|
||
t.Fatalf("guard did not replay mute command: %+v", speakResp)
|
||
}
|
||
}
|
||
|
||
// TestSweepStalePresenceRemovesUserAndReleasesSeat 验证 stale worker 复用离房链路清理断线用户。
|
||
func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-stale"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
|
||
usedClock.now = usedClock.now.Add(30 * time.Second)
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
|
||
t.Fatalf("refresh owner JoinRoom failed: %v", err)
|
||
}
|
||
|
||
usedClock.now = usedClock.now.Add(40 * time.Second)
|
||
if err := svc.SweepStalePresence(ctx); err != nil {
|
||
t.Fatalf("SweepStalePresence failed: %v", err)
|
||
}
|
||
|
||
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("VerifyRoomPresence failed: %v", err)
|
||
}
|
||
if presenceResp.GetPresent() || presenceResp.GetReason() != "not_in_room" {
|
||
t.Fatalf("stale user should be removed: %+v", presenceResp)
|
||
}
|
||
|
||
snapshot, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom after stale sweep failed: %v", err)
|
||
}
|
||
if seat := findSeat(snapshot.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
|
||
t.Fatalf("stale sweep should release seat: %+v", seat)
|
||
}
|
||
if !hasSyncEventReason(syncPublisher.events, "stale_timeout") {
|
||
t.Fatalf("stale sweep should publish room_user_left with reason: %+v", syncPublisher.events)
|
||
}
|
||
}
|
||
|
||
// TestRoomOutboxCompensationWhenSyncPublishFails 验证同步广播失败不回滚房间状态,outbox 可补偿。
|
||
func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := memory.NewRepository()
|
||
directory := router.NewMemoryDirectory()
|
||
syncPublisher := &fakeSyncPublisher{fail: true}
|
||
outboxPublisher := &fakeOutboxPublisher{}
|
||
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
|
||
|
||
roomID := "room-outbox"
|
||
|
||
// 创建和进房先建立可送礼的基本房间态。
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
OwnerUserId: 1,
|
||
HostUserId: 1,
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "car",
|
||
GiftCount: 1,
|
||
GiftUnitValue: 100,
|
||
}); err != nil {
|
||
t.Fatalf("SendGift failed: %v", err)
|
||
}
|
||
|
||
// fakeSyncPublisher 失败后,房间事件仍应作为 pending outbox 记录保留。
|
||
pendingBefore, err := repository.ListPendingOutbox(ctx, 100)
|
||
if err != nil {
|
||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
if len(pendingBefore) == 0 {
|
||
t.Fatalf("expected pending outbox records before compensation")
|
||
}
|
||
|
||
// 补偿 worker 成功投递后 fakeOutboxPublisher 应记录事件信封。
|
||
if err := svc.ProcessPendingOutbox(ctx, 100); err != nil {
|
||
t.Fatalf("ProcessPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
if len(outboxPublisher.envelopes) == 0 {
|
||
t.Fatalf("expected compensated outbox publish")
|
||
}
|
||
}
|
||
|
||
func countPendingOutboxType(t *testing.T, repository *memory.Repository, eventType string) int {
|
||
t.Helper()
|
||
|
||
records, err := repository.ListPendingOutbox(context.Background(), 0)
|
||
if err != nil {
|
||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
count := 0
|
||
for _, record := range records {
|
||
if record.EventType == eventType {
|
||
count++
|
||
}
|
||
}
|
||
|
||
return count
|
||
}
|
||
|
||
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
||
for _, user := range snapshot.GetOnlineUsers() {
|
||
if user.GetUserId() == userID {
|
||
return user
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
|
||
for _, seat := range snapshot.GetMicSeats() {
|
||
if seat.GetSeatNo() == seatNo {
|
||
return seat
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {
|
||
for _, event := range events {
|
||
if event.EventType == "room_user_left" && event.Attributes["reason"] == reason {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|