2026-05-16 08:12:42 +08:00

245 lines
7.4 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/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
}