2469 lines
97 KiB
Go
2469 lines
97 KiB
Go
// 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 timeout;worker 必须能在无其他 owner 时续租并执行超时下麦。
|
||
directory.ForceExpire(roomRouteKeyForTest(roomID))
|
||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
|
||
}
|
||
|
||
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom refresh failed: %v", err)
|
||
}
|
||
if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
|
||
t.Fatalf("publish timeout should release seat and session: %+v", seat)
|
||
}
|
||
if len(syncPublisher.events) == 0 {
|
||
t.Fatalf("expected timeout mic_down event")
|
||
}
|
||
last := syncPublisher.events[len(syncPublisher.events)-1]
|
||
if last.EventType != "room_mic_down" || last.Attributes["reason"] != "publish_timeout" || last.Attributes["mic_session_id"] != micResp.GetMicSessionId() {
|
||
t.Fatalf("timeout should publish mic_down with session reason: %+v", last)
|
||
}
|
||
}
|
||
|
||
func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-mic-old-session"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 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)
|
||
}
|
||
|
||
// 新服务没有内存 Cell;CheckSpeakPermission 必须从 v2 快照后回放 v3 禁言命令。
|
||
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
speakResp, err := serviceB.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
|
||
t.Fatalf("guard did not replay mute command: %+v", speakResp)
|
||
}
|
||
}
|
||
|
||
// TestSweepStalePresenceRemovesUserAndReleasesSeat 验证 stale worker 复用离房链路清理断线用户。
|
||
func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-stale"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 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}
|
||
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
|
||
t.Fatalf("first ProcessPendingOutbox should keep batch alive after publish failure: %v", err)
|
||
}
|
||
failed, exists := repository.OutboxRecord(record.EventID)
|
||
if !exists {
|
||
t.Fatalf("outbox record missing after failure")
|
||
}
|
||
if failed.Status != outbox.StatusRetryable || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") {
|
||
t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed)
|
||
}
|
||
|
||
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
|
||
t.Fatalf("second ProcessPendingOutbox failed: %v", err)
|
||
}
|
||
delivered, exists := repository.OutboxRecord(record.EventID)
|
||
if !exists {
|
||
t.Fatalf("outbox record missing after success")
|
||
}
|
||
if delivered.Status != outbox.StatusDelivered || delivered.LastError != "" || len(outboxPublisher.envelopes) != 1 {
|
||
t.Fatalf("second publish should mark record delivered: record=%+v envelopes=%d", delivered, len(outboxPublisher.envelopes))
|
||
}
|
||
}
|
||
|
||
func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
|
||
record, err := outbox.Build("room-claim", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
|
||
SenderUserId: 1,
|
||
TargetUserId: 2,
|
||
GiftId: "rose",
|
||
GiftCount: 1,
|
||
GiftValue: 10,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Build outbox failed: %v", err)
|
||
}
|
||
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
|
||
t.Fatalf("SaveOutbox failed: %v", err)
|
||
}
|
||
|
||
claimedA, err := repository.ClaimPendingOutbox(ctx, "worker-a", 10, time.Now().Add(time.Minute).UnixMilli())
|
||
if err != nil {
|
||
t.Fatalf("worker-a claim failed: %v", err)
|
||
}
|
||
if len(claimedA) != 1 || claimedA[0].Status != outbox.StatusDelivering || claimedA[0].WorkerID != "worker-a" {
|
||
t.Fatalf("worker-a should claim one delivering record: %+v", claimedA)
|
||
}
|
||
claimedB, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli())
|
||
if err != nil {
|
||
t.Fatalf("worker-b claim failed: %v", err)
|
||
}
|
||
if len(claimedB) != 0 {
|
||
t.Fatalf("worker-b should not claim worker-a locked record: %+v", claimedB)
|
||
}
|
||
|
||
if err := repository.MarkOutboxFailed(ctx, record.EventID, "temporary failure"); err != nil {
|
||
t.Fatalf("MarkOutboxFailed failed: %v", err)
|
||
}
|
||
claimedRetry, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli())
|
||
if err != nil {
|
||
t.Fatalf("retry claim failed: %v", err)
|
||
}
|
||
if len(claimedRetry) != 1 || claimedRetry[0].Status != outbox.StatusDelivering || claimedRetry[0].RetryCount != 1 {
|
||
t.Fatalf("retryable record should be claimable again: %+v", claimedRetry)
|
||
}
|
||
}
|
||
|
||
func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
|
||
roomID := "room-sync-delivered"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 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("expected sync room event")
|
||
}
|
||
|
||
eventID := syncPublisher.events[len(syncPublisher.events)-1].EventID
|
||
record, exists := repository.OutboxRecord(eventID)
|
||
if !exists {
|
||
t.Fatalf("sync event outbox record missing: %s", eventID)
|
||
}
|
||
if record.Status != outbox.StatusDelivered {
|
||
t.Fatalf("sync success should best-effort mark outbox delivered: %+v", record)
|
||
}
|
||
}
|
||
|
||
func TestRunOutboxWorkerExitsOnCancel(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
done := make(chan struct{})
|
||
|
||
go func() {
|
||
defer close(done)
|
||
svc.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
|
||
PollInterval: 10 * time.Millisecond,
|
||
BatchSize: 10,
|
||
PublishTimeout: time.Second,
|
||
})
|
||
}()
|
||
cancel()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(time.Second):
|
||
t.Fatal("outbox worker did not exit after cancel")
|
||
}
|
||
}
|
||
|
||
func countPendingOutboxType(t *testing.T, repository roomservice.Repository, eventType string) int {
|
||
t.Helper()
|
||
|
||
records, err := repository.ListPendingOutbox(context.Background(), 0)
|
||
if err != nil {
|
||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
count := 0
|
||
for _, record := range records {
|
||
if record.EventType == eventType {
|
||
count++
|
||
}
|
||
}
|
||
|
||
return count
|
||
}
|
||
|
||
func countPendingMicDownReason(t *testing.T, repository roomservice.Repository, reason string) int {
|
||
t.Helper()
|
||
|
||
records, err := repository.ListPendingOutbox(context.Background(), 0)
|
||
if err != nil {
|
||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
count := 0
|
||
for _, record := range records {
|
||
if record.EventType != "RoomMicChanged" || record.Envelope == nil {
|
||
continue
|
||
}
|
||
var body roomeventsv1.RoomMicChanged
|
||
if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil {
|
||
t.Fatalf("decode RoomMicChanged failed: %v", err)
|
||
}
|
||
if body.GetAction() == "down" && body.GetReason() == reason {
|
||
count++
|
||
}
|
||
}
|
||
|
||
return count
|
||
}
|
||
|
||
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
||
for _, user := range snapshot.GetOnlineUsers() {
|
||
if user.GetUserId() == userID {
|
||
return user
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
|
||
for _, seat := range snapshot.GetMicSeats() {
|
||
if seat.GetSeatNo() == seatNo {
|
||
return seat
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func containsInt64(values []int64, target int64) bool {
|
||
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
|
||
}
|