// 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 }