// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。 package service_test import ( "context" "errors" "strings" "testing" "time" roomeventsv1 "hyapp/api/proto/events/room/v1" roomv1 "hyapp/api/proto/room/v1" walletv1 "hyapp/api/proto/wallet/v1" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/outbox" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" ) // 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 // failCount 控制前 N 次投递失败,用于验证 retry_count 和后续成功。 failCount int // err 是失败时返回的错误;为空时使用 context.DeadlineExceeded。 err error // attempts 记录投递尝试次数,失败也会累计。 attempts int } // PublishOutboxEvent 记录补偿事件,模拟外部消费者投递成功。 func (p *fakeOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error { p.attempts++ if p.failCount > 0 { p.failCount-- if p.err != nil { return p.err } return context.DeadlineExceeded } 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 组装带真实 MySQL repository 的 room-service,便于测试 Room Cell 持久化语义。 func newRoomService(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.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 roomservice.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) } // TestCreateRoomDefaultsOwnerAndHostFromActor 验证 CreateRoom 身份语义收敛在 room-service。 func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-owner-default" createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 42), SeatCount: 4, Mode: "social", }) if err != nil { t.Fatalf("CreateRoom with owner/host defaults failed: %v", err) } if createResp.GetRoom().GetOwnerUserId() != 42 || createResp.GetRoom().GetHostUserId() != 42 { t.Fatalf("owner/host should default from actor, got owner=%d host=%d", createResp.GetRoom().GetOwnerUserId(), createResp.GetRoom().GetHostUserId()) } if user := findRoomUser(createResp.GetRoom(), 42); user == nil || user.GetRole() != "owner" { t.Fatalf("actor should enter room as owner presence, got %+v", createResp.GetRoom().GetOnlineUsers()) } meta, exists, err := repository.GetRoomMeta(ctx, roomID) if err != nil { t.Fatalf("GetRoomMeta failed: %v", err) } if !exists || meta.OwnerUserID != 42 || meta.HostUserID != 42 { t.Fatalf("room meta owner/host mismatch: exists=%v meta=%+v", exists, meta) } } func TestCreateRoomRejectsOwnerHostMismatchWithActor(t *testing.T) { ctx := context.Background() svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), mysqltest.NewRepository(t), &fakeSyncPublisher{}, &fakeOutboxPublisher{}) _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-owner-mismatch", 42), OwnerUserId: 99, HostUserId: 99, SeatCount: 4, Mode: "social", }) if !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("CreateRoom should reject owner/host mismatch, got %v", err) } } func TestCreateRoomRejectsIncompatibleRoomID(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room:1001", 42), SeatCount: 4, Mode: "social", }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("CreateRoom should reject incompatible room_id, got %v", err) } if _, exists, getErr := repository.GetRoomMeta(ctx, "room:1001"); getErr != nil || exists { t.Fatalf("invalid room_id must not create room meta: exists=%v err=%v", exists, getErr) } } func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) { tests := []struct { name string req *roomv1.CreateRoomRequest }{ {name: "seat_count", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-seat", 42), Mode: "social"}}, {name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 4}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), mysqltest.NewRepository(t), &fakeSyncPublisher{}, &fakeOutboxPublisher{}) _, err := svc.CreateRoom(context.Background(), test.req) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("CreateRoom should reject missing %s, got %v", test.name, err) } }) } } // TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。 func TestRoomLifecycleGiftAndGuards(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) 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 := mysqltest.NewRepository(t) 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 := mysqltest.NewRepository(t) 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 := mysqltest.NewRepository(t) 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 := mysqltest.NewRepository(t) 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 := mysqltest.NewRepository(t) 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 := mysqltest.NewRepository(t) 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, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil { t.Fatalf("ProcessPendingOutbox failed: %v", err) } if len(outboxPublisher.envelopes) == 0 { t.Fatalf("expected compensated outbox publish") } if remaining, err := repository.ListPendingOutbox(ctx, 100); err != nil { t.Fatalf("ListPendingOutbox after compensation failed: %v", err) } else if len(remaining) != 0 { t.Fatalf("expected all pending outbox records to be marked published, got %d", len(remaining)) } } func TestOutboxWorkerRetriesFailedPublishAndLaterMarksPublished(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) outboxPublisher := &fakeOutboxPublisher{failCount: 1, err: errors.New("temporary im failure")} svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher) record, err := outbox.Build("room-retry", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{ SenderUserId: 1, TargetUserId: 2, GiftId: "car", GiftCount: 1, GiftValue: 100, }) if err != nil { t.Fatalf("Build outbox failed: %v", err) } if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil { t.Fatalf("SaveOutbox failed: %v", err) } options := roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second} if err := svc.ProcessPendingOutbox(ctx, options); err != nil { t.Fatalf("first ProcessPendingOutbox should keep batch alive after publish failure: %v", err) } failed, exists := repository.OutboxRecord(record.EventID) if !exists { t.Fatalf("outbox record missing after failure") } if failed.Status != outbox.StatusPending || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") { t.Fatalf("failed publish should remain pending with retry metadata: %+v", failed) } if err := svc.ProcessPendingOutbox(ctx, options); err != nil { t.Fatalf("second ProcessPendingOutbox failed: %v", err) } published, exists := repository.OutboxRecord(record.EventID) if !exists { t.Fatalf("outbox record missing after success") } if published.Status != outbox.StatusPublished || published.LastError != "" || len(outboxPublisher.envelopes) != 1 { t.Fatalf("second publish should mark record published: record=%+v envelopes=%d", published, len(outboxPublisher.envelopes)) } } func TestSyncPublishSuccessMarksOutboxPublished(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) syncPublisher := &fakeSyncPublisher{} svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) roomID := "room-sync-published" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 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 len(syncPublisher.events) == 0 { t.Fatalf("expected sync room event") } eventID := syncPublisher.events[len(syncPublisher.events)-1].EventID record, exists := repository.OutboxRecord(eventID) if !exists { t.Fatalf("sync event outbox record missing: %s", eventID) } if record.Status != outbox.StatusPublished { t.Fatalf("sync success should best-effort mark outbox published: %+v", record) } } func TestRunOutboxWorkerExitsOnCancel(t *testing.T) { repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) svc.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{ PollInterval: 10 * time.Millisecond, BatchSize: 10, PublishTimeout: time.Second, }) }() cancel() select { case <-done: case <-time.After(time.Second): t.Fatal("outbox worker did not exit after cancel") } } func countPendingOutboxType(t *testing.T, repository roomservice.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 }