2026-05-12 19:34:47 +08:00

2509 lines
99 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。
package service_test
import (
"context"
"errors"
"fmt"
"slices"
"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: 10,
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: 10,
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: 10,
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: 10,
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: 10,
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: 10,
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: 10,
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() != 10 {
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: 10,
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() != 10 || 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: 10,
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: 10,
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: 10,
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: 10,
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: 10,
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: 10,
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: 10,
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: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 10, RoomName: "Test Room"}},
{name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 10, 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)
}
})
}
}
func TestCreateRoomUsesDefaultSeatConfig(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-default-seat-config", 42),
Mode: "social",
RoomName: "Default Seat Room",
})
if err != nil {
t.Fatalf("CreateRoom without seat_count should use default config: %v", err)
}
if got := len(resp.GetRoom().GetMicSeats()); got != 15 {
t.Fatalf("default room seat count mismatch: got %d", got)
}
}
func TestCreateRoomRejectsDisallowedSeatCount(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
for _, seatCount := range []int32{8, 12, 31, -1} {
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(fmt.Sprintf("room-disallowed-seat-%d", seatCount), 42),
SeatCount: seatCount,
Mode: "social",
RoomName: "Bad Seat Room",
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("CreateRoom should reject seat_count=%d, got %v", seatCount, err)
}
}
}
func TestUpdateRoomProfileUpdatesProjectionAndExpandsSeats(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
syncPublisher := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, outboxPublisher)
roomID := "room-profile-update"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 10,
Mode: "voice",
RoomName: "Old Room",
RoomAvatar: "https://cdn.example.com/old.png",
RoomDescription: "old desc",
VisibleRegionId: 1001,
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
resp, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
RoomName: new(" New Room "),
RoomAvatar: new(" https://cdn.example.com/new.png "),
RoomDescription: new(" new desc "),
SeatCount: int32Ptr(20),
})
if err != nil {
t.Fatalf("UpdateRoomProfile failed: %v", err)
}
snapshot := resp.GetRoom()
if !resp.GetResult().GetApplied() || snapshot.GetVersion() != 2 {
t.Fatalf("profile update should advance room version: %+v", resp.GetResult())
}
if got := len(snapshot.GetMicSeats()); got != 20 {
t.Fatalf("expanded seat count mismatch: got %d", got)
}
ext := snapshot.GetRoomExt()
if ext["title"] != "New Room" || ext["cover_url"] != "https://cdn.example.com/new.png" || ext["description"] != "new desc" {
t.Fatalf("room ext mismatch after profile update: %+v", ext)
}
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
if err != nil || !exists || meta.SeatCount != 20 {
t.Fatalf("room meta seat_count should update: exists=%v meta=%+v err=%v", exists, meta, err)
}
listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
ViewerUserId: 99,
VisibleRegionId: 1001,
Tab: "hot",
Limit: 20,
})
if err != nil {
t.Fatalf("ListRooms failed: %v", err)
}
if len(listResp.GetRooms()) != 1 || listResp.GetRooms()[0].GetTitle() != "New Room" || listResp.GetRooms()[0].GetSeatCount() != 20 {
t.Fatalf("room list projection should reflect profile update: %+v", listResp.GetRooms())
}
if len(outboxPublisher.envelopes) != 1 || outboxPublisher.envelopes[0].GetEventType() != "RoomProfileUpdated" {
t.Fatalf("profile update should publish one RoomProfileUpdated outbox envelope: %+v", outboxPublisher.envelopes)
}
if len(syncPublisher.events) != 1 || syncPublisher.events[0].EventType != "room_profile_updated" || syncPublisher.events[0].Attributes["seat_count"] != "20" {
t.Fatalf("profile update should publish room_profile_updated: %+v", syncPublisher.events)
}
}
func TestUpdateRoomProfileRejectsInvalidSeatCount(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-profile-invalid-seat"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 10,
Mode: "voice",
RoomName: "Seat Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
_, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: int32Ptr(12),
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("UpdateRoomProfile should reject disallowed seat count, got %v", err)
}
}
func TestUpdateRoomProfileRejectsShrinkWhenRemovedSeatsBusy(t *testing.T) {
t.Run("occupied", func(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-shrink-occupied"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink 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: 20}); err != nil {
t.Fatalf("MicUp failed: %v", err)
}
_, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: int32Ptr(10),
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("shrinking occupied high seat should conflict, got %v", err)
}
})
t.Run("locked", func(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-shrink-locked"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 20, Locked: true}); err != nil {
t.Fatalf("SetMicSeatLock failed: %v", err)
}
_, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: int32Ptr(10),
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("shrinking locked high seat should conflict, got %v", err)
}
})
}
func TestUpdateRoomProfileRecoveryReplay(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-profile-replay"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Old Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
RoomName: new("Replay Room"),
RoomDescription: new("replayed desc"),
SeatCount: int32Ptr(20),
}); err != nil {
t.Fatalf("UpdateRoomProfile 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, 2)})
if err != nil {
t.Fatalf("JoinRoom on recovered service failed: %v", err)
}
snapshot := joinResp.GetRoom()
if len(snapshot.GetMicSeats()) != 20 || snapshot.GetRoomExt()["title"] != "Replay Room" || snapshot.GetRoomExt()["description"] != "replayed desc" {
t.Fatalf("recovered profile state mismatch: seats=%d ext=%+v", len(snapshot.GetMicSeats()), snapshot.GetRoomExt())
}
}
// 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: 10,
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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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 timeoutworker 必须能在无其他 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10, 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: 10,
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: 10,
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: 10,
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: 10,
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: 10, 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: 10,
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)
}
// 新服务没有内存 CellCheckSpeakPermission 必须从 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: 10,
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: 10, 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: 10, 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: 10,
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, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond, MaxRetryCount: 3}
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 || failed.NextRetryAtMS <= time.Now().Add(-time.Second).UnixMilli() || !strings.Contains(failed.LastError, "temporary im failure") {
t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed)
}
time.Sleep(2 * time.Millisecond)
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.MarkOutboxRetryable(ctx, record.EventID, "temporary failure", time.Now().UnixMilli()); err != nil {
t.Fatalf("MarkOutboxRetryable 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 TestOutboxWorkerMarksDeadLetterAfterRetryLimit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
outboxPublisher := &fakeOutboxPublisher{failCount: 10, err: errors.New("permanent im failure")}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher)
record, err := outbox.Build("room-dead", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 1,
TargetUserId: 2,
GiftId: "rocket",
GiftCount: 1,
GiftValue: 100,
})
if err != nil {
t.Fatalf("Build outbox failed: %v", err)
}
record.RetryCount = 2
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
t.Fatalf("SaveOutbox failed: %v", err)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second, MaxRetryCount: 2}); err != nil {
t.Fatalf("ProcessPendingOutbox failed: %v", err)
}
dead, exists := repository.OutboxRecord(record.EventID)
if !exists || dead.Status != outbox.StatusFailed || !strings.Contains(dead.LastError, "retry limit") {
t.Fatalf("event should be dead-lettered: exists=%v record=%+v", exists, dead)
}
if len(outboxPublisher.envelopes) != 0 {
t.Fatalf("dead-lettered record must not be published again")
}
}
func TestRoomEventStaysPendingUntilOutboxWorkerDelivers(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: 10,
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("room events must not be synchronously published: %+v", syncPublisher.events)
}
records, err := repository.ListPendingOutbox(ctx, 100)
if err != nil {
t.Fatalf("ListPendingOutbox failed: %v", err)
}
if len(records) == 0 {
t.Fatalf("expected pending room event before worker delivery")
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil {
t.Fatalf("ProcessPendingOutbox failed: %v", err)
}
delivered, exists := repository.OutboxRecord(records[0].EventID)
if !exists || delivered.Status != outbox.StatusDelivered {
t.Fatalf("outbox worker should mark event delivered: exists=%v record=%+v", exists, delivered)
}
}
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 {
return slices.Contains(values, target)
}
//go:fix inline
func stringPtr(value string) *string {
return new(value)
}
//go:fix inline
func int32Ptr(value int32) *int32 {
return new(value)
}
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
}