404 lines
13 KiB
Go
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
|
|
}
|