404 lines
13 KiB
Go

package service_test
import (
"context"
"fmt"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
roomoutbox "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"
)
type fakeRTCUserRemover struct {
calls []rtcRemoveCall
roomID string
userID int64
}
type rtcRemoveCall struct {
roomID string
userID int64
}
func (r *fakeRTCUserRemover) RemoveUserByStrRoomID(_ context.Context, roomID string, userID int64) error {
r.roomID = roomID
r.userID = userID
r.calls = append(r.calls, rtcRemoveCall{roomID: roomID, userID: userID})
return nil
}
func TestKickUserRemovesPresenceBanAndRTCConnection(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
rtc := &fakeRTCUserRemover{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-kick-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
RTCUserRemover: rtc,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-kick-flow"
ownerID := int64(7101)
viewerID := int64(7201)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Kick Flow",
RoomAvatar: testRoomCoverURL,
RoomShortId: "kick-flow",
}); err != nil {
t.Fatalf("create kick room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join kick target failed: %v", err)
}
kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: viewerID,
})
if err != nil {
t.Fatalf("kick user failed: %v", err)
}
if !kickResp.GetRtcKicked() || kickResp.GetRtcKickError() != "" || rtc.roomID != roomID || rtc.userID != viewerID {
t.Fatalf("kick must remove RTC user after Room Cell commit: resp=%+v rtc=%+v", kickResp, rtc)
}
if !containsInt64(kickResp.GetRoom().GetBanUserIds(), viewerID) || findRoomUser(kickResp.GetRoom(), viewerID) != nil {
t.Fatalf("kick response must ban and remove target presence: %+v", kickResp.GetRoom())
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
AppCode: appcode.Default,
RoomId: roomID,
UserId: viewerID,
})
if err != nil {
t.Fatalf("verify room presence after kick failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("kicked user must not pass IM presence guard: %+v", presenceResp)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err == nil {
t.Fatalf("kicked user must not join before unban")
}
}
func TestKickUserDurationExpiresAndAllowsJoin(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 28, 8, 0, 0, 0, time.UTC)}
svc := roomservice.New(roomservice.Config{
NodeID: "node-kick-duration-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: now,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-kick-duration"
ownerID := int64(7301)
viewerID := int64(7401)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Kick Duration",
RoomAvatar: testRoomCoverURL,
RoomShortId: "kick-duration",
}); err != nil {
t.Fatalf("create duration room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join duration target failed: %v", err)
}
kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: viewerID,
DurationMs: 1000,
})
if err != nil {
t.Fatalf("duration kick failed: %v", err)
}
if len(kickResp.GetRoom().GetBanStates()) != 1 || kickResp.GetRoom().GetBanStates()[0].GetExpiresAtMs() != now.Now().Add(time.Second).UnixMilli() {
t.Fatalf("duration kick must persist ban expiration: %+v", kickResp.GetRoom().GetBanStates())
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err == nil {
t.Fatalf("target must not join before kick duration expires")
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
AppCode: appcode.Default,
RoomId: roomID,
UserId: viewerID,
})
if err != nil {
t.Fatalf("verify presence before expiration failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("active duration ban must reject IM guard: %+v", presenceResp)
}
now.now = now.now.Add(2 * time.Second)
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("target must join after kick duration expires: %v", err)
}
}
func TestListRoomBannedUsersReturnsActiveOnlyForManagers(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)}
svc := roomservice.New(roomservice.Config{
NodeID: "node-banned-users-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: now,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-banned-users"
ownerID := int64(7501)
expiredID := int64(7502)
activeID := int64(7503)
normalID := int64(7504)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Banned Users",
RoomAvatar: testRoomCoverURL,
RoomShortId: "banned-users",
}); err != nil {
t.Fatalf("create banned users room failed: %v", err)
}
for _, userID := range []int64{expiredID, activeID, normalID} {
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, userID),
Role: "audience",
}); err != nil {
t.Fatalf("join fixture user %d failed: %v", userID, err)
}
}
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: expiredID,
DurationMs: 1000,
}); err != nil {
t.Fatalf("kick expired fixture user failed: %v", err)
}
now.now = now.now.Add(2 * time.Second)
activeCreatedAt := now.Now().UnixMilli()
activeUnbanAt := now.Now().Add(10 * time.Second).UnixMilli()
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: activeID,
DurationMs: 10_000,
}); err != nil {
t.Fatalf("kick active fixture user failed: %v", err)
}
if _, err := svc.ListRoomBannedUsers(ctx, &roomv1.ListRoomBannedUsersRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: normalID,
Page: 1,
PageSize: 10,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("non-manager list banned users must be denied: %v", err)
}
resp, err := svc.ListRoomBannedUsers(ctx, &roomv1.ListRoomBannedUsersRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: ownerID,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("owner list banned users failed: %v", err)
}
if resp.GetTotal() != 1 || len(resp.GetItems()) != 1 {
t.Fatalf("only active ban must be returned: %+v", resp)
}
item := resp.GetItems()[0]
if item.GetUserId() != activeID || item.GetActorUserId() != ownerID || item.GetCreatedAtMs() != activeCreatedAt || item.GetUnbanAtMs() != activeUnbanAt || item.GetRemainingMs() != 10_000 || item.GetPermanent() {
t.Fatalf("active ban item mismatch: %+v", item)
}
}
func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
rtc := &fakeRTCUserRemover{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-close-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
RTCUserRemover: rtc,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-close-flow"
ownerID := int64(8101)
viewerID := int64(8201)
adminID := int64(1)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Close Flow",
RoomAvatar: testRoomCoverURL,
RoomShortId: "close-flow",
}); err != nil {
t.Fatalf("create close room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join close target failed: %v", err)
}
resp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-close-admin",
CommandId: "cmd-close-admin",
ActorUserId: adminID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Now().UnixMilli(),
},
Reason: "admin_closed",
})
if err != nil {
t.Fatalf("admin close room failed: %v", err)
}
if resp.GetRoom().GetStatus() != "closed" || len(resp.GetRoom().GetOnlineUsers()) != 0 {
t.Fatalf("admin close response must close room and clear users: %+v", resp.GetRoom())
}
if !hasRTCRemoveCall(rtc.calls, roomID, ownerID) || !hasRTCRemoveCall(rtc.calls, roomID, viewerID) {
t.Fatalf("admin close must remove all current RTC users: %+v", rtc.calls)
}
records, err := repository.ListPendingOutbox(ctx, 20)
if err != nil {
t.Fatalf("list pending outbox failed: %v", err)
}
if !hasOutboxEvent(records, "RoomClosed", 0) ||
!hasOutboxEvent(records, "RoomUserKicked", ownerID) ||
!hasOutboxEvent(records, "RoomUserKicked", viewerID) {
t.Fatalf("admin close must write room closed and per-user kick events: %+v", outboxSummary(records))
}
reopenResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-reopen-admin",
CommandId: "cmd-reopen-admin",
ActorUserId: adminID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Now().UnixMilli(),
},
Reason: "admin_reopen",
})
if err != nil {
t.Fatalf("admin reopen room failed: %v", err)
}
if reopenResp.GetRoom().GetStatus() != "active" {
t.Fatalf("admin reopen response must make room active: %+v", reopenResp.GetRoom())
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("viewer must join after admin reopen: %v", err)
}
}
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
return user
}
}
return nil
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func hasRTCRemoveCall(calls []rtcRemoveCall, roomID string, userID int64) bool {
for _, call := range calls {
if call.roomID == roomID && call.userID == userID {
return true
}
}
return false
}
func hasOutboxEvent(records []roomoutbox.Record, eventType string, targetUserID int64) bool {
for _, record := range records {
if record.EventType != eventType {
continue
}
if targetUserID == 0 {
return true
}
var body roomeventsv1.RoomUserKicked
if record.Envelope == nil || proto.Unmarshal(record.Envelope.GetBody(), &body) != nil {
continue
}
if body.GetTargetUserId() == targetUserID {
return true
}
}
return false
}
func outboxSummary(records []roomoutbox.Record) []string {
summary := make([]string, 0, len(records))
for _, record := range records {
summary = append(summary, fmt.Sprintf("%s:%s", record.EventType, record.EventID))
}
return summary
}