// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。 package service_test import ( "context" "testing" "time" roomeventsv1 "hyapp/api/proto/events/room/v1" imv1 "hyapp/api/proto/im/v1" roomv1 "hyapp/api/proto/room/v1" walletv1 "hyapp/api/proto/wallet/v1" 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-service 的房间事件。 type fakeSyncPublisher struct { // fail 为 true 时模拟 im-service PublishRoomEvent 失败。 fail bool // events 保存已尝试同步发布的事件。 events []*imv1.RoomEvent } // PublishRoomEvent 记录事件,并按 fail 开关返回错误。 func (p *fakeSyncPublisher) PublishRoomEvent(_ context.Context, event *imv1.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 } // 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) } // 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-service WS 订阅。 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].GetEventType() != "room_gift_sent" { t.Fatalf("expected room_gift_sent sync publish, got %+v", syncPublisher.events) } // MuteUser 后 CheckSpeakPermission 必须拒绝 im-service 的公屏发送请求。 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-service 的 subscribe_room 请求。 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) } } // 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) } } // 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") } }