1262 lines
48 KiB
Go
1262 lines
48 KiB
Go
// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。
|
||
package service_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||
roomv1 "hyapp/api/proto/room/v1"
|
||
walletv1 "hyapp/api/proto/wallet/v1"
|
||
"hyapp/pkg/tencentim"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/room-service/internal/room/outbox"
|
||
roomservice "hyapp/services/room-service/internal/room/service"
|
||
"hyapp/services/room-service/internal/router"
|
||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||
)
|
||
|
||
// fakeWallet 模拟 wallet-service 扣费成功路径。
|
||
type fakeWallet struct{}
|
||
|
||
// DebitGift 返回可预测账单回执和钱包结算值,测试只关注 room-service 状态变更。
|
||
func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||
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
|
||
}
|
||
|
||
// fakeSyncPublisher 记录 room-service 同步推给腾讯云 IM 的房间事件。
|
||
type fakeSyncPublisher struct {
|
||
// fail 为 true 时模拟腾讯云 IM PublishRoomEvent 失败。
|
||
fail bool
|
||
// events 保存已尝试同步发布的事件。
|
||
events []tencentim.RoomEvent
|
||
}
|
||
|
||
// EnsureRoomGroup 模拟腾讯云 IM 群组存在。
|
||
func (p *fakeSyncPublisher) EnsureRoomGroup(context.Context, string, int64) error {
|
||
return nil
|
||
}
|
||
|
||
// PublishRoomEvent 记录事件,并按 fail 开关返回错误。
|
||
func (p *fakeSyncPublisher) PublishRoomEvent(_ context.Context, event tencentim.RoomEvent) error {
|
||
p.events = append(p.events, event)
|
||
if p.fail {
|
||
return context.DeadlineExceeded
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// fakeOutboxPublisher 记录 outbox worker 补偿投递的事件信封。
|
||
type fakeOutboxPublisher struct {
|
||
// envelopes 保存补偿发布过的 protobuf 信封。
|
||
envelopes []*roomeventsv1.EventEnvelope
|
||
// failCount 控制前 N 次投递失败,用于验证 retry_count 和后续成功。
|
||
failCount int
|
||
// err 是失败时返回的错误;为空时使用 context.DeadlineExceeded。
|
||
err error
|
||
// attempts 记录投递尝试次数,失败也会累计。
|
||
attempts int
|
||
}
|
||
|
||
// PublishOutboxEvent 记录补偿事件,模拟外部消费者投递成功。
|
||
func (p *fakeOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
|
||
p.attempts++
|
||
if p.failCount > 0 {
|
||
p.failCount--
|
||
if p.err != nil {
|
||
return p.err
|
||
}
|
||
return context.DeadlineExceeded
|
||
}
|
||
p.envelopes = append(p.envelopes, envelope)
|
||
return nil
|
||
}
|
||
|
||
// mutableClock 让 presence 刷新和 stale 清理测试不依赖真实时间。
|
||
type mutableClock struct {
|
||
now time.Time
|
||
}
|
||
|
||
func (c *mutableClock) Now() time.Time {
|
||
return c.now
|
||
}
|
||
|
||
// newRoomService 组装带真实 MySQL repository 的 room-service,便于测试 Room Cell 持久化语义。
|
||
func newRoomService(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher) *roomservice.Service {
|
||
return roomservice.New(roomservice.Config{
|
||
NodeID: nodeID,
|
||
LeaseTTL: 10 * time.Second,
|
||
RankLimit: 20,
|
||
SnapshotEveryN: snapshotEveryN,
|
||
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
|
||
}
|
||
|
||
// newRoomServiceWithClock 允许测试直接控制房间命令时间和 stale presence 阈值。
|
||
func newRoomServiceWithClock(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher, usedClock *mutableClock, staleAfter time.Duration) *roomservice.Service {
|
||
return roomservice.New(roomservice.Config{
|
||
NodeID: nodeID,
|
||
LeaseTTL: 10 * time.Second,
|
||
RankLimit: 20,
|
||
SnapshotEveryN: snapshotEveryN,
|
||
PresenceStaleAfter: staleAfter,
|
||
Clock: usedClock,
|
||
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
|
||
}
|
||
|
||
// TestCreateRoomDefaultsOwnerAndHostFromActor 验证 CreateRoom 身份语义收敛在 room-service。
|
||
func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
|
||
roomID := "room-owner-default"
|
||
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 42),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRoom with owner/host defaults failed: %v", err)
|
||
}
|
||
|
||
if createResp.GetRoom().GetOwnerUserId() != 42 || createResp.GetRoom().GetHostUserId() != 42 {
|
||
t.Fatalf("owner/host should default from actor, got owner=%d host=%d", createResp.GetRoom().GetOwnerUserId(), createResp.GetRoom().GetHostUserId())
|
||
}
|
||
if user := findRoomUser(createResp.GetRoom(), 42); user == nil || user.GetRole() != "owner" {
|
||
t.Fatalf("actor should enter room as owner presence, got %+v", createResp.GetRoom().GetOnlineUsers())
|
||
}
|
||
|
||
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
|
||
if err != nil {
|
||
t.Fatalf("GetRoomMeta failed: %v", err)
|
||
}
|
||
if !exists || meta.OwnerUserID != 42 || meta.HostUserID != 42 {
|
||
t.Fatalf("room meta owner/host mismatch: exists=%v meta=%+v", exists, meta)
|
||
}
|
||
}
|
||
|
||
func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
|
||
roomID := "room-list-region-a"
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
VisibleRegionId: 1001,
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom region 1001 failed: %v", err)
|
||
}
|
||
|
||
usedClock.now = usedClock.now.Add(time.Second)
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta("room-list-region-b", 3),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
VisibleRegionId: 2002,
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom region 2002 failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "rose",
|
||
GiftCount: 1,
|
||
}); err != nil {
|
||
t.Fatalf("SendGift failed: %v", err)
|
||
}
|
||
|
||
listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||
ViewerUserId: 99,
|
||
VisibleRegionId: 1001,
|
||
Tab: "hot",
|
||
Limit: 20,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("ListRooms region 1001 failed: %v", err)
|
||
}
|
||
if len(listResp.GetRooms()) != 1 {
|
||
t.Fatalf("region list should only expose matching rooms: %+v", listResp.GetRooms())
|
||
}
|
||
item := listResp.GetRooms()[0]
|
||
if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 {
|
||
t.Fatalf("room list item identity mismatch: %+v", item)
|
||
}
|
||
if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 4 {
|
||
t.Fatalf("room list projection did not track Room Cell state: %+v", item)
|
||
}
|
||
|
||
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 TestCreateRoomRejectsInvalidRoomID(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
|
||
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta("room:1001", 42),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
})
|
||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("CreateRoom should reject invalid room_id, got %v", err)
|
||
}
|
||
if _, exists, getErr := repository.GetRoomMeta(ctx, "room:1001"); getErr != nil || exists {
|
||
t.Fatalf("invalid room_id must not create room meta: exists=%v err=%v", exists, getErr)
|
||
}
|
||
}
|
||
|
||
func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
req *roomv1.CreateRoomRequest
|
||
}{
|
||
{name: "seat_count", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-seat", 42), Mode: "social"}},
|
||
{name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 4}},
|
||
}
|
||
|
||
for _, test := range tests {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), mysqltest.NewRepository(t), &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
|
||
_, err := svc.CreateRoom(context.Background(), test.req)
|
||
|
||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("CreateRoom should reject missing %s, got %v", test.name, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。
|
||
func TestRoomLifecycleGiftAndGuards(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
outboxPublisher := &fakeOutboxPublisher{}
|
||
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
|
||
|
||
roomID := "room-1001"
|
||
|
||
// 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。
|
||
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if !createResp.GetResult().GetApplied() {
|
||
t.Fatalf("CreateRoom should apply")
|
||
}
|
||
|
||
// JoinRoom 只进入 room-service 业务 presence,不涉及腾讯云 IM 连接态。
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
// MicUp 验证 Room Cell 内麦位占用和版本变更。
|
||
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
SeatNo: 1,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
|
||
if micResp.GetSeatNo() != 1 {
|
||
t.Fatalf("MicUp seat mismatch: got %d", micResp.GetSeatNo())
|
||
}
|
||
|
||
// SendGift 先走 fakeWallet,再更新热度和房间内本地礼物榜。
|
||
giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "rose",
|
||
GiftCount: 2,
|
||
})
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestRoomManagementPermissionsAndHostFlow(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-management-flow"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
|
||
t.Fatalf("JoinRoom user3 failed: %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp user2 failed: %v", err)
|
||
}
|
||
|
||
_, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2})
|
||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||
t.Fatalf("audience should not mic down others, got %v", err)
|
||
}
|
||
|
||
adminResp, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 3,
|
||
Enabled: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("SetRoomAdmin failed: %v", err)
|
||
}
|
||
if !containsInt64(adminResp.GetRoom().GetAdminUserIds(), 3) {
|
||
t.Fatalf("admin ids should contain user3: %+v", adminResp.GetRoom().GetAdminUserIds())
|
||
}
|
||
|
||
if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}); err != nil {
|
||
t.Fatalf("admin MicDown failed: %v", err)
|
||
}
|
||
hostResp, err := svc.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("TransferRoomHost failed: %v", err)
|
||
}
|
||
if hostResp.GetRoom().GetHostUserId() != 2 || !containsInt64(hostResp.GetRoom().GetAdminUserIds(), 2) {
|
||
t.Fatalf("host transfer should update host and admin set: %+v", hostResp.GetRoom())
|
||
}
|
||
|
||
_, err = svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 3),
|
||
TargetUserId: 2,
|
||
Muted: true,
|
||
})
|
||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||
t.Fatalf("admin should not mute current host, got %v", err)
|
||
}
|
||
if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 2), Enabled: false}); err != nil {
|
||
t.Fatalf("host SetChatEnabled failed: %v", err)
|
||
}
|
||
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 1})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "chat_disabled" {
|
||
t.Fatalf("chat disabled should block all users: %+v", speakResp)
|
||
}
|
||
}
|
||
|
||
func TestMicPublishingConfirmAndStaleEvents(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-mic-publish-confirm"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice"}); 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 TestMicPublishTimeoutMicDownsPendingSession(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-mic-publish-timeout"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice"}); 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())
|
||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
|
||
}
|
||
|
||
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom refresh failed: %v", err)
|
||
}
|
||
if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
|
||
t.Fatalf("publish timeout should release seat and session: %+v", seat)
|
||
}
|
||
if len(syncPublisher.events) == 0 {
|
||
t.Fatalf("expected timeout mic_down event")
|
||
}
|
||
last := syncPublisher.events[len(syncPublisher.events)-1]
|
||
if last.EventType != "room_mic_down" || last.Attributes["reason"] != "publish_timeout" || last.Attributes["mic_session_id"] != micResp.GetMicSessionId() {
|
||
t.Fatalf("timeout should publish mic_down with session reason: %+v", last)
|
||
}
|
||
}
|
||
|
||
func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-mic-old-session"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice"}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||
if err != nil {
|
||
t.Fatalf("first MicUp failed: %v", err)
|
||
}
|
||
if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("MicDown failed: %v", err)
|
||
}
|
||
|
||
usedClock.now = usedClock.now.Add(time.Second)
|
||
newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||
if err != nil {
|
||
t.Fatalf("second MicUp failed: %v", err)
|
||
}
|
||
if newMic.GetMicSessionId() == oldMic.GetMicSessionId() {
|
||
t.Fatalf("new MicUp must create a new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId())
|
||
}
|
||
|
||
usedClock.now = time.UnixMilli(oldMic.GetPublishDeadlineMs())
|
||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||
t.Fatalf("old timeout sweep failed: %v", err)
|
||
}
|
||
staleConfirm, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
MicSessionId: oldMic.GetMicSessionId(),
|
||
RoomVersion: oldMic.GetResult().GetRoomVersion(),
|
||
EventTimeMs: usedClock.now.Add(time.Second).UnixMilli(),
|
||
Source: "rtc_webhook",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("old session confirm should be ignored without error: %v", err)
|
||
}
|
||
seat := findSeat(staleConfirm.GetRoom(), 1)
|
||
if seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() || seat.GetPublishState() != "pending_publish" {
|
||
t.Fatalf("old session event must not clear new MicUp: seat=%+v new=%+v old=%+v", seat, newMic, oldMic)
|
||
}
|
||
}
|
||
|
||
func TestMicSeatLockAndNoopIdempotency(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
syncPublisher := &fakeSyncPublisher{fail: true}
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
|
||
roomID := "room-mic-lock"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 3, Mode: "social"}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
lockResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatNo: 2,
|
||
Locked: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("SetMicSeatLock failed: %v", err)
|
||
}
|
||
if seat := findSeat(lockResp.GetRoom(), 2); seat == nil || !seat.GetLocked() {
|
||
t.Fatalf("seat should be locked: %+v", seat)
|
||
}
|
||
if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 {
|
||
t.Fatalf("lock should write one outbox event, got %d", got)
|
||
}
|
||
|
||
repeatResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatNo: 2,
|
||
Locked: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("repeat SetMicSeatLock failed: %v", err)
|
||
}
|
||
if repeatResp.GetResult().GetApplied() {
|
||
t.Fatalf("repeat lock should be a no-op")
|
||
}
|
||
if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 {
|
||
t.Fatalf("repeat lock should not write duplicate outbox, got %d", got)
|
||
}
|
||
|
||
_, err = svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2})
|
||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||
t.Fatalf("locked seat MicUp should be denied, got %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp unlocked seat failed: %v", err)
|
||
}
|
||
_, err = svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 1, Locked: true})
|
||
if !xerr.IsCode(err, xerr.Conflict) {
|
||
t.Fatalf("locking occupied seat should conflict, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestKickUnbanAndAdminRemoval(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-ban-unban"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); 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 TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-command-conflict"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
firstMeta := roomservice.NewRequestMeta(roomID, 1)
|
||
firstMeta.CommandId = "cmd-chat-disable-fixed"
|
||
firstMeta.RequestId = "req-first"
|
||
if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: firstMeta, Enabled: false}); err != nil {
|
||
t.Fatalf("first SetChatEnabled failed: %v", err)
|
||
}
|
||
|
||
retryMeta := roomservice.NewRequestMeta(roomID, 1)
|
||
retryMeta.CommandId = firstMeta.GetCommandId()
|
||
retryMeta.RequestId = "req-retry"
|
||
retryMeta.SessionId = "different-session"
|
||
retryMeta.SentAtMs = firstMeta.GetSentAtMs() + 1000
|
||
retryResp, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: false})
|
||
if err != nil {
|
||
t.Fatalf("same command with different trace fields should be idempotent: %v", err)
|
||
}
|
||
if retryResp.GetResult().GetApplied() {
|
||
t.Fatalf("idempotent retry should not apply again")
|
||
}
|
||
|
||
_, err = svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: true})
|
||
if !xerr.IsCode(err, xerr.Conflict) {
|
||
t.Fatalf("same command_id with different business payload should conflict, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRoomManagementRecoveryReplay(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-management-replay"
|
||
|
||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); 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(roomID)
|
||
serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom on recovered service failed: %v", err)
|
||
}
|
||
snapshot := joinResp.GetRoom()
|
||
if snapshot.GetHostUserId() != 2 || !containsInt64(snapshot.GetAdminUserIds(), 2) {
|
||
t.Fatalf("host/admin state did not replay: %+v", snapshot)
|
||
}
|
||
if snapshot.GetChatEnabled() {
|
||
t.Fatalf("chat_enabled should replay as false")
|
||
}
|
||
if seat := findSeat(snapshot, 4); seat == nil || !seat.GetLocked() {
|
||
t.Fatalf("locked seat did not replay: %+v", seat)
|
||
}
|
||
if containsInt64(snapshot.GetBanUserIds(), 3) {
|
||
t.Fatalf("unban should replay after kick: %+v", snapshot.GetBanUserIds())
|
||
}
|
||
}
|
||
|
||
// TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent 验证重连只刷新 presence,不重复广播进房。
|
||
func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
svc := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-repeat-join"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("first JoinRoom failed: %v", err)
|
||
}
|
||
beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined")
|
||
|
||
usedClock.now = usedClock.now.Add(5 * time.Second)
|
||
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("repeat JoinRoom failed: %v", err)
|
||
}
|
||
|
||
user := findRoomUser(joinResp.GetRoom(), 2)
|
||
if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() {
|
||
t.Fatalf("last_seen was not refreshed: user=%+v now=%d", user, usedClock.now.UnixMilli())
|
||
}
|
||
if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents {
|
||
t.Fatalf("repeat JoinRoom should not create duplicate join outbox: before=%d after=%d", beforeJoinEvents, after)
|
||
}
|
||
}
|
||
|
||
// TestLeaveRoomReleasesSeatAndBlocksGuards 验证离房同时移除 presence 和麦位占用。
|
||
func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-leave-seat"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
|
||
leaveResp, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)})
|
||
if err != nil {
|
||
t.Fatalf("LeaveRoom failed: %v", err)
|
||
}
|
||
if findRoomUser(leaveResp.GetRoom(), 2) != nil {
|
||
t.Fatalf("left user should not remain in presence: %+v", leaveResp.GetRoom().GetOnlineUsers())
|
||
}
|
||
if seat := findSeat(leaveResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
|
||
t.Fatalf("leave should release seat: %+v", seat)
|
||
}
|
||
|
||
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "not_in_room" {
|
||
t.Fatalf("unexpected speak response after leave: %+v", speakResp)
|
||
}
|
||
}
|
||
|
||
// TestRoomRecoveryAfterLeaseTakeover 验证 lease 过期后新节点通过 snapshot + command log 恢复房间。
|
||
func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
syncPublisherA := &fakeSyncPublisher{}
|
||
outboxPublisher := &fakeOutboxPublisher{}
|
||
serviceA := newRoomService("node-a", 2, directory, repository, syncPublisherA, outboxPublisher)
|
||
|
||
roomID := "room-recover"
|
||
|
||
// node-a 先创建并推进房间状态,snapshotEveryN=2 会让恢复需要结合快照和命令日志。
|
||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "rose",
|
||
GiftCount: 1,
|
||
}); err != nil {
|
||
t.Fatalf("SendGift failed: %v", err)
|
||
}
|
||
|
||
// 强制过期模拟 node-a 故障后 Redis lease 失效。
|
||
directory.ForceExpire(roomID)
|
||
|
||
// node-b 收到命令时应先接管 lease,再恢复 Room Cell,而不是返回 room not found。
|
||
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
|
||
|
||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 3),
|
||
Role: "audience",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom on takeover node failed: %v", err)
|
||
}
|
||
|
||
if got := joinResp.GetRoom().GetHeat(); got != 10 {
|
||
t.Fatalf("recovered heat mismatch: got %d want 10", got)
|
||
}
|
||
|
||
if len(joinResp.GetRoom().GetGiftRank()) != 1 || joinResp.GetRoom().GetGiftRank()[0].GetUserId() != 1 {
|
||
t.Fatalf("recovered rank mismatch: %+v", joinResp.GetRoom().GetGiftRank())
|
||
}
|
||
|
||
lease, exists, err := directory.Lookup(ctx, roomID)
|
||
if err != nil || !exists {
|
||
t.Fatalf("Lookup failed: %v exists=%v", err, exists)
|
||
}
|
||
|
||
if lease.NodeID != "node-b" {
|
||
t.Fatalf("lease takeover failed: owner=%s", lease.NodeID)
|
||
}
|
||
}
|
||
|
||
// TestGuardRecoveryReplaysCommandsAfterSnapshot 验证无内存 Cell 的守卫查询会补回快照后的 command log。
|
||
func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
serviceA := newRoomService("node-a", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
roomID := "room-guard-recover"
|
||
|
||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
if _, err := serviceA.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
Muted: true,
|
||
}); err != nil {
|
||
t.Fatalf("MuteUser failed: %v", err)
|
||
}
|
||
|
||
// 新服务没有内存 Cell;CheckSpeakPermission 必须从 v2 快照后回放 v3 禁言命令。
|
||
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
speakResp, err := serviceB.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||
}
|
||
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
|
||
t.Fatalf("guard did not replay mute command: %+v", speakResp)
|
||
}
|
||
}
|
||
|
||
// TestSweepStalePresenceRemovesUserAndReleasesSeat 验证 stale worker 复用离房链路清理断线用户。
|
||
func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||
roomID := "room-stale"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||
}
|
||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||
t.Fatalf("MicUp failed: %v", err)
|
||
}
|
||
|
||
usedClock.now = usedClock.now.Add(30 * time.Second)
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
|
||
t.Fatalf("refresh owner JoinRoom failed: %v", err)
|
||
}
|
||
|
||
usedClock.now = usedClock.now.Add(40 * time.Second)
|
||
if err := svc.SweepStalePresence(ctx); err != nil {
|
||
t.Fatalf("SweepStalePresence failed: %v", err)
|
||
}
|
||
|
||
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2})
|
||
if err != nil {
|
||
t.Fatalf("VerifyRoomPresence failed: %v", err)
|
||
}
|
||
if presenceResp.GetPresent() || presenceResp.GetReason() != "not_in_room" {
|
||
t.Fatalf("stale user should be removed: %+v", presenceResp)
|
||
}
|
||
|
||
snapshot, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)})
|
||
if err != nil {
|
||
t.Fatalf("JoinRoom after stale sweep failed: %v", err)
|
||
}
|
||
if seat := findSeat(snapshot.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
|
||
t.Fatalf("stale sweep should release seat: %+v", seat)
|
||
}
|
||
if !hasSyncEventReason(syncPublisher.events, "stale_timeout") {
|
||
t.Fatalf("stale sweep should publish room_user_left with reason: %+v", syncPublisher.events)
|
||
}
|
||
}
|
||
|
||
// TestRoomOutboxCompensationWhenSyncPublishFails 验证同步广播失败不回滚房间状态,outbox 可补偿。
|
||
func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
directory := router.NewMemoryDirectory()
|
||
syncPublisher := &fakeSyncPublisher{fail: true}
|
||
outboxPublisher := &fakeOutboxPublisher{}
|
||
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
|
||
|
||
roomID := "room-outbox"
|
||
|
||
// 创建和进房先建立可送礼的基本房间态。
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
Role: "audience",
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
|
||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
TargetUserId: 2,
|
||
GiftId: "car",
|
||
GiftCount: 1,
|
||
}); err != nil {
|
||
t.Fatalf("SendGift failed: %v", err)
|
||
}
|
||
|
||
// fakeSyncPublisher 失败后,房间事件仍应作为 pending outbox 记录保留。
|
||
pendingBefore, err := repository.ListPendingOutbox(ctx, 100)
|
||
if err != nil {
|
||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
if len(pendingBefore) == 0 {
|
||
t.Fatalf("expected pending outbox records before compensation")
|
||
}
|
||
|
||
// 补偿 worker 成功投递后 fakeOutboxPublisher 应记录事件信封。
|
||
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil {
|
||
t.Fatalf("ProcessPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
if len(outboxPublisher.envelopes) == 0 {
|
||
t.Fatalf("expected compensated outbox publish")
|
||
}
|
||
if remaining, err := repository.ListPendingOutbox(ctx, 100); err != nil {
|
||
t.Fatalf("ListPendingOutbox after compensation failed: %v", err)
|
||
} else if len(remaining) != 0 {
|
||
t.Fatalf("expected all pending outbox records to be marked published, got %d", len(remaining))
|
||
}
|
||
}
|
||
|
||
func TestOutboxWorkerRetriesFailedPublishAndLaterMarksPublished(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
outboxPublisher := &fakeOutboxPublisher{failCount: 1, err: errors.New("temporary im failure")}
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher)
|
||
|
||
record, err := outbox.Build("room-retry", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
|
||
SenderUserId: 1,
|
||
TargetUserId: 2,
|
||
GiftId: "car",
|
||
GiftCount: 1,
|
||
GiftValue: 100,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Build outbox failed: %v", err)
|
||
}
|
||
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
|
||
t.Fatalf("SaveOutbox failed: %v", err)
|
||
}
|
||
|
||
options := roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second}
|
||
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
|
||
t.Fatalf("first ProcessPendingOutbox should keep batch alive after publish failure: %v", err)
|
||
}
|
||
failed, exists := repository.OutboxRecord(record.EventID)
|
||
if !exists {
|
||
t.Fatalf("outbox record missing after failure")
|
||
}
|
||
if failed.Status != outbox.StatusPending || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") {
|
||
t.Fatalf("failed publish should remain pending with retry metadata: %+v", failed)
|
||
}
|
||
|
||
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
|
||
t.Fatalf("second ProcessPendingOutbox failed: %v", err)
|
||
}
|
||
published, exists := repository.OutboxRecord(record.EventID)
|
||
if !exists {
|
||
t.Fatalf("outbox record missing after success")
|
||
}
|
||
if published.Status != outbox.StatusPublished || published.LastError != "" || len(outboxPublisher.envelopes) != 1 {
|
||
t.Fatalf("second publish should mark record published: record=%+v envelopes=%d", published, len(outboxPublisher.envelopes))
|
||
}
|
||
}
|
||
|
||
func TestSyncPublishSuccessMarksOutboxPublished(t *testing.T) {
|
||
ctx := context.Background()
|
||
repository := mysqltest.NewRepository(t)
|
||
syncPublisher := &fakeSyncPublisher{}
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
|
||
roomID := "room-sync-published"
|
||
|
||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||
SeatCount: 4,
|
||
Mode: "social",
|
||
}); err != nil {
|
||
t.Fatalf("CreateRoom failed: %v", err)
|
||
}
|
||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||
}); err != nil {
|
||
t.Fatalf("JoinRoom failed: %v", err)
|
||
}
|
||
if len(syncPublisher.events) == 0 {
|
||
t.Fatalf("expected sync room event")
|
||
}
|
||
|
||
eventID := syncPublisher.events[len(syncPublisher.events)-1].EventID
|
||
record, exists := repository.OutboxRecord(eventID)
|
||
if !exists {
|
||
t.Fatalf("sync event outbox record missing: %s", eventID)
|
||
}
|
||
if record.Status != outbox.StatusPublished {
|
||
t.Fatalf("sync success should best-effort mark outbox published: %+v", record)
|
||
}
|
||
}
|
||
|
||
func TestRunOutboxWorkerExitsOnCancel(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
done := make(chan struct{})
|
||
|
||
go func() {
|
||
defer close(done)
|
||
svc.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
|
||
PollInterval: 10 * time.Millisecond,
|
||
BatchSize: 10,
|
||
PublishTimeout: time.Second,
|
||
})
|
||
}()
|
||
cancel()
|
||
|
||
select {
|
||
case <-done:
|
||
case <-time.After(time.Second):
|
||
t.Fatal("outbox worker did not exit after cancel")
|
||
}
|
||
}
|
||
|
||
func countPendingOutboxType(t *testing.T, repository roomservice.Repository, eventType string) int {
|
||
t.Helper()
|
||
|
||
records, err := repository.ListPendingOutbox(context.Background(), 0)
|
||
if err != nil {
|
||
t.Fatalf("ListPendingOutbox failed: %v", err)
|
||
}
|
||
|
||
count := 0
|
||
for _, record := range records {
|
||
if record.EventType == eventType {
|
||
count++
|
||
}
|
||
}
|
||
|
||
return count
|
||
}
|
||
|
||
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
||
for _, user := range snapshot.GetOnlineUsers() {
|
||
if user.GetUserId() == userID {
|
||
return user
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
|
||
for _, seat := range snapshot.GetMicSeats() {
|
||
if seat.GetSeatNo() == seatNo {
|
||
return seat
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func containsInt64(values []int64, target int64) bool {
|
||
for _, value := range values {
|
||
if value == target {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {
|
||
for _, event := range events {
|
||
if event.EventType == "room_user_left" && event.Attributes["reason"] == reason {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|