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 TestAdminDeleteRoomHardDeletesRoomStateAndAllowsRecreate(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := roomservice.New(roomservice.Config{ NodeID: "node-admin-delete-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-admin-delete-flow" ownerID := int64(8301) viewerID := int64(8302) if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), SeatCount: 10, Mode: "voice", VisibleRegionId: 8101, RoomName: "Delete Flow", RoomAvatar: testRoomCoverURL, RoomShortId: "delete-flow", }); err != nil { t.Fatalf("create delete room fixture failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", }); err != nil { t.Fatalf("join delete fixture viewer failed: %v", err) } resp, err := svc.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-admin-delete-room", CommandId: "cmd-admin-delete-room", ActorUserId: 1, RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(), }, AdminId: 1, AdminName: "admin", }) if err != nil { t.Fatalf("admin delete room failed: %v", err) } if !resp.GetResult().GetApplied() || resp.GetRoom().GetStatus() != "deleted" { t.Fatalf("delete response must report one applied delete mutation: %+v", resp) } if _, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || exists { t.Fatalf("rooms metadata must be physically deleted, exists=%v err=%v", exists, err) } for _, table := range []string{"room_list_entries", "room_snapshots", "room_command_log", "room_user_presence", "room_user_feed_entries"} { if count := repository.CountRowsByRoom(table, roomID); count != 0 { t.Fatalf("%s rows must be deleted, count=%d", table, count) } } listResp, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, OwnerUserId: ownerID, Page: 1, PageSize: 20, }) if err != nil { t.Fatalf("list rooms after delete failed: %v", err) } if listResp.GetTotal() != 0 || len(listResp.GetRooms()) != 0 { t.Fatalf("deleted room must disappear from admin list: %+v", listResp) } if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), SeatCount: 10, Mode: "voice", VisibleRegionId: 8101, RoomName: "Recreated Flow", RoomAvatar: testRoomCoverURL, RoomShortId: "delete-flow", }); err != nil { t.Fatalf("owner must be able to recreate room after hard delete: %v", err) } } func TestClosedRoomOwnerJoinReactivatesButAudienceCannot(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := roomservice.New(roomservice.Config{ NodeID: "node-owner-reactivate-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-owner-reactivate" ownerID := int64(8401) viewerID := int64(8402) adminID := int64(1) if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), SeatCount: 10, Mode: "voice", VisibleRegionId: 8101, RoomName: "Reactivate Flow", RoomAvatar: testRoomCoverURL, RoomShortId: "reactivate-flow", }); err != nil { t.Fatalf("create reactivation room fixture failed: %v", err) } if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-close-reactivation-room", CommandId: "cmd-close-reactivation-room", ActorUserId: adminID, RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(), }, Reason: "admin_closed", }); err != nil { t.Fatalf("close room fixture failed: %v", err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", }); !xerr.IsCode(err, xerr.RoomClosed) { t.Fatalf("audience join closed room must be rejected as ROOM_CLOSED: %v", err) } ownerJoin, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, ownerID), Role: "audience", }) if err != nil { t.Fatalf("owner join closed room must reactivate it: %v", err) } if ownerJoin.GetRoom().GetStatus() != "active" { t.Fatalf("owner join must return active room: %+v", ownerJoin.GetRoom()) } if user := findRoomUser(ownerJoin.GetRoom(), ownerID); user == nil || user.GetRole() != "owner" { t.Fatalf("reactivated owner must be in room with owner role: %+v", ownerJoin.GetRoom().GetOnlineUsers()) } meta, exists, err := repository.GetRoomMeta(ctx, roomID) if err != nil || !exists || meta.Status != "active" { t.Fatalf("owner reactivation must persist active status, exists=%v meta=%+v err=%v", exists, meta, err) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience", }); err != nil { t.Fatalf("audience must join after owner reactivates room: %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 }