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" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/xerr" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/testutil/mysqltest" ) const vipTestAppCode = "fami" func TestJoinRoomCarriesEffectiveVIPAndQueriesWalletOnce(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) wallet := &rocketTestWallet{vipResponses: map[int64]*walletv1.GetMyVipResponse{ 8202: tieredVIPResponse(8202, 4, "VIP4", "room_entry_notice"), }} directIM := newRecordingRoomDirectIMPublisher() now := &fixedRoomRocketClock{now: time.Date(2026, 7, 10, 8, 0, 0, 0, time.UTC)} svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "fami-vip-join-room" createVIPRoom(t, ctx, svc, roomID, 8201) response, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: vipRoomMeta(roomID, 8202, "join"), Role: "audience", }) if err != nil { t.Fatalf("join room failed: %v", err) } if response.GetEffectiveVip().GetProgramType() != "tiered_privilege_v1" || response.GetEffectiveVip().GetLevel() != 4 || response.GetEffectiveVip().GetName() != "VIP4" || !response.GetEffectiveVip().GetRoomEntryNoticeEnabled() { t.Fatalf("join effective vip mismatch: %+v", response.GetEffectiveVip()) } if len(wallet.vipRequests) != 1 || wallet.vipRequests[0].GetAppCode() != vipTestAppCode || wallet.vipRequests[0].GetUserId() != 8202 { t.Fatalf("join must query exactly one scoped effective vip state: %+v", wallet.vipRequests) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1}) records := directIM.recordsByType("RoomUserJoined") if len(records) != 1 { t.Fatalf("joined direct IM record mismatch: %+v", records) } var joined roomeventsv1.RoomUserJoined if err := proto.Unmarshal(records[0].GetBody(), &joined); err != nil { t.Fatalf("decode joined event failed: %v", err) } if joined.GetVipProgramType() != "tiered_privilege_v1" || joined.GetEffectiveVipLevel() != 4 || joined.GetEffectiveVipName() != "VIP4" || !joined.GetRoomEntryNoticeEnabled() { t.Fatalf("joined event vip mismatch: %+v", &joined) } } func TestRoomEntryNoticeRequiresBenefitAndUserSetting(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) wallet := &rocketTestWallet{vipResponses: map[int64]*walletv1.GetMyVipResponse{ 8502: tieredVIPResponseWithRoomNoticeSetting(8502, 8, "VIP8", true, true), 8503: tieredVIPResponseWithRoomNoticeSetting(8503, 8, "VIP8", true, false), 8504: tieredVIPResponseWithRoomNoticeSetting(8504, 8, "VIP8", false, true), // nil user_settings 模拟滚动升级期间旧 wallet,必须按默认开启兼容。 8505: tieredVIPResponse(8505, 8, "VIP8", "room_entry_notice"), }} directIM := newRecordingRoomDirectIMPublisher() now := &fixedRoomRocketClock{now: time.Date(2026, 7, 10, 8, 30, 0, 0, time.UTC)} svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "fami-vip-setting-room" createVIPRoom(t, ctx, svc, roomID, 8501) wants := map[int64]bool{8502: true, 8503: false, 8504: false, 8505: true} for userID, want := range wants { response, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: vipRoomMeta(roomID, userID, fmt.Sprintf("settings-%d", userID)), Role: "audience", }) if err != nil { t.Fatalf("join user %d failed: %v", userID, err) } if got := response.GetEffectiveVip().GetRoomEntryNoticeEnabled(); got != want { t.Fatalf("room snapshot setting mismatch user=%d got=%v want=%v snapshot=%+v", userID, got, want, response.GetEffectiveVip()) } } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": len(wants)}) seen := make(map[int64]bool, len(wants)) for _, record := range directIM.recordsByType("RoomUserJoined") { var joined roomeventsv1.RoomUserJoined if err := proto.Unmarshal(record.GetBody(), &joined); err != nil { t.Fatalf("decode joined event failed: %v", err) } seen[joined.GetUserId()] = joined.GetRoomEntryNoticeEnabled() } for userID, want := range wants { if got, exists := seen[userID]; !exists || got != want { t.Fatalf("joined event/IM setting mismatch user=%d exists=%v got=%v want=%v", userID, exists, got, want) } } } func TestTieredVIPCustomRoomBackgroundRequiresEffectiveBenefit(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) ownerID := int64(8301) wallet := &rocketTestWallet{vipResponses: map[int64]*walletv1.GetMyVipResponse{ ownerID: tieredVIPResponse(ownerID, 3, "VIP3"), }} now := &fixedRoomRocketClock{now: time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC)} svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher()) roomID := "fami-vip-background-room" createVIPRoom(t, ctx, svc, roomID, ownerID) if _, err := svc.SaveRoomBackground(ctx, &roomv1.SaveRoomBackgroundRequest{ Meta: vipRoomMeta(roomID, ownerID, "save-denied"), RoomId: roomID, ImageUrl: "https://cdn.example.com/fami-vip-denied.png", }); !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("tiered app without custom_room_background must be denied: %v", err) } wallet.vipResponses[ownerID] = tieredVIPResponse(ownerID, 3, "VIP3", "custom_room_background") first := saveVIPRoomBackground(t, ctx, svc, roomID, ownerID, "first", "https://cdn.example.com/fami-vip-first.png") second := saveVIPRoomBackground(t, ctx, svc, roomID, ownerID, "second", "https://cdn.example.com/fami-vip-second.png") if _, err := svc.SetRoomBackground(ctx, &roomv1.SetRoomBackgroundRequest{ Meta: vipRoomMeta(roomID, ownerID, "set-allowed"), BackgroundId: first.GetBackgroundId(), }); err != nil { t.Fatalf("effective custom_room_background benefit must allow set: %v", err) } wallet.vipResponses[ownerID] = tieredVIPResponse(ownerID, 3, "VIP3") if _, err := svc.SetRoomBackground(ctx, &roomv1.SetRoomBackgroundRequest{ Meta: vipRoomMeta(roomID, ownerID, "set-denied"), BackgroundId: second.GetBackgroundId(), }); !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("expired custom_room_background benefit must block later set: %v", err) } } func TestVIPModerationProtectionBlocksRoomManagersButNotSystemEvict(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) targetID := int64(8402) wallet := &rocketTestWallet{vipResponses: map[int64]*walletv1.GetMyVipResponse{ targetID: tieredVIPResponse(targetID, 9, "VIP9", "anti_kick", "anti_mute"), }} now := &fixedRoomRocketClock{now: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)} svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher()) roomID := "fami-vip-moderation-room" ownerID := int64(8401) createVIPRoom(t, ctx, svc, roomID, ownerID) if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: vipRoomMeta(roomID, targetID, "target-join"), Role: "audience"}); err != nil { t.Fatalf("join protected target failed: %v", err) } if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{ Meta: vipRoomMeta(roomID, ownerID, "mute"), TargetUserId: targetID, Muted: true, }); !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("anti_mute must block ordinary room owner: %v", err) } if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{ Meta: vipRoomMeta(roomID, ownerID, "kick"), TargetUserId: targetID, }); !xerr.IsCode(err, xerr.PermissionDenied) { t.Fatalf("anti_kick must block ordinary room owner: %v", err) } vipCallsBeforeSystemEvict := len(wallet.vipRequests) evict, err := svc.SystemEvictUser(ctx, &roomv1.SystemEvictUserRequest{ Meta: vipRoomMeta(roomID, 9901, "system-evict"), TargetUserId: targetID, OperatorUserId: 9901, Reason: "platform_moderation", BanFromRoom: true, }) if err != nil { t.Fatalf("system evict must bypass anti_kick: %v", err) } if !evict.GetResult().GetApplied() || findRoomUser(evict.GetRoom(), targetID) != nil || !containsInt64(evict.GetRoom().GetBanUserIds(), targetID) { t.Fatalf("system evict must remove and ban protected target: %+v", evict) } if len(wallet.vipRequests) != vipCallsBeforeSystemEvict { t.Fatalf("SystemEvictUser must not query or enforce ordinary VIP moderation benefits: before=%d after=%d", vipCallsBeforeSystemEvict, len(wallet.vipRequests)) } } func tieredVIPResponse(userID int64, level int32, name string, benefitCodes ...string) *walletv1.GetMyVipResponse { benefits := make([]*walletv1.VipBenefit, 0, len(benefitCodes)) for index, code := range benefitCodes { benefits = append(benefits, &walletv1.VipBenefit{ BenefitCode: code, Name: code, Status: "active", SortOrder: int32(index + 1), }) } return &walletv1.GetMyVipResponse{State: &walletv1.VipState{ ProgramConfig: &walletv1.VipProgramConfig{AppCode: vipTestAppCode, ProgramType: "tiered_privilege_v1", Status: "active", LevelCount: 9}, EffectiveVip: &walletv1.UserVip{ UserId: userID, Level: level, Name: name, Active: level > 0, ProgramType: "tiered_privilege_v1", }, EffectiveBenefits: benefits, }} } func tieredVIPResponseWithRoomNoticeSetting(userID int64, level int32, name string, hasBenefit bool, enabled bool) *walletv1.GetMyVipResponse { codes := []string{} if hasBenefit { codes = append(codes, "room_entry_notice") } response := tieredVIPResponse(userID, level, name, codes...) response.GetState().UserSettings = &walletv1.VipUserSettings{ AppCode: vipTestAppCode, UserId: userID, RoomEntryNoticeEnabled: enabled, OnlineGlobalNoticeEnabled: true, } return response } func createVIPRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, ownerID int64) { t.Helper() if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: vipRoomMeta(roomID, ownerID, "create"), SeatCount: 10, Mode: "voice", VisibleRegionId: 1001, RoomName: roomID, RoomAvatar: testRoomCoverURL, RoomShortId: roomID, }); err != nil { t.Fatalf("create vip room failed: %v", err) } } func saveVIPRoomBackground(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, ownerID int64, suffix string, imageURL string) *roomv1.RoomBackgroundImage { t.Helper() response, err := svc.SaveRoomBackground(ctx, &roomv1.SaveRoomBackgroundRequest{ Meta: vipRoomMeta(roomID, ownerID, "save-"+suffix), RoomId: roomID, ImageUrl: imageURL, }) if err != nil { t.Fatalf("save vip room background failed: %v", err) } return response.GetBackground() } func vipRoomMeta(roomID string, actorUserID int64, suffix string) *roomv1.RequestMeta { return &roomv1.RequestMeta{ RequestId: fmt.Sprintf("req-vip-%s", suffix), CommandId: fmt.Sprintf("cmd-vip-%s", suffix), ActorUserId: actorUserID, RoomId: roomID, AppCode: vipTestAppCode, SentAtMs: time.Date(2026, 7, 10, 8, 0, 0, 0, time.UTC).UnixMilli(), } }