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/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", 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 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", 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 }