2026-05-06 00:30:22 +08:00

1849 lines
74 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。
package service_test
import (
"context"
"errors"
"strings"
"testing"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/outbox"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
// fakeWallet 模拟 wallet-service 扣费成功路径。
type fakeWallet struct{}
// DebitGift 返回可预测账单回执和钱包结算值,测试只关注 room-service 状态变更。
func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
heatValue := int64(req.GetGiftCount()) * 10
return &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt_" + req.GetCommandId(),
TransactionId: "wtx_" + req.GetCommandId(),
CoinSpent: heatValue,
GiftPointAdded: heatValue,
HeatValue: heatValue,
PriceVersion: "test-v1",
BalanceAfter: 1000 - heatValue,
}, nil
}
type takeoverWallet struct {
roomID string
directory *router.MemoryDirectory
takeoverSvc *roomservice.Service
}
func (w takeoverWallet) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.directory.ForceExpire(roomRouteKeyForTest(w.roomID))
if _, err := w.takeoverSvc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(w.roomID, 3)}); err != nil {
return nil, err
}
return fakeWallet{}.DebitGift(ctx, req)
}
func roomRouteKeyForTest(roomID string) string {
return appcode.Default + "\x00" + roomID
}
// fakeSyncPublisher 记录 room-service 同步推给腾讯云 IM 的房间事件。
type fakeSyncPublisher struct {
// fail 为 true 时模拟腾讯云 IM PublishRoomEvent 失败。
fail bool
// events 保存已尝试同步发布的事件。
events []tencentim.RoomEvent
}
// EnsureRoomGroup 模拟腾讯云 IM 群组存在。
func (p *fakeSyncPublisher) EnsureRoomGroup(context.Context, string, int64) error {
return nil
}
// PublishRoomEvent 记录事件,并按 fail 开关返回错误。
func (p *fakeSyncPublisher) PublishRoomEvent(_ context.Context, event tencentim.RoomEvent) error {
p.events = append(p.events, event)
if p.fail {
return context.DeadlineExceeded
}
return nil
}
// fakeOutboxPublisher 记录 outbox worker 补偿投递的事件信封。
type fakeOutboxPublisher struct {
// envelopes 保存补偿发布过的 protobuf 信封。
envelopes []*roomeventsv1.EventEnvelope
// failCount 控制前 N 次投递失败,用于验证 retry_count 和后续成功。
failCount int
// err 是失败时返回的错误;为空时使用 context.DeadlineExceeded。
err error
// attempts 记录投递尝试次数,失败也会累计。
attempts int
}
// PublishOutboxEvent 记录补偿事件,模拟外部消费者投递成功。
func (p *fakeOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
p.attempts++
if p.failCount > 0 {
p.failCount--
if p.err != nil {
return p.err
}
return context.DeadlineExceeded
}
p.envelopes = append(p.envelopes, envelope)
return nil
}
// mutableClock 让 presence 刷新和 stale 清理测试不依赖真实时间。
type mutableClock struct {
now time.Time
}
func (c *mutableClock) Now() time.Time {
return c.now
}
// newRoomService 组装带真实 MySQL repository 的 room-service便于测试 Room Cell 持久化语义。
func newRoomService(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher) *roomservice.Service {
return roomservice.New(roomservice.Config{
NodeID: nodeID,
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: snapshotEveryN,
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
}
// newRoomServiceWithClock 允许测试直接控制房间命令时间和 stale presence 阈值。
func newRoomServiceWithClock(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher, usedClock *mutableClock, staleAfter time.Duration) *roomservice.Service {
return roomservice.New(roomservice.Config{
NodeID: nodeID,
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: snapshotEveryN,
PresenceStaleAfter: staleAfter,
Clock: usedClock,
}, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher)
}
// TestCreateRoomDefaultsOwnerAndHostFromActor 验证 CreateRoom 身份语义收敛在 room-service。
func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-owner-default"
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
})
if err != nil {
t.Fatalf("CreateRoom with owner/host defaults failed: %v", err)
}
if createResp.GetRoom().GetOwnerUserId() != 42 || createResp.GetRoom().GetHostUserId() != 42 {
t.Fatalf("owner/host should default from actor, got owner=%d host=%d", createResp.GetRoom().GetOwnerUserId(), createResp.GetRoom().GetHostUserId())
}
if user := findRoomUser(createResp.GetRoom(), 42); user == nil || user.GetRole() != "owner" {
t.Fatalf("actor should enter room as owner presence, got %+v", createResp.GetRoom().GetOnlineUsers())
}
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
if err != nil {
t.Fatalf("GetRoomMeta failed: %v", err)
}
if !exists || meta.OwnerUserID != 42 || meta.HostUserID != 42 {
t.Fatalf("room meta owner/host mismatch: exists=%v meta=%+v", exists, meta)
}
if ext := createResp.GetRoom().GetRoomExt(); ext["title"] != "Test Room" || ext["cover_url"] != "hyapp://room/default-avatar" || ext["description"] != "" {
t.Fatalf("room profile should be stored in RoomExt: %+v", ext)
}
}
func TestRoomRouteKeyAlwaysIncludesDefaultAppCode(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-route-default-app"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Route Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, exists, err := directory.Lookup(ctx, roomID); err != nil || exists {
t.Fatalf("raw room_id route key must not exist: exists=%v err=%v", exists, err)
}
lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID))
if err != nil || !exists {
t.Fatalf("app-scoped route key should exist: exists=%v err=%v", exists, err)
}
if lease.NodeID != "node-a" {
t.Fatalf("route owner mismatch: %+v", lease)
}
}
func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-owner-single-a", 42),
SeatCount: 4,
Mode: "social",
RoomName: "Owner Room A",
}); err != nil {
t.Fatalf("first CreateRoom failed: %v", err)
}
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-owner-single-b", 42),
SeatCount: 4,
Mode: "social",
RoomName: "Owner Room B",
})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("second CreateRoom by same owner should conflict, got %v", err)
}
if meta, exists, err := repository.GetRoomMetaByOwner(ctx, 42); err != nil || !exists || meta.RoomID != "room-owner-single-a" {
t.Fatalf("owner lookup should keep first room: exists=%v meta=%+v err=%v", exists, meta, err)
}
}
func TestRoomMetaOwnerUniqueIndexMapsConflict(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
first := roomservice.RoomMeta{
RoomID: "room-owner-unique-a",
OwnerUserID: 77,
HostUserID: 77,
SeatCount: 4,
Mode: "social",
Status: "active",
}
if err := repository.SaveRoomMeta(ctx, first); err != nil {
t.Fatalf("save first room meta failed: %v", err)
}
second := first
second.RoomID = "room-owner-unique-b"
if err := repository.SaveRoomMeta(ctx, second); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("duplicate owner should map to conflict, got %v", err)
}
}
func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-list-region-a"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Region A Room",
RoomAvatar: "https://cdn.example.com/room-a.png",
VisibleRegionId: 1001,
}); err != nil {
t.Fatalf("CreateRoom region 1001 failed: %v", err)
}
usedClock.now = usedClock.now.Add(time.Second)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room-list-region-b", 3),
SeatCount: 4,
Mode: "social",
RoomName: "Region B Room",
VisibleRegionId: 2002,
}); err != nil {
t.Fatalf("CreateRoom region 2002 failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp failed: %v", err)
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "rose",
GiftCount: 1,
}); err != nil {
t.Fatalf("SendGift failed: %v", err)
}
listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
ViewerUserId: 99,
VisibleRegionId: 1001,
Tab: "hot",
Limit: 20,
})
if err != nil {
t.Fatalf("ListRooms region 1001 failed: %v", err)
}
if len(listResp.GetRooms()) != 1 {
t.Fatalf("region list should only expose matching rooms: %+v", listResp.GetRooms())
}
item := listResp.GetRooms()[0]
if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 {
t.Fatalf("room list item identity mismatch: %+v", item)
}
if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 4 {
t.Fatalf("room list projection did not track Room Cell state: %+v", item)
}
if item.GetTitle() != "Region A Room" || item.GetCoverUrl() != "https://cdn.example.com/room-a.png" {
t.Fatalf("room list should project RoomExt title/cover_url: %+v", item)
}
otherRegionResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
ViewerUserId: 99,
VisibleRegionId: 3003,
Tab: "hot",
Limit: 20,
})
if err != nil {
t.Fatalf("ListRooms other region failed: %v", err)
}
if len(otherRegionResp.GetRooms()) != 0 {
t.Fatalf("region isolation should not leak rooms: %+v", otherRegionResp.GetRooms())
}
}
func TestCreateRoomRejectsInvalidRoomID(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta("room:1001", 42),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("CreateRoom should reject invalid room_id, got %v", err)
}
if _, exists, getErr := repository.GetRoomMeta(ctx, "room:1001"); getErr != nil || exists {
t.Fatalf("invalid room_id must not create room meta: exists=%v err=%v", exists, getErr)
}
}
func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) {
tests := []struct {
name string
req *roomv1.CreateRoomRequest
}{
{name: "seat_count", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-seat", 42), Mode: "social", RoomName: "Test Room"}},
{name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 4, RoomName: "Test Room"}},
{name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 4, Mode: "social"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), mysqltest.NewRepository(t), &fakeSyncPublisher{}, &fakeOutboxPublisher{})
_, err := svc.CreateRoom(context.Background(), test.req)
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("CreateRoom should reject missing %s, got %v", test.name, err)
}
})
}
}
// TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。
func TestRoomLifecycleGiftAndGuards(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
syncPublisher := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
roomID := "room-1001"
// 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
})
if err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if !createResp.GetResult().GetApplied() {
t.Fatalf("CreateRoom should apply")
}
// JoinRoom 只进入 room-service 业务 presence不涉及腾讯云 IM 连接态。
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
// MicUp 验证 Room Cell 内麦位占用和版本变更。
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
SeatNo: 1,
})
if err != nil {
t.Fatalf("MicUp failed: %v", err)
}
if micResp.GetSeatNo() != 1 {
t.Fatalf("MicUp seat mismatch: got %d", micResp.GetSeatNo())
}
// SendGift 先走 fakeWallet再更新热度和房间内本地礼物榜。
giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "rose",
GiftCount: 2,
})
if err != nil {
t.Fatalf("SendGift failed: %v", err)
}
if got := giftResp.GetRoomHeat(); got != 20 {
t.Fatalf("room heat mismatch: got %d want 20", got)
}
if len(giftResp.GetGiftRank()) != 1 || giftResp.GetGiftRank()[0].GetUserId() != 1 {
t.Fatalf("gift rank mismatch: %+v", giftResp.GetGiftRank())
}
if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_gift_sent" {
t.Fatalf("expected room_gift_sent sync publish, got %+v", syncPublisher.events)
}
// MuteUser 后 CheckSpeakPermission 必须拒绝外部 IM 的公屏发送请求。
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
Muted: true,
}); err != nil {
t.Fatalf("MuteUser failed: %v", err)
}
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{
RoomId: roomID,
UserId: 2,
})
if err != nil {
t.Fatalf("CheckSpeakPermission failed: %v", err)
}
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
t.Fatalf("unexpected speak guard response: %+v", speakResp)
}
// KickUser 后 VerifyRoomPresence 必须拒绝外部 IM 的进群请求。
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
}); err != nil {
t.Fatalf("KickUser failed: %v", err)
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
RoomId: roomID,
UserId: 2,
})
if err != nil {
t.Fatalf("VerifyRoomPresence failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("unexpected presence guard response: %+v", presenceResp)
}
}
func TestRoomManagementPermissionsAndHostFlow(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-management-flow"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom user3 failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp user2 failed: %v", err)
}
_, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("audience should not mic down others, got %v", err)
}
adminResp, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 3,
Enabled: true,
})
if err != nil {
t.Fatalf("SetRoomAdmin failed: %v", err)
}
if !containsInt64(adminResp.GetRoom().GetAdminUserIds(), 3) {
t.Fatalf("admin ids should contain user3: %+v", adminResp.GetRoom().GetAdminUserIds())
}
if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}); err != nil {
t.Fatalf("admin MicDown failed: %v", err)
}
hostResp, err := svc.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
})
if err != nil {
t.Fatalf("TransferRoomHost failed: %v", err)
}
if hostResp.GetRoom().GetHostUserId() != 2 || !containsInt64(hostResp.GetRoom().GetAdminUserIds(), 2) {
t.Fatalf("host transfer should update host and admin set: %+v", hostResp.GetRoom())
}
_, err = svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: roomservice.NewRequestMeta(roomID, 3),
TargetUserId: 2,
Muted: true,
})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("admin should not mute current host, got %v", err)
}
if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 2), Enabled: false}); err != nil {
t.Fatalf("host SetChatEnabled failed: %v", err)
}
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 1})
if err != nil {
t.Fatalf("CheckSpeakPermission failed: %v", err)
}
if speakResp.GetAllowed() || speakResp.GetReason() != "chat_disabled" {
t.Fatalf("chat disabled should block all users: %+v", speakResp)
}
}
func TestMicPublishingConfirmAndStaleEvents(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-publish-confirm"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("MicUp failed: %v", err)
}
seat := findSeat(micResp.GetRoom(), 1)
if seat == nil || seat.GetUserId() != 2 || seat.GetPublishState() != "pending_publish" || seat.GetMicSessionId() == "" {
t.Fatalf("MicUp should create pending_publish session: seat=%+v", seat)
}
if micResp.GetMicSessionId() != seat.GetMicSessionId() || micResp.GetPublishDeadlineMs() != seat.GetPublishDeadlineMs() {
t.Fatalf("MicUp response should expose current mic session: resp=%+v seat=%+v", micResp, seat)
}
if seat.GetLastPublishEventTimeMs() != 0 {
t.Fatalf("new pending session should not pre-fill publish event time: seat=%+v", seat)
}
confirmEventTime := usedClock.now.Add(-time.Second).UnixMilli()
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
MicSessionId: micResp.GetMicSessionId(),
RoomVersion: micResp.GetResult().GetRoomVersion(),
EventTimeMs: confirmEventTime,
Source: "client",
})
if err != nil {
t.Fatalf("ConfirmMicPublishing failed: %v", err)
}
seat = findSeat(confirmResp.GetRoom(), 1)
if seat == nil || seat.GetPublishState() != "publishing" || seat.GetLastPublishEventTimeMs() != confirmEventTime {
t.Fatalf("confirm should move seat to publishing: seat=%+v", seat)
}
staleResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
MicSessionId: micResp.GetMicSessionId(),
RoomVersion: micResp.GetResult().GetRoomVersion(),
EventTimeMs: confirmEventTime,
Source: "rtc_webhook",
})
if err != nil {
t.Fatalf("stale ConfirmMicPublishing should be ignored without error: %v", err)
}
if staleResp.GetResult().GetApplied() {
t.Fatalf("stale confirmation must not advance room version")
}
}
func TestAudienceCanChangeOwnMicSeat(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-self-change-mic"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp user2 failed: %v", err)
}
changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 2,
SeatNo: 3,
})
if err != nil {
t.Fatalf("audience should be able to change own mic seat: %v", err)
}
if from := findSeat(changeResp.GetRoom(), 1); from == nil || from.GetUserId() != 0 {
t.Fatalf("source seat should be empty after switch: %+v", from)
}
if to := findSeat(changeResp.GetRoom(), 3); to == nil || to.GetUserId() != 2 {
t.Fatalf("target seat should contain user2 after switch: %+v", to)
}
}
func TestApplyRTCEventConfirmsAudioAndClearsOnStopOrExit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
syncPublisher := &fakeSyncPublisher{}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-rtc-event"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("MicUp user2 failed: %v", err)
}
startEventTime := usedClock.now.Add(time.Second).UnixMilli()
startResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 2,
EventType: "audio_started",
EventTimeMs: startEventTime,
Source: "tencent_rtc_callback",
ExternalEventId: "rtc_evt_start",
})
if err != nil {
t.Fatalf("ApplyRTCEvent audio_started failed: %v", err)
}
seat := findSeat(startResp.GetRoom(), 1)
if seat == nil || seat.GetPublishState() != "publishing" || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetLastPublishEventTimeMs() != startEventTime {
t.Fatalf("RTC audio_started should confirm current mic session: seat=%+v", seat)
}
stopResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 2,
EventType: "audio_stopped",
EventTimeMs: usedClock.now.Add(2 * time.Second).UnixMilli(),
Reason: "rtc_audio_stopped:0",
Source: "tencent_rtc_callback",
})
if err != nil {
t.Fatalf("ApplyRTCEvent audio_stopped failed: %v", err)
}
if seat := findSeat(stopResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
t.Fatalf("RTC audio_stopped should release mic seat: seat=%+v", seat)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom user3 failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 3), SeatNo: 2}); err != nil {
t.Fatalf("MicUp user3 failed: %v", err)
}
exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 3),
TargetUserId: 3,
EventType: "room_exited",
EventTimeMs: usedClock.now.Add(3 * time.Second).UnixMilli(),
Reason: "rtc_room_exited:2",
Source: "tencent_rtc_callback",
})
if err != nil {
t.Fatalf("ApplyRTCEvent room_exited failed: %v", err)
}
if user := findRoomUser(exitResp.GetRoom(), 3); user != nil {
t.Fatalf("RTC room_exited should remove business presence: user=%+v", user)
}
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_user_left" {
t.Fatalf("RTC room_exited should publish room_user_left: %+v", syncPublisher.events)
}
}
func TestOldRTCRoomExitedDoesNotClearRejoinedUserOrNewMicSession(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-rtc-exit-replay"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("first MicUp failed: %v", err)
}
oldExitEventTime := usedClock.now.Add(time.Second).UnixMilli()
usedClock.now = usedClock.now.Add(2 * time.Second)
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("LeaveRoom failed: %v", err)
}
usedClock.now = usedClock.now.Add(2 * time.Second)
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("rejoin user2 failed: %v", err)
}
newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2})
if err != nil {
t.Fatalf("second MicUp failed: %v", err)
}
if newMic.GetMicSessionId() == oldMic.GetMicSessionId() {
t.Fatalf("rejoined MicUp must create new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId())
}
exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 2,
EventType: "room_exited",
EventTimeMs: oldExitEventTime,
Reason: "rtc_room_exited:old",
Source: "tencent_rtc_callback",
ExternalEventId: "rtc_evt_old_exit",
})
if err != nil {
t.Fatalf("old room_exited should be ignored without error: %v", err)
}
if exitResp.GetResult().GetApplied() {
t.Fatalf("old room_exited should be a no-op: %+v", exitResp.GetResult())
}
if user := findRoomUser(exitResp.GetRoom(), 2); user == nil {
t.Fatalf("old room_exited must not remove rejoined user")
}
if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() {
t.Fatalf("old room_exited must not clear new mic session: seat=%+v new=%+v old=%+v", seat, newMic, oldMic)
}
}
func 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", 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())
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
t.Fatalf("RoomHeartbeat owner before mic timeout sweep failed: %v", err)
}
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
}
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)})
if err != nil {
t.Fatalf("JoinRoom refresh failed: %v", err)
}
if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
t.Fatalf("publish timeout should release seat and session: %+v", seat)
}
if len(syncPublisher.events) == 0 {
t.Fatalf("expected timeout mic_down event")
}
last := syncPublisher.events[len(syncPublisher.events)-1]
if last.EventType != "room_mic_down" || last.Attributes["reason"] != "publish_timeout" || last.Attributes["mic_session_id"] != micResp.GetMicSessionId() {
t.Fatalf("timeout should publish mic_down with session reason: %+v", last)
}
}
func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-old-session"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("first MicUp failed: %v", err)
}
if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("MicDown failed: %v", err)
}
usedClock.now = usedClock.now.Add(time.Second)
newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("second MicUp failed: %v", err)
}
if newMic.GetMicSessionId() == oldMic.GetMicSessionId() {
t.Fatalf("new MicUp must create a new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId())
}
usedClock.now = time.UnixMilli(oldMic.GetPublishDeadlineMs())
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
t.Fatalf("old timeout sweep failed: %v", err)
}
staleConfirm, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
MicSessionId: oldMic.GetMicSessionId(),
RoomVersion: oldMic.GetResult().GetRoomVersion(),
EventTimeMs: usedClock.now.Add(time.Second).UnixMilli(),
Source: "rtc_webhook",
})
if err != nil {
t.Fatalf("old session confirm should be ignored without error: %v", err)
}
seat := findSeat(staleConfirm.GetRoom(), 1)
if seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() || seat.GetPublishState() != "pending_publish" {
t.Fatalf("old session event must not clear new MicUp: seat=%+v new=%+v old=%+v", seat, newMic, oldMic)
}
}
func TestMicSeatLockAndNoopIdempotency(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
syncPublisher := &fakeSyncPublisher{fail: true}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
roomID := "room-mic-lock"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 3, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
lockResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatNo: 2,
Locked: true,
})
if err != nil {
t.Fatalf("SetMicSeatLock failed: %v", err)
}
if seat := findSeat(lockResp.GetRoom(), 2); seat == nil || !seat.GetLocked() {
t.Fatalf("seat should be locked: %+v", seat)
}
if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 {
t.Fatalf("lock should write one outbox event, got %d", got)
}
repeatResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatNo: 2,
Locked: true,
})
if err != nil {
t.Fatalf("repeat SetMicSeatLock failed: %v", err)
}
if repeatResp.GetResult().GetApplied() {
t.Fatalf("repeat lock should be a no-op")
}
if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 {
t.Fatalf("repeat lock should not write duplicate outbox, got %d", got)
}
_, err = svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("locked seat MicUp should be denied, got %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp unlocked seat failed: %v", err)
}
_, err = svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 1, Locked: true})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("locking occupied seat should conflict, got %v", err)
}
}
func TestKickUnbanAndAdminRemoval(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-ban-unban"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom user3 failed: %v", err)
}
if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil {
t.Fatalf("SetRoomAdmin user2 failed: %v", err)
}
if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3, Enabled: true}); err != nil {
t.Fatalf("SetRoomAdmin user3 failed: %v", err)
}
kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2})
if err != nil {
t.Fatalf("KickUser failed: %v", err)
}
if !containsInt64(kickResp.GetRoom().GetBanUserIds(), 2) || containsInt64(kickResp.GetRoom().GetAdminUserIds(), 2) {
t.Fatalf("kick should ban user2 and remove admin: %+v", kickResp.GetRoom())
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("banned user should not join, got %v", err)
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2})
if err != nil {
t.Fatalf("VerifyRoomPresence failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("banned user should fail presence guard: %+v", presenceResp)
}
_, err = svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("admin should not unban, got %v", err)
}
unbanResp, err := svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2})
if err != nil {
t.Fatalf("owner UnbanUser failed: %v", err)
}
if containsInt64(unbanResp.GetRoom().GetBanUserIds(), 2) || containsInt64(unbanResp.GetRoom().GetAdminUserIds(), 2) {
t.Fatalf("unban should not restore ban or admin state: %+v", unbanResp.GetRoom())
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("unbanned user should join again: %v", err)
}
}
func TestJoinRoomRejectsClosedRoom(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-closed"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
repository.SetRoomStatus(roomID, "closed")
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.RoomClosed) {
t.Fatalf("closed room should reject JoinRoom with ROOM_CLOSED, got %v", err)
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 1})
if err != nil {
t.Fatalf("VerifyRoomPresence failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "room_closed" {
t.Fatalf("closed room should fail presence guard: %+v", presenceResp)
}
}
func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
syncPublisher := &fakeSyncPublisher{}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
roomID := "room-close-command"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Close Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp failed: %v", err)
}
closeResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Reason: "owner_closed"})
if err != nil {
t.Fatalf("CloseRoom failed: %v", err)
}
if closeResp.GetRoom().GetStatus() != "closed" || len(closeResp.GetRoom().GetOnlineUsers()) != 0 {
t.Fatalf("closed room should clear presence: %+v", closeResp.GetRoom())
}
if seat := findSeat(closeResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
t.Fatalf("closed room should release seats: %+v", seat)
}
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
if err != nil || !exists || meta.Status != "closed" {
t.Fatalf("room meta should be closed: exists=%v meta=%+v err=%v", exists, meta, err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.RoomClosed) {
t.Fatalf("closed room should reject JoinRoom, got %v", err)
}
if !hasSyncEventType(syncPublisher.events, "room_closed") {
t.Fatalf("CloseRoom should publish room_closed event: %+v", syncPublisher.events)
}
}
func TestDrainingRejectsNewRoomCommands(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-draining"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Drain Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
svc.MarkDraining()
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("draining service should reject new JoinRoom, got %v", err)
}
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 4, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("draining service should reject new CreateRoom, got %v", err)
}
}
func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-command-conflict"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
firstMeta := roomservice.NewRequestMeta(roomID, 1)
firstMeta.CommandId = "cmd-chat-disable-fixed"
firstMeta.RequestId = "req-first"
if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: firstMeta, Enabled: false}); err != nil {
t.Fatalf("first SetChatEnabled failed: %v", err)
}
retryMeta := roomservice.NewRequestMeta(roomID, 1)
retryMeta.CommandId = firstMeta.GetCommandId()
retryMeta.RequestId = "req-retry"
retryMeta.SessionId = "different-session"
retryMeta.SentAtMs = firstMeta.GetSentAtMs() + 1000
retryResp, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: false})
if err != nil {
t.Fatalf("same command with different trace fields should be idempotent: %v", err)
}
if retryResp.GetResult().GetApplied() {
t.Fatalf("idempotent retry should not apply again")
}
_, err = svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: true})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("same command_id with different business payload should conflict, got %v", err)
}
}
func TestRoomManagementRecoveryReplay(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-management-replay"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom user3 failed: %v", err)
}
if _, err := serviceA.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 4, Locked: true}); err != nil {
t.Fatalf("SetMicSeatLock failed: %v", err)
}
if _, err := serviceA.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Enabled: false}); err != nil {
t.Fatalf("SetChatEnabled failed: %v", err)
}
if _, err := serviceA.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil {
t.Fatalf("SetRoomAdmin failed: %v", err)
}
if _, err := serviceA.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}); err != nil {
t.Fatalf("TransferRoomHost failed: %v", err)
}
if _, err := serviceA.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil {
t.Fatalf("KickUser failed: %v", err)
}
if _, err := serviceA.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil {
t.Fatalf("UnbanUser failed: %v", err)
}
directory.ForceExpire(roomRouteKeyForTest(roomID))
serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
if err != nil {
t.Fatalf("JoinRoom on recovered service failed: %v", err)
}
snapshot := joinResp.GetRoom()
if snapshot.GetHostUserId() != 2 || !containsInt64(snapshot.GetAdminUserIds(), 2) {
t.Fatalf("host/admin state did not replay: %+v", snapshot)
}
if snapshot.GetChatEnabled() {
t.Fatalf("chat_enabled should replay as false")
}
if seat := findSeat(snapshot, 4); seat == nil || !seat.GetLocked() {
t.Fatalf("locked seat did not replay: %+v", seat)
}
if containsInt64(snapshot.GetBanUserIds(), 3) {
t.Fatalf("unban should replay after kick: %+v", snapshot.GetBanUserIds())
}
}
// TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent 验证重连只刷新 presence不重复广播进房。
func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-repeat-join"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("first JoinRoom failed: %v", err)
}
beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined")
usedClock.now = usedClock.now.Add(5 * time.Second)
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
})
if err != nil {
t.Fatalf("repeat JoinRoom failed: %v", err)
}
user := findRoomUser(joinResp.GetRoom(), 2)
if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() {
t.Fatalf("last_seen was not refreshed: user=%+v now=%d", user, usedClock.now.UnixMilli())
}
if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents {
t.Fatalf("repeat JoinRoom should not create duplicate join outbox: before=%d after=%d", beforeJoinEvents, after)
}
}
func TestRoomHeartbeatRefreshesExistingPresenceOnly(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-heartbeat"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined")
usedClock.now = usedClock.now.Add(30 * time.Second)
heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 2)})
if err != nil {
t.Fatalf("RoomHeartbeat failed: %v", err)
}
user := findRoomUser(heartbeatResp.GetRoom(), 2)
if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() {
t.Fatalf("heartbeat should refresh last_seen only: user=%+v now=%d", user, usedClock.now.UnixMilli())
}
if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents {
t.Fatalf("heartbeat must not create join outbox: before=%d after=%d", beforeJoinEvents, after)
}
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("heartbeat must not create missing presence, got %v", err)
}
}
// TestLeaveRoomReleasesSeatAndBlocksGuards 验证离房同时移除 presence 和麦位占用。
func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-leave-seat"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp failed: %v", err)
}
leaveResp, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)})
if err != nil {
t.Fatalf("LeaveRoom failed: %v", err)
}
if findRoomUser(leaveResp.GetRoom(), 2) != nil {
t.Fatalf("left user should not remain in presence: %+v", leaveResp.GetRoom().GetOnlineUsers())
}
if seat := findSeat(leaveResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
t.Fatalf("leave should release seat: %+v", seat)
}
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
if err != nil {
t.Fatalf("CheckSpeakPermission failed: %v", err)
}
if speakResp.GetAllowed() || speakResp.GetReason() != "not_in_room" {
t.Fatalf("unexpected speak response after leave: %+v", speakResp)
}
}
// TestRoomRecoveryAfterLeaseTakeover 验证 lease 过期后新节点通过 snapshot + command log 恢复房间。
func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
syncPublisherA := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
serviceA := newRoomService("node-a", 2, directory, repository, syncPublisherA, outboxPublisher)
roomID := "room-recover"
// node-a 先创建并推进房间状态snapshotEveryN=2 会让恢复需要结合快照和命令日志。
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "rose",
GiftCount: 1,
}); err != nil {
t.Fatalf("SendGift failed: %v", err)
}
// 强制过期模拟 node-a 故障后 Redis lease 失效。
directory.ForceExpire(roomRouteKeyForTest(roomID))
// node-b 收到命令时应先接管 lease再恢复 Room Cell而不是返回 room not found。
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 3),
Role: "audience",
})
if err != nil {
t.Fatalf("JoinRoom on takeover node failed: %v", err)
}
if got := joinResp.GetRoom().GetHeat(); got != 10 {
t.Fatalf("recovered heat mismatch: got %d want 10", got)
}
if len(joinResp.GetRoom().GetGiftRank()) != 1 || joinResp.GetRoom().GetGiftRank()[0].GetUserId() != 1 {
t.Fatalf("recovered rank mismatch: %+v", joinResp.GetRoom().GetGiftRank())
}
lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID))
if err != nil || !exists {
t.Fatalf("Lookup failed: %v exists=%v", err, exists)
}
if lease.NodeID != "node-b" {
t.Fatalf("lease takeover failed: owner=%s", lease.NodeID)
}
}
func TestLeaseFencingRejectsOldOwnerAfterSlowWalletCommand(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
syncPublisherA := &fakeSyncPublisher{}
outboxPublisher := &fakeOutboxPublisher{}
serviceB := newRoomService("node-b", 1, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
serviceA := roomservice.New(roomservice.Config{
NodeID: "node-a",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, directory, repository, takeoverWallet{
roomID: "room-lease-fencing",
directory: directory,
takeoverSvc: serviceB,
}, syncPublisherA, outboxPublisher)
roomID := "room-lease-fencing"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Fence Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
_, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
TargetUserId: 1,
GiftId: "rose",
GiftCount: 1,
})
if !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("old owner should fail fencing before room commit, got %v", err)
}
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
if err != nil {
t.Fatalf("new owner should keep serving room: %v", err)
}
if joinResp.GetRoom().GetHeat() != 0 {
t.Fatalf("fenced SendGift must not update room heat: %+v", joinResp.GetRoom())
}
}
// TestGuardRecoveryReplaysCommandsAfterSnapshot 验证无内存 Cell 的守卫查询会补回快照后的 command log。
func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
serviceA := newRoomService("node-a", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-guard-recover"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := serviceA.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
Muted: true,
}); err != nil {
t.Fatalf("MuteUser failed: %v", err)
}
// 新服务没有内存 CellCheckSpeakPermission 必须从 v2 快照后回放 v3 禁言命令。
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
speakResp, err := serviceB.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2})
if err != nil {
t.Fatalf("CheckSpeakPermission failed: %v", err)
}
if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" {
t.Fatalf("guard did not replay mute command: %+v", speakResp)
}
}
// TestSweepStalePresenceRemovesUserAndReleasesSeat 验证 stale worker 复用离房链路清理断线用户。
func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
syncPublisher := &fakeSyncPublisher{}
svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-stale"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
t.Fatalf("MicUp failed: %v", err)
}
usedClock.now = usedClock.now.Add(30 * time.Second)
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
t.Fatalf("refresh owner JoinRoom failed: %v", err)
}
usedClock.now = usedClock.now.Add(40 * time.Second)
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
t.Fatalf("RoomHeartbeat owner before stale sweep failed: %v", err)
}
if err := svc.SweepStalePresence(ctx); err != nil {
t.Fatalf("SweepStalePresence failed: %v", err)
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2})
if err != nil {
t.Fatalf("VerifyRoomPresence failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "not_in_room" {
t.Fatalf("stale user should be removed: %+v", presenceResp)
}
snapshot, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)})
if err != nil {
t.Fatalf("JoinRoom after stale sweep failed: %v", err)
}
if seat := findSeat(snapshot.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 {
t.Fatalf("stale sweep should release seat: %+v", seat)
}
if !hasSyncEventReason(syncPublisher.events, "stale_timeout") {
t.Fatalf("stale sweep should publish room_user_left with reason: %+v", syncPublisher.events)
}
}
func TestSweepStalePresenceSkipsRoomAfterLeaseTakeover(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-stale-lease-takeover"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
usedClock.now = usedClock.now.Add(2 * time.Minute)
directory.ForceExpire(roomRouteKeyForTest(roomID))
if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom takeover failed: %v", err)
}
if err := serviceA.SweepStalePresence(ctx); err != nil {
t.Fatalf("old owner stale sweep failed: %v", err)
}
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
if err != nil {
t.Fatalf("JoinRoom on current owner failed: %v", err)
}
if user := findRoomUser(joinResp.GetRoom(), 2); user == nil {
t.Fatalf("old owner stale sweep must not remove users after takeover: %+v", joinResp.GetRoom().GetOnlineUsers())
}
}
func TestSweepMicPublishTimeoutSkipsRoomAfterLeaseTakeover(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-timeout-lease-takeover"
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom user2 failed: %v", err)
}
micResp, err := serviceA.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("MicUp failed: %v", err)
}
usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs())
directory.ForceExpire(roomRouteKeyForTest(roomID))
if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
t.Fatalf("JoinRoom takeover failed: %v", err)
}
if err := serviceA.SweepMicPublishTimeouts(ctx); err != nil {
t.Fatalf("old owner mic timeout sweep failed: %v", err)
}
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
if err != nil {
t.Fatalf("JoinRoom on current owner failed: %v", err)
}
if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetPublishState() != "pending_publish" {
t.Fatalf("old owner mic timeout sweep must not clear current owner's seat: seat=%+v mic=%+v", seat, micResp)
}
}
// TestRoomOutboxCompensationWhenSyncPublishFails 验证同步广播失败不回滚房间状态outbox 可补偿。
func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
directory := router.NewMemoryDirectory()
syncPublisher := &fakeSyncPublisher{fail: true}
outboxPublisher := &fakeOutboxPublisher{}
svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher)
roomID := "room-outbox"
// 创建和进房先建立可送礼的基本房间态。
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
Role: "audience",
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
TargetUserId: 2,
GiftId: "car",
GiftCount: 1,
}); err != nil {
t.Fatalf("SendGift failed: %v", err)
}
// fakeSyncPublisher 失败后,房间事件仍应作为 pending outbox 记录保留。
pendingBefore, err := repository.ListPendingOutbox(ctx, 100)
if err != nil {
t.Fatalf("ListPendingOutbox failed: %v", err)
}
if len(pendingBefore) == 0 {
t.Fatalf("expected pending outbox records before compensation")
}
// 补偿 worker 成功投递后 fakeOutboxPublisher 应记录事件信封。
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil {
t.Fatalf("ProcessPendingOutbox failed: %v", err)
}
if len(outboxPublisher.envelopes) == 0 {
t.Fatalf("expected compensated outbox publish")
}
if remaining, err := repository.ListPendingOutbox(ctx, 100); err != nil {
t.Fatalf("ListPendingOutbox after compensation failed: %v", err)
} else if len(remaining) != 0 {
t.Fatalf("expected all pending outbox records to be marked delivered, got %d", len(remaining))
}
}
func TestOutboxWorkerRetriesFailedPublishAndLaterMarksDelivered(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
outboxPublisher := &fakeOutboxPublisher{failCount: 1, err: errors.New("temporary im failure")}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher)
record, err := outbox.Build("room-retry", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 1,
TargetUserId: 2,
GiftId: "car",
GiftCount: 1,
GiftValue: 100,
})
if err != nil {
t.Fatalf("Build outbox failed: %v", err)
}
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
t.Fatalf("SaveOutbox failed: %v", err)
}
options := roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second}
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
t.Fatalf("first ProcessPendingOutbox should keep batch alive after publish failure: %v", err)
}
failed, exists := repository.OutboxRecord(record.EventID)
if !exists {
t.Fatalf("outbox record missing after failure")
}
if failed.Status != outbox.StatusRetryable || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") {
t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed)
}
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
t.Fatalf("second ProcessPendingOutbox failed: %v", err)
}
delivered, exists := repository.OutboxRecord(record.EventID)
if !exists {
t.Fatalf("outbox record missing after success")
}
if delivered.Status != outbox.StatusDelivered || delivered.LastError != "" || len(outboxPublisher.envelopes) != 1 {
t.Fatalf("second publish should mark record delivered: record=%+v envelopes=%d", delivered, len(outboxPublisher.envelopes))
}
}
func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
record, err := outbox.Build("room-claim", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 1,
TargetUserId: 2,
GiftId: "rose",
GiftCount: 1,
GiftValue: 10,
})
if err != nil {
t.Fatalf("Build outbox failed: %v", err)
}
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
t.Fatalf("SaveOutbox failed: %v", err)
}
claimedA, err := repository.ClaimPendingOutbox(ctx, "worker-a", 10, time.Now().Add(time.Minute))
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))
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))
if err != nil {
t.Fatalf("retry claim failed: %v", err)
}
if len(claimedRetry) != 1 || claimedRetry[0].Status != outbox.StatusDelivering || claimedRetry[0].RetryCount != 1 {
t.Fatalf("retryable record should be claimable again: %+v", claimedRetry)
}
}
func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
syncPublisher := &fakeSyncPublisher{}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
roomID := "room-sync-delivered"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "social",
RoomName: "Test Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 2),
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if len(syncPublisher.events) == 0 {
t.Fatalf("expected sync room event")
}
eventID := syncPublisher.events[len(syncPublisher.events)-1].EventID
record, exists := repository.OutboxRecord(eventID)
if !exists {
t.Fatalf("sync event outbox record missing: %s", eventID)
}
if record.Status != outbox.StatusDelivered {
t.Fatalf("sync success should best-effort mark outbox delivered: %+v", record)
}
}
func TestRunOutboxWorkerExitsOnCancel(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
svc.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: 10 * time.Millisecond,
BatchSize: 10,
PublishTimeout: time.Second,
})
}()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("outbox worker did not exit after cancel")
}
}
func countPendingOutboxType(t *testing.T, repository roomservice.Repository, eventType string) int {
t.Helper()
records, err := repository.ListPendingOutbox(context.Background(), 0)
if err != nil {
t.Fatalf("ListPendingOutbox failed: %v", err)
}
count := 0
for _, record := range records {
if record.EventType == eventType {
count++
}
}
return count
}
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
return user
}
}
return nil
}
func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
for _, seat := range snapshot.GetMicSeats() {
if seat.GetSeatNo() == seatNo {
return seat
}
}
return nil
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {
for _, event := range events {
if event.EventType == "room_user_left" && event.Attributes["reason"] == reason {
return true
}
}
return false
}
func hasSyncEventType(events []tencentim.RoomEvent, eventType string) bool {
for _, event := range events {
if event.EventType == eventType {
return true
}
}
return false
}