// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。 package service_test import ( "context" "errors" "strings" "testing" "time" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "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) { heatValue := int64(req.GetGiftCount()) * 10 return &walletv1.DebitGiftResponse{ BillingReceiptId: "receipt_" + req.GetCommandId(), TransactionId: "wtx_" + req.GetCommandId(), CoinSpent: heatValue, GiftPointAdded: heatValue, HeatValue: heatValue, PriceVersion: "test-v1", BalanceAfter: 1000 - heatValue, }, nil } type takeoverWallet struct { roomID string directory *router.MemoryDirectory takeoverSvc *roomservice.Service } func (w takeoverWallet) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { w.directory.ForceExpire(roomRouteKeyForTest(w.roomID)) if _, err := w.takeoverSvc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(w.roomID, 3)}); err != nil { return nil, err } return fakeWallet{}.DebitGift(ctx, req) } func roomRouteKeyForTest(roomID string) string { return appcode.Default + "\x00" + roomID } // 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", RoomName: "Test Room", }) 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) } if ext := createResp.GetRoom().GetRoomExt(); ext["title"] != "Test Room" || ext["cover_url"] != "hyapp://room/default-avatar" || ext["description"] != "" { t.Fatalf("room profile should be stored in RoomExt: %+v", ext) } } func TestRoomRouteKeyAlwaysIncludesDefaultAppCode(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) directory := router.NewMemoryDirectory() svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-route-default-app" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Route Room", }); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, exists, err := directory.Lookup(ctx, roomID); err != nil || exists { t.Fatalf("raw room_id route key must not exist: exists=%v err=%v", exists, err) } lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID)) if err != nil || !exists { t.Fatalf("app-scoped route key should exist: exists=%v err=%v", exists, err) } if lease.NodeID != "node-a" { t.Fatalf("route owner mismatch: %+v", lease) } } func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-owner-single-a", 42), SeatCount: 4, Mode: "social", RoomName: "Owner Room A", }); err != nil { t.Fatalf("first CreateRoom failed: %v", err) } _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-owner-single-b", 42), SeatCount: 4, Mode: "social", RoomName: "Owner Room B", }) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("second CreateRoom by same owner should conflict, got %v", err) } if meta, exists, err := repository.GetRoomMetaByOwner(ctx, 42); err != nil || !exists || meta.RoomID != "room-owner-single-a" { t.Fatalf("owner lookup should keep first room: exists=%v meta=%+v err=%v", exists, meta, err) } } func TestRoomMetaOwnerUniqueIndexMapsConflict(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) first := roomservice.RoomMeta{ RoomID: "room-owner-unique-a", OwnerUserID: 77, HostUserID: 77, SeatCount: 4, Mode: "social", Status: "active", } if err := repository.SaveRoomMeta(ctx, first); err != nil { t.Fatalf("save first room meta failed: %v", err) } second := first second.RoomID = "room-owner-unique-b" if err := repository.SaveRoomMeta(ctx, second); !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("duplicate owner should map to conflict, got %v", err) } } func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-list-region-a" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Region A Room", RoomAvatar: "https://cdn.example.com/room-a.png", VisibleRegionId: 1001, }); err != nil { t.Fatalf("CreateRoom region 1001 failed: %v", err) } usedClock.now = usedClock.now.Add(time.Second) if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta("room-list-region-b", 3), SeatCount: 4, Mode: "social", RoomName: "Region B Room", VisibleRegionId: 2002, }); err != nil { t.Fatalf("CreateRoom region 2002 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) } if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, GiftId: "rose", GiftCount: 1, }); err != nil { t.Fatalf("SendGift failed: %v", err) } listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ ViewerUserId: 99, VisibleRegionId: 1001, Tab: "hot", Limit: 20, }) if err != nil { t.Fatalf("ListRooms region 1001 failed: %v", err) } if len(listResp.GetRooms()) != 1 { t.Fatalf("region list should only expose matching rooms: %+v", listResp.GetRooms()) } item := listResp.GetRooms()[0] if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 { t.Fatalf("room list item identity mismatch: %+v", item) } if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 4 { t.Fatalf("room list projection did not track Room Cell state: %+v", item) } if item.GetTitle() != "Region A Room" || item.GetCoverUrl() != "https://cdn.example.com/room-a.png" { t.Fatalf("room list should project RoomExt title/cover_url: %+v", item) } otherRegionResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ ViewerUserId: 99, VisibleRegionId: 3003, Tab: "hot", Limit: 20, }) if err != nil { t.Fatalf("ListRooms other region failed: %v", err) } if len(otherRegionResp.GetRooms()) != 0 { t.Fatalf("region isolation should not leak rooms: %+v", otherRegionResp.GetRooms()) } } func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-my-room-owner" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 42), SeatCount: 6, Mode: "voice", RoomName: "Owner Top Card", RoomAvatar: "https://cdn.example.com/owner-room.png", VisibleRegionId: 1001, }); err != nil { t.Fatalf("CreateRoom failed: %v", err) } repository.DeleteRoomListEntry(roomID) resp, err := svc.GetMyRoom(ctx, &roomv1.GetMyRoomRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, OwnerUserId: 42, }) if err != nil { t.Fatalf("GetMyRoom failed: %v", err) } if !resp.GetHasRoom() || resp.GetRoom().GetRoomId() != roomID { t.Fatalf("my room should return owner room from rooms metadata: %+v", resp) } if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 6 || resp.GetRoom().GetVisibleRegionId() != 1001 { t.Fatalf("my room card should use snapshot/meta fields: %+v", resp.GetRoom()) } } func TestListRoomFeedsVisitedUsesJoinRoomFeed(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-visited-feed" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Visited Room", VisibleRegionId: 1001, }); 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) } resp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 2, VisibleRegionId: 1001, Tab: "visited", Limit: 20, }) if err != nil { t.Fatalf("ListRoomFeeds visited failed: %v", err) } if len(resp.GetRooms()) != 1 || resp.GetRooms()[0].GetRoomId() != roomID { t.Fatalf("visited feed should expose joined room: %+v", resp.GetRooms()) } otherUserResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 3, VisibleRegionId: 1001, Tab: "visited", Limit: 20, }) if err != nil { t.Fatalf("ListRoomFeeds visited for other user failed: %v", err) } if len(otherUserResp.GetRooms()) != 0 { t.Fatalf("visited feed must be scoped to viewer: %+v", otherUserResp.GetRooms()) } } func TestListRoomFeedsFriendFollowingUsesGatewayRelations(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) for _, room := range []struct { roomID string ownerID int64 regionID int64 title string }{ {roomID: "room-related-low", ownerID: 10002, regionID: 1001, title: "Low Relation"}, {roomID: "room-related-high", ownerID: 10003, regionID: 1001, title: "High Relation"}, {roomID: "room-related-other-region", ownerID: 10004, regionID: 2002, title: "Other Region"}, } { if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(room.roomID, room.ownerID), SeatCount: 4, Mode: "voice", RoomName: room.title, VisibleRegionId: room.regionID, }); err != nil { t.Fatalf("CreateRoom %s failed: %v", room.roomID, err) } } resp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 42, VisibleRegionId: 1001, Tab: "following", Limit: 1, RelatedUsers: []*roomv1.RoomFeedRelatedUser{ {UserId: 10002, RelationUpdatedAtMs: 8000}, {UserId: 10003, RelationUpdatedAtMs: 9000}, {UserId: 10004, RelationUpdatedAtMs: 10000}, }, }) if err != nil { t.Fatalf("ListRoomFeeds following failed: %v", err) } if len(resp.GetRooms()) != 1 || resp.GetRooms()[0].GetRoomId() != "room-related-high" { t.Fatalf("following feed should sort by user-service relation time and filter region: %+v", resp.GetRooms()) } if resp.GetNextCursor() == "" { t.Fatal("following feed should return cursor when more related rooms exist") } nextResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 42, VisibleRegionId: 1001, Tab: "following", Limit: 20, Cursor: resp.GetNextCursor(), RelatedUsers: []*roomv1.RoomFeedRelatedUser{ {UserId: 10002, RelationUpdatedAtMs: 8000}, {UserId: 10003, RelationUpdatedAtMs: 9000}, {UserId: 10004, RelationUpdatedAtMs: 10000}, }, }) if err != nil { t.Fatalf("ListRoomFeeds following next page failed: %v", err) } if len(nextResp.GetRooms()) != 1 || nextResp.GetRooms()[0].GetRoomId() != "room-related-low" { t.Fatalf("following cursor should continue after previous relation subject: %+v", nextResp.GetRooms()) } emptyResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 42, VisibleRegionId: 1001, Tab: "friend", Limit: 20, }) if err != nil { t.Fatalf("ListRoomFeeds friend with empty related users failed: %v", err) } if len(emptyResp.GetRooms()) != 0 { t.Fatalf("friend feed without user-service relations must be empty: %+v", emptyResp.GetRooms()) } } func TestListRoomsRejectsUserFeedTabs(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) for _, tab := range []string{"visited", "friend", "following", "me"} { _, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, ViewerUserId: 2, VisibleRegionId: 1001, Tab: tab, Limit: 20, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("ListRooms must reject %q tab: %v", tab, err) } } } func TestGetCurrentRoomReturnsRecoverablePresence(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-current-presence" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Current Room", }); 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) } micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("MicUp failed: %v", err) } current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, UserId: 2, }) if err != nil { t.Fatalf("GetCurrentRoom failed: %v", err) } if !current.GetHasCurrentRoom() || current.GetRoomId() != roomID || current.GetRole() != "audience" { t.Fatalf("current room response mismatch: %+v", current) } if current.GetPublishState() != "pending_publish" || current.GetMicSessionId() != micResp.GetMicSessionId() || !current.GetNeedRtcToken() || !current.GetNeedJoinImGroup() { t.Fatalf("current mic restore fields mismatch: %+v", current) } } func TestGetCurrentRoomReturnsOwnerAfterCreate(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-current-owner-after-create" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Current Room", }); err != nil { t.Fatalf("CreateRoom failed: %v", err) } current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, UserId: 1, }) if err != nil { t.Fatalf("GetCurrentRoom failed: %v", err) } if !current.GetHasCurrentRoom() || current.GetRoomId() != roomID || current.GetRole() != "owner" || !current.GetNeedJoinImGroup() { t.Fatalf("owner should recover room immediately after CreateRoom: %+v", current) } if current.GetNeedRtcToken() || current.GetPublishState() != "" || current.GetMicSessionId() != "" { t.Fatalf("owner not on mic should not require RTC restore: %+v", current) } } func TestGetCurrentRoomReturnsEmptyAfterLeave(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-current-left" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Current Room", }); 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.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { t.Fatalf("LeaveRoom failed: %v", err) } current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, UserId: 2, }) if err != nil { t.Fatalf("GetCurrentRoom failed: %v", err) } if current.GetHasCurrentRoom() || current.GetRoomId() != "" { t.Fatalf("left user should not have current room: %+v", current) } } func TestGetRoomSnapshotRequiresViewerPresence(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-snapshot-read" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Snapshot Room", }); err != nil { t.Fatalf("CreateRoom failed: %v", err) } if _, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, RoomId: roomID, ViewerUserId: 2, }); !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("non-presence viewer should not read full snapshot, got %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { t.Fatalf("JoinRoom failed: %v", err) } resp, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, RoomId: roomID, ViewerUserId: 2, }) if err != nil { t.Fatalf("GetRoomSnapshot failed: %v", err) } if resp.GetRoom().GetRoomId() != roomID || resp.GetRoom().GetVersion() == 0 || findRoomUser(resp.GetRoom(), 2) == nil { t.Fatalf("snapshot response mismatch: %+v", resp.GetRoom()) } } func TestCreateRoomRejectsInvalidRoomID(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", RoomName: "Test Room", }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("CreateRoom should reject invalid 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", RoomName: "Test Room"}}, {name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 4, RoomName: "Test Room"}}, {name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 4, Mode: "social"}}, } 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }) 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, }) 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) } if got := countPendingMicDownReason(t, repository, "kick"); got != 1 { t.Fatalf("KickUser should write one RoomMicChanged/down reason=kick, got %d", got) } } func TestRoomManagementPermissionsAndHostFlow(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-management-flow" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); 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.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { t.Fatalf("JoinRoom user3 failed: %v", err) } if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { t.Fatalf("MicUp user2 failed: %v", err) } _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}) if !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("audience should not mic down others, got %v", err) } adminResp, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3, Enabled: true, }) if err != nil { t.Fatalf("SetRoomAdmin failed: %v", err) } if !containsInt64(adminResp.GetRoom().GetAdminUserIds(), 3) { t.Fatalf("admin ids should contain user3: %+v", adminResp.GetRoom().GetAdminUserIds()) } if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}); err != nil { t.Fatalf("admin MicDown failed: %v", err) } hostResp, err := svc.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, }) if err != nil { t.Fatalf("TransferRoomHost failed: %v", err) } if hostResp.GetRoom().GetHostUserId() != 2 || !containsInt64(hostResp.GetRoom().GetAdminUserIds(), 2) { t.Fatalf("host transfer should update host and admin set: %+v", hostResp.GetRoom()) } _, err = svc.MuteUser(ctx, &roomv1.MuteUserRequest{ Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2, Muted: true, }) if !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("admin should not mute current host, got %v", err) } if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 2), Enabled: false}); err != nil { t.Fatalf("host SetChatEnabled failed: %v", err) } speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 1}) if err != nil { t.Fatalf("CheckSpeakPermission failed: %v", err) } if speakResp.GetAllowed() || speakResp.GetReason() != "chat_disabled" { t.Fatalf("chat disabled should block all users: %+v", speakResp) } } func TestMicPublishingConfirmAndStaleEvents(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-publish-confirm" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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) } micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("MicUp failed: %v", err) } seat := findSeat(micResp.GetRoom(), 1) if seat == nil || seat.GetUserId() != 2 || seat.GetPublishState() != "pending_publish" || seat.GetMicSessionId() == "" { t.Fatalf("MicUp should create pending_publish session: seat=%+v", seat) } if micResp.GetMicSessionId() != seat.GetMicSessionId() || micResp.GetPublishDeadlineMs() != seat.GetPublishDeadlineMs() { t.Fatalf("MicUp response should expose current mic session: resp=%+v seat=%+v", micResp, seat) } if seat.GetLastPublishEventTimeMs() != 0 { t.Fatalf("new pending session should not pre-fill publish event time: seat=%+v", seat) } confirmEventTime := usedClock.now.Add(-time.Second).UnixMilli() confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), MicSessionId: micResp.GetMicSessionId(), RoomVersion: micResp.GetResult().GetRoomVersion(), EventTimeMs: confirmEventTime, Source: "client", }) if err != nil { t.Fatalf("ConfirmMicPublishing failed: %v", err) } seat = findSeat(confirmResp.GetRoom(), 1) if seat == nil || seat.GetPublishState() != "publishing" || seat.GetLastPublishEventTimeMs() != confirmEventTime { t.Fatalf("confirm should move seat to publishing: seat=%+v", seat) } staleResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), MicSessionId: micResp.GetMicSessionId(), RoomVersion: micResp.GetResult().GetRoomVersion(), EventTimeMs: confirmEventTime, Source: "rtc_webhook", }) if err != nil { t.Fatalf("stale ConfirmMicPublishing should be ignored without error: %v", err) } if staleResp.GetResult().GetApplied() { t.Fatalf("stale confirmation must not advance room version") } } func TestAudienceCanChangeOwnMicSeat(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-self-change-mic" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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 user2 failed: %v", err) } changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), TargetUserId: 2, SeatNo: 3, }) if err != nil { t.Fatalf("audience should be able to change own mic seat: %v", err) } if from := findSeat(changeResp.GetRoom(), 1); from == nil || from.GetUserId() != 0 { t.Fatalf("source seat should be empty after switch: %+v", from) } if to := findSeat(changeResp.GetRoom(), 3); to == nil || to.GetUserId() != 2 { t.Fatalf("target seat should contain user2 after switch: %+v", to) } } func TestApplyRTCEventConfirmsAudioAndClearsMicOnStopOrExit(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} syncPublisher := &fakeSyncPublisher{} svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-rtc-event" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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) } micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("MicUp user2 failed: %v", err) } startEventTime := usedClock.now.Add(time.Second).UnixMilli() startResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), TargetUserId: 2, EventType: "audio_started", EventTimeMs: startEventTime, Source: "tencent_rtc_callback", ExternalEventId: "rtc_evt_start", }) if err != nil { t.Fatalf("ApplyRTCEvent audio_started failed: %v", err) } seat := findSeat(startResp.GetRoom(), 1) if seat == nil || seat.GetPublishState() != "publishing" || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetLastPublishEventTimeMs() != startEventTime { t.Fatalf("RTC audio_started should confirm current mic session: seat=%+v", seat) } stopResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), TargetUserId: 2, EventType: "audio_stopped", EventTimeMs: usedClock.now.Add(2 * time.Second).UnixMilli(), Reason: "rtc_audio_stopped:0", Source: "tencent_rtc_callback", }) if err != nil { t.Fatalf("ApplyRTCEvent audio_stopped failed: %v", err) } if seat := findSeat(stopResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { t.Fatalf("RTC audio_stopped should release mic seat: seat=%+v", seat) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { t.Fatalf("JoinRoom user3 failed: %v", err) } if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 3), SeatNo: 2}); err != nil { t.Fatalf("MicUp user3 failed: %v", err) } exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 3, EventType: "room_exited", EventTimeMs: usedClock.now.Add(3 * time.Second).UnixMilli(), Reason: "rtc_room_exited:2", Source: "tencent_rtc_callback", }) if err != nil { t.Fatalf("ApplyRTCEvent room_exited failed: %v", err) } if user := findRoomUser(exitResp.GetRoom(), 3); user == nil { t.Fatalf("RTC room_exited must not remove business presence") } if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 0 { t.Fatalf("RTC room_exited should release occupied mic: seat=%+v", seat) } if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_mic_down" { t.Fatalf("RTC room_exited should publish room_mic_down: %+v", syncPublisher.events) } } func TestOldRTCRoomExitedDoesNotClearRejoinedUserOrNewMicSession(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-rtc-exit-replay" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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) } oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("first MicUp failed: %v", err) } oldExitEventTime := usedClock.now.Add(time.Second).UnixMilli() usedClock.now = usedClock.now.Add(2 * time.Second) if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { t.Fatalf("LeaveRoom failed: %v", err) } usedClock.now = usedClock.now.Add(2 * time.Second) if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { t.Fatalf("rejoin user2 failed: %v", err) } newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2}) if err != nil { t.Fatalf("second MicUp failed: %v", err) } if newMic.GetMicSessionId() == oldMic.GetMicSessionId() { t.Fatalf("rejoined MicUp must create new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId()) } exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), TargetUserId: 2, EventType: "room_exited", EventTimeMs: oldExitEventTime, Reason: "rtc_room_exited:old", Source: "tencent_rtc_callback", ExternalEventId: "rtc_evt_old_exit", }) if err != nil { t.Fatalf("old room_exited should be ignored without error: %v", err) } if exitResp.GetResult().GetApplied() { t.Fatalf("old room_exited should be a no-op: %+v", exitResp.GetResult()) } if user := findRoomUser(exitResp.GetRoom(), 2); user == nil { t.Fatalf("old room_exited must not remove rejoined user") } if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() { t.Fatalf("old room_exited must not clear new mic session: seat=%+v new=%+v old=%+v", seat, newMic, oldMic) } } func TestRTCRoomExitedRecoveryKeepsBusinessPresence(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) directory := router.NewMemoryDirectory() usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} serviceA := newRoomServiceWithClock("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-rtc-exit-recovery-presence" if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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 user2 failed: %v", err) } if _, err := serviceA.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { t.Fatalf("MicUp user2 failed: %v", err) } usedClock.now = usedClock.now.Add(time.Second) exitResp, err := serviceA.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), TargetUserId: 2, EventType: "room_exited", EventTimeMs: usedClock.now.UnixMilli(), Reason: "rtc_room_exited:recovery", Source: "tencent_rtc_callback", }) if err != nil { t.Fatalf("ApplyRTCEvent room_exited failed: %v", err) } if user := findRoomUser(exitResp.GetRoom(), 2); user == nil { t.Fatalf("real-time room_exited must keep business presence") } if seat := findSeat(exitResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 { t.Fatalf("real-time room_exited should release mic seat: seat=%+v", seat) } // snapshotEveryN=100 保证 room_exited 后没有最新快照;node-b 必须通过 command log replay 恢复同一语义。 directory.ForceExpire(roomRouteKeyForTest(roomID)) serviceB := newRoomServiceWithClock("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) recoveredResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}) if err != nil { t.Fatalf("JoinRoom on recovered service failed: %v", err) } if user := findRoomUser(recoveredResp.GetRoom(), 2); user == nil { t.Fatalf("replayed room_exited must keep business presence") } if seat := findSeat(recoveredResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { t.Fatalf("replayed room_exited should keep released mic seat: seat=%+v", seat) } } func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} directory := router.NewMemoryDirectory() syncPublisher := &fakeSyncPublisher{} svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-publish-timeout" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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) } micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("MicUp failed: %v", err) } usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs()) // 真实配置里 lease_ttl 可能短于 publish timeout;worker 必须能在无其他 owner 时续租并执行超时下麦。 directory.ForceExpire(roomRouteKeyForTest(roomID)) if err := svc.SweepMicPublishTimeouts(ctx); err != nil { t.Fatalf("SweepMicPublishTimeouts failed: %v", err) } joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}) if err != nil { t.Fatalf("JoinRoom refresh failed: %v", err) } if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { t.Fatalf("publish timeout should release seat and session: %+v", seat) } if len(syncPublisher.events) == 0 { t.Fatalf("expected timeout mic_down event") } last := syncPublisher.events[len(syncPublisher.events)-1] if last.EventType != "room_mic_down" || last.Attributes["reason"] != "publish_timeout" || last.Attributes["mic_session_id"] != micResp.GetMicSessionId() { t.Fatalf("timeout should publish mic_down with session reason: %+v", last) } } func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-old-session" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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) } oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("first MicUp failed: %v", err) } if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { t.Fatalf("MicDown failed: %v", err) } usedClock.now = usedClock.now.Add(time.Second) newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("second MicUp failed: %v", err) } if newMic.GetMicSessionId() == oldMic.GetMicSessionId() { t.Fatalf("new MicUp must create a new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId()) } usedClock.now = time.UnixMilli(oldMic.GetPublishDeadlineMs()) if err := svc.SweepMicPublishTimeouts(ctx); err != nil { t.Fatalf("old timeout sweep failed: %v", err) } staleConfirm, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), MicSessionId: oldMic.GetMicSessionId(), RoomVersion: oldMic.GetResult().GetRoomVersion(), EventTimeMs: usedClock.now.Add(time.Second).UnixMilli(), Source: "rtc_webhook", }) if err != nil { t.Fatalf("old session confirm should be ignored without error: %v", err) } seat := findSeat(staleConfirm.GetRoom(), 1) if seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() || seat.GetPublishState() != "pending_publish" { t.Fatalf("old session event must not clear new MicUp: seat=%+v new=%+v old=%+v", seat, newMic, oldMic) } } func TestMicSeatLockAndNoopIdempotency(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) syncPublisher := &fakeSyncPublisher{fail: true} svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) roomID := "room-mic-lock" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 3, Mode: "social", RoomName: "Test Room"}); 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) } lockResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 2, Locked: true, }) if err != nil { t.Fatalf("SetMicSeatLock failed: %v", err) } if seat := findSeat(lockResp.GetRoom(), 2); seat == nil || !seat.GetLocked() { t.Fatalf("seat should be locked: %+v", seat) } if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 { t.Fatalf("lock should write one outbox event, got %d", got) } repeatResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 2, Locked: true, }) if err != nil { t.Fatalf("repeat SetMicSeatLock failed: %v", err) } if repeatResp.GetResult().GetApplied() { t.Fatalf("repeat lock should be a no-op") } if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 { t.Fatalf("repeat lock should not write duplicate outbox, got %d", got) } _, err = svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2}) if !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("locked seat MicUp should be denied, got %v", err) } if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { t.Fatalf("MicUp unlocked seat failed: %v", err) } _, err = svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 1, Locked: true}) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("locking occupied seat should conflict, got %v", err) } } func TestKickUnbanAndAdminRemoval(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-ban-unban" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); 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.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { t.Fatalf("JoinRoom user3 failed: %v", err) } if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil { t.Fatalf("SetRoomAdmin user2 failed: %v", err) } if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3, Enabled: true}); err != nil { t.Fatalf("SetRoomAdmin user3 failed: %v", err) } kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}) if err != nil { t.Fatalf("KickUser failed: %v", err) } if !containsInt64(kickResp.GetRoom().GetBanUserIds(), 2) || containsInt64(kickResp.GetRoom().GetAdminUserIds(), 2) { t.Fatalf("kick should ban user2 and remove admin: %+v", kickResp.GetRoom()) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("banned user should not join, got %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("banned user should fail presence guard: %+v", presenceResp) } _, err = svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}) if !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("admin should not unban, got %v", err) } unbanResp, err := svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}) if err != nil { t.Fatalf("owner UnbanUser failed: %v", err) } if containsInt64(unbanResp.GetRoom().GetBanUserIds(), 2) || containsInt64(unbanResp.GetRoom().GetAdminUserIds(), 2) { t.Fatalf("unban should not restore ban or admin state: %+v", unbanResp.GetRoom()) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { t.Fatalf("unbanned user should join again: %v", err) } } func TestJoinRoomRejectsClosedRoom(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-closed" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } repository.SetRoomStatus(roomID, "closed") if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.RoomClosed) { t.Fatalf("closed room should reject JoinRoom with ROOM_CLOSED, got %v", err) } presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 1}) if err != nil { t.Fatalf("VerifyRoomPresence failed: %v", err) } if presenceResp.GetPresent() || presenceResp.GetReason() != "room_closed" { t.Fatalf("closed room should fail presence guard: %+v", presenceResp) } } func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) syncPublisher := &fakeSyncPublisher{} svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) roomID := "room-close-command" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Close Room"}); 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) } closeResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Reason: "owner_closed"}) if err != nil { t.Fatalf("CloseRoom failed: %v", err) } if closeResp.GetRoom().GetStatus() != "closed" || len(closeResp.GetRoom().GetOnlineUsers()) != 0 { t.Fatalf("closed room should clear presence: %+v", closeResp.GetRoom()) } if seat := findSeat(closeResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { t.Fatalf("closed room should release seats: %+v", seat) } if got := countPendingMicDownReason(t, repository, "room_closed"); got != 1 { t.Fatalf("CloseRoom should write one RoomMicChanged/down reason=room_closed, got %d", got) } meta, exists, err := repository.GetRoomMeta(ctx, roomID) if err != nil || !exists || meta.Status != "closed" { t.Fatalf("room meta should be closed: exists=%v meta=%+v err=%v", exists, meta, err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.RoomClosed) { t.Fatalf("closed room should reject JoinRoom, got %v", err) } if !hasSyncEventType(syncPublisher.events, "room_closed") { t.Fatalf("CloseRoom should publish room_closed event: %+v", syncPublisher.events) } } func TestDrainingRejectsNewRoomCommands(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-draining" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Drain Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } svc.MarkDraining() if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("draining service should reject new JoinRoom, got %v", err) } if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 4, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("draining service should reject new CreateRoom, got %v", err) } } func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-command-conflict" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil { t.Fatalf("CreateRoom failed: %v", err) } firstMeta := roomservice.NewRequestMeta(roomID, 1) firstMeta.CommandId = "cmd-chat-disable-fixed" firstMeta.RequestId = "req-first" if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: firstMeta, Enabled: false}); err != nil { t.Fatalf("first SetChatEnabled failed: %v", err) } retryMeta := roomservice.NewRequestMeta(roomID, 1) retryMeta.CommandId = firstMeta.GetCommandId() retryMeta.RequestId = "req-retry" retryMeta.SessionId = "different-session" retryMeta.SentAtMs = firstMeta.GetSentAtMs() + 1000 retryResp, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: false}) if err != nil { t.Fatalf("same command with different trace fields should be idempotent: %v", err) } if retryResp.GetResult().GetApplied() { t.Fatalf("idempotent retry should not apply again") } _, err = svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: true}) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("same command_id with different business payload should conflict, got %v", err) } } func TestRoomManagementRecoveryReplay(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) directory := router.NewMemoryDirectory() serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) roomID := "room-management-replay" if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); 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 user2 failed: %v", err) } if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { t.Fatalf("JoinRoom user3 failed: %v", err) } if _, err := serviceA.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 4, Locked: true}); err != nil { t.Fatalf("SetMicSeatLock failed: %v", err) } if _, err := serviceA.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Enabled: false}); err != nil { t.Fatalf("SetChatEnabled failed: %v", err) } if _, err := serviceA.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil { t.Fatalf("SetRoomAdmin failed: %v", err) } if _, err := serviceA.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}); err != nil { t.Fatalf("TransferRoomHost failed: %v", err) } if _, err := serviceA.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil { t.Fatalf("KickUser failed: %v", err) } if _, err := serviceA.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil { t.Fatalf("UnbanUser failed: %v", err) } directory.ForceExpire(roomRouteKeyForTest(roomID)) serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) if err != nil { t.Fatalf("JoinRoom on recovered service failed: %v", err) } snapshot := joinResp.GetRoom() if snapshot.GetHostUserId() != 2 || !containsInt64(snapshot.GetAdminUserIds(), 2) { t.Fatalf("host/admin state did not replay: %+v", snapshot) } if snapshot.GetChatEnabled() { t.Fatalf("chat_enabled should replay as false") } if seat := findSeat(snapshot, 4); seat == nil || !seat.GetLocked() { t.Fatalf("locked seat did not replay: %+v", seat) } if containsInt64(snapshot.GetBanUserIds(), 3) { t.Fatalf("unban should replay after kick: %+v", snapshot.GetBanUserIds()) } } // 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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) } } func TestRoomHeartbeatRefreshesExistingPresenceOnly(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-heartbeat" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room", }); 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) } beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined") usedClock.now = usedClock.now.Add(30 * time.Second) heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}) if err != nil { t.Fatalf("RoomHeartbeat failed: %v", err) } user := findRoomUser(heartbeatResp.GetRoom(), 2) if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() { t.Fatalf("heartbeat should refresh last_seen only: user=%+v now=%d", user, usedClock.now.UnixMilli()) } if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents { t.Fatalf("heartbeat must not create join outbox: before=%d after=%d", beforeJoinEvents, after) } if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.NotFound) { t.Fatalf("heartbeat must not create missing presence, got %v", err) } } // 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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) } if got := countPendingMicDownReason(t, repository, "leave"); got != 1 { t.Fatalf("LeaveRoom should write one RoomMicChanged/down reason=leave, got %d", got) } 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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, }); err != nil { t.Fatalf("SendGift failed: %v", err) } // 强制过期模拟 node-a 故障后 Redis lease 失效。 directory.ForceExpire(roomRouteKeyForTest(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, roomRouteKeyForTest(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) } } func TestLeaseFencingRejectsOldOwnerAfterSlowWalletCommand(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) directory := router.NewMemoryDirectory() syncPublisherA := &fakeSyncPublisher{} outboxPublisher := &fakeOutboxPublisher{} serviceB := newRoomService("node-b", 1, directory, repository, &fakeSyncPublisher{}, outboxPublisher) serviceA := roomservice.New(roomservice.Config{ NodeID: "node-a", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, directory, repository, takeoverWallet{ roomID: "room-lease-fencing", directory: directory, takeoverSvc: serviceB, }, syncPublisherA, outboxPublisher) roomID := "room-lease-fencing" if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Fence Room"}); 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) } _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: roomservice.NewRequestMeta(roomID, 2), TargetUserId: 1, GiftId: "rose", GiftCount: 1, }) if !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("old owner should fail fencing before room commit, got %v", err) } joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) if err != nil { t.Fatalf("new owner should keep serving room: %v", err) } if joinResp.GetRoom().GetHeat() != 0 { t.Fatalf("fenced SendGift must not update room heat: %+v", joinResp.GetRoom()) } } // 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil { t.Fatalf("RoomHeartbeat owner before stale sweep failed: %v", err) } 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) } } func TestSweepStalePresenceSkipsRoomAfterLeaseTakeover(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) directory := router.NewMemoryDirectory() usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-stale-lease-takeover" if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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 user2 failed: %v", err) } usedClock.now = usedClock.now.Add(2 * time.Minute) directory.ForceExpire(roomRouteKeyForTest(roomID)) if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { t.Fatalf("JoinRoom takeover failed: %v", err) } if err := serviceA.SweepStalePresence(ctx); err != nil { t.Fatalf("old owner stale sweep failed: %v", err) } joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) if err != nil { t.Fatalf("JoinRoom on current owner failed: %v", err) } if user := findRoomUser(joinResp.GetRoom(), 2); user == nil { t.Fatalf("old owner stale sweep must not remove users after takeover: %+v", joinResp.GetRoom().GetOnlineUsers()) } } func TestSweepMicPublishTimeoutSkipsRoomAfterLeaseTakeover(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) directory := router.NewMemoryDirectory() usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) roomID := "room-mic-timeout-lease-takeover" if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); 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 user2 failed: %v", err) } micResp, err := serviceA.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) if err != nil { t.Fatalf("MicUp failed: %v", err) } usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs()) directory.ForceExpire(roomRouteKeyForTest(roomID)) if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { t.Fatalf("JoinRoom takeover failed: %v", err) } if err := serviceA.SweepMicPublishTimeouts(ctx); err != nil { t.Fatalf("old owner mic timeout sweep failed: %v", err) } joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) if err != nil { t.Fatalf("JoinRoom on current owner failed: %v", err) } if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetPublishState() != "pending_publish" { t.Fatalf("old owner mic timeout sweep must not clear current owner's seat: seat=%+v mic=%+v", seat, micResp) } } // 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), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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, }); 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 delivered, got %d", len(remaining)) } } func TestOutboxWorkerRetriesFailedPublishAndLaterMarksDelivered(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.StatusRetryable || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") { t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed) } if err := svc.ProcessPendingOutbox(ctx, options); err != nil { t.Fatalf("second ProcessPendingOutbox failed: %v", err) } delivered, exists := repository.OutboxRecord(record.EventID) if !exists { t.Fatalf("outbox record missing after success") } if delivered.Status != outbox.StatusDelivered || delivered.LastError != "" || len(outboxPublisher.envelopes) != 1 { t.Fatalf("second publish should mark record delivered: record=%+v envelopes=%d", delivered, len(outboxPublisher.envelopes)) } } func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) record, err := outbox.Build("room-claim", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{ SenderUserId: 1, TargetUserId: 2, GiftId: "rose", GiftCount: 1, GiftValue: 10, }) 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) } claimedA, err := repository.ClaimPendingOutbox(ctx, "worker-a", 10, time.Now().Add(time.Minute).UnixMilli()) if err != nil { t.Fatalf("worker-a claim failed: %v", err) } if len(claimedA) != 1 || claimedA[0].Status != outbox.StatusDelivering || claimedA[0].WorkerID != "worker-a" { t.Fatalf("worker-a should claim one delivering record: %+v", claimedA) } claimedB, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli()) if err != nil { t.Fatalf("worker-b claim failed: %v", err) } if len(claimedB) != 0 { t.Fatalf("worker-b should not claim worker-a locked record: %+v", claimedB) } if err := repository.MarkOutboxFailed(ctx, record.EventID, "temporary failure"); err != nil { t.Fatalf("MarkOutboxFailed failed: %v", err) } claimedRetry, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli()) if err != nil { t.Fatalf("retry claim failed: %v", err) } if len(claimedRetry) != 1 || claimedRetry[0].Status != outbox.StatusDelivering || claimedRetry[0].RetryCount != 1 { t.Fatalf("retryable record should be claimable again: %+v", claimedRetry) } } func TestSyncPublishSuccessMarksOutboxDelivered(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-delivered" if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room", }); 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.StatusDelivered { t.Fatalf("sync success should best-effort mark outbox delivered: %+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 countPendingMicDownReason(t *testing.T, repository roomservice.Repository, reason 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 != "RoomMicChanged" || record.Envelope == nil { continue } var body roomeventsv1.RoomMicChanged if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil { t.Fatalf("decode RoomMicChanged failed: %v", err) } if body.GetAction() == "down" && body.GetReason() == reason { 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 containsInt64(values []int64, target int64) bool { for _, value := range values { if value == target { return true } } return false } 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 } func hasSyncEventType(events []tencentim.RoomEvent, eventType string) bool { for _, event := range events { if event.EventType == eventType { return true } } return false }