package http import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "hyapp/services/gateway-service/internal/transport/http/httpkit" "hyapp/services/gateway-service/internal/transport/http/roomapi" "io" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "strconv" "strings" "testing" "time" jwt "github.com/golang-jwt/jwt/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" activityv1 "hyapp.local/api/proto/activity/v1" roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/auth" gatewayclient "hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/financewithdrawal" ) // fakeRoomClient 只承接 gateway transport 测试需要观察的 gRPC 入参。 // 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。 type fakeRoomClient struct { lastCreate *roomv1.CreateRoomRequest lastUpdateProfile *roomv1.UpdateRoomProfileRequest lastSaveBackground *roomv1.SaveRoomBackgroundRequest lastSetBackground *roomv1.SetRoomBackgroundRequest lastJoin *roomv1.JoinRoomRequest lastHeartbeat *roomv1.RoomHeartbeatRequest lastLeave *roomv1.LeaveRoomRequest lastMicUp *roomv1.MicUpRequest lastMicDown *roomv1.MicDownRequest lastChangeMicSeat *roomv1.ChangeMicSeatRequest lastConfirmMic *roomv1.ConfirmMicPublishingRequest lastMicHeartbeat *roomv1.MicHeartbeatRequest lastSetMicMute *roomv1.SetMicMuteRequest lastRTCEvent *roomv1.ApplyRTCEventRequest lastMicSeatLock *roomv1.SetMicSeatLockRequest lastChatEnabled *roomv1.SetChatEnabledRequest lastRoomPassword *roomv1.SetRoomPasswordRequest lastSetAdmin *roomv1.SetRoomAdminRequest lastMute *roomv1.MuteUserRequest lastKick *roomv1.KickUserRequest lastUnban *roomv1.UnbanUserRequest lastGift *roomv1.SendGiftRequest sendGiftResp *roomv1.SendGiftResponse lastFollowRoom *roomv1.FollowRoomRequest lastUnfollowRoom *roomv1.UnfollowRoomRequest createErr error updateProfileErr error joinErr error joinResp *roomv1.JoinRoomResponse } func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) { f.lastCreate = req if f.createErr != nil { return nil, f.createErr } return &roomv1.CreateRoomResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1}, }, nil } func (f *fakeRoomClient) UpdateRoomProfile(_ context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) { f.lastUpdateProfile = req if f.updateProfileErr != nil { return nil, f.updateProfileErr } return &roomv1.UpdateRoomProfileResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3, ServerTimeMs: 1_700_000_000_002}, Room: &roomv1.RoomSnapshot{ RoomId: req.GetMeta().GetRoomId(), OwnerUserId: req.GetMeta().GetActorUserId(), Mode: "voice", Status: "active", ChatEnabled: true, MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}, {SeatNo: 3}}, RoomExt: map[string]string{ "title": req.GetRoomName(), "cover_url": req.GetRoomAvatar(), "description": req.GetRoomDescription(), }, Version: 3, }, }, nil } func (f *fakeRoomClient) SaveRoomBackground(_ context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) { f.lastSaveBackground = req return &roomv1.SaveRoomBackgroundResponse{ Background: &roomv1.RoomBackgroundImage{ BackgroundId: 101, RoomId: req.GetRoomId(), ImageUrl: req.GetImageUrl(), CreatedByUserId: req.GetMeta().GetActorUserId(), CreatedAtMs: 1_700_000_000_003, UpdatedAtMs: 1_700_000_000_003, }, ServerTimeMs: 1_700_000_000_003, }, nil } func (f *fakeRoomClient) SetRoomBackground(_ context.Context, req *roomv1.SetRoomBackgroundRequest) (*roomv1.SetRoomBackgroundResponse, error) { f.lastSetBackground = req return &roomv1.SetRoomBackgroundResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4, ServerTimeMs: 1_700_000_000_004}, Room: &roomv1.RoomSnapshot{ RoomId: req.GetMeta().GetRoomId(), OwnerUserId: req.GetMeta().GetActorUserId(), Mode: "voice", Status: "active", ChatEnabled: true, RoomExt: map[string]string{ "background_url": "https://cdn.example.com/bg.png", }, Version: 4, }, Background: &roomv1.RoomBackgroundImage{ BackgroundId: req.GetBackgroundId(), RoomId: req.GetMeta().GetRoomId(), ImageUrl: "https://cdn.example.com/bg.png", CreatedByUserId: req.GetMeta().GetActorUserId(), CreatedAtMs: 1_700_000_000_003, UpdatedAtMs: 1_700_000_000_003, }, }, nil } func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) { f.lastJoin = req if f.joinErr != nil { return nil, f.joinErr } if f.joinResp != nil { return f.joinResp, nil } userID := req.GetMeta().GetActorUserId() role := req.GetRole() if role == "" { role = "audience" } return &roomv1.JoinRoomResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2, ServerTimeMs: 1_700_000_000_001}, User: &roomv1.RoomUser{UserId: userID, Role: role, JoinedAtMs: 1_700_000_000_000, LastSeenAtMs: 1_700_000_000_000}, Room: &roomv1.RoomSnapshot{ RoomId: req.GetMeta().GetRoomId(), OwnerUserId: 1001, Mode: "voice", Status: "active", ChatEnabled: true, MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2, UserId: 1002, PublishState: "publishing", MicSessionId: "mic-1002"}}, OnlineUsers: []*roomv1.RoomUser{{UserId: userID, Role: role}, {UserId: 1002, Role: "admin"}}, GiftRank: []*roomv1.RankItem{{UserId: 1002, Score: 200, GiftValue: 200}}, RoomExt: map[string]string{"title": "Room", "cover_url": "https://cdn.example/room.png", "description": "welcome", "room_short_id": "100001"}, Heat: 99, Version: 2, RoomShortId: "100001", }, }, nil } func (f *fakeRoomClient) RoomHeartbeat(_ context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) { f.lastHeartbeat = req return &roomv1.RoomHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil } func (f *fakeRoomClient) LeaveRoom(_ context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) { f.lastLeave = req return &roomv1.LeaveRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3}}, nil } func (f *fakeRoomClient) MicUp(_ context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) { f.lastMicUp = req return &roomv1.MicUpResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4}}, nil } func (f *fakeRoomClient) MicDown(_ context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) { f.lastMicDown = req return &roomv1.MicDownResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 5}}, nil } func (f *fakeRoomClient) ChangeMicSeat(_ context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) { f.lastChangeMicSeat = req return &roomv1.ChangeMicSeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 6}}, nil } func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) { f.lastConfirmMic = req return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil } func (f *fakeRoomClient) MicHeartbeat(_ context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) { f.lastMicHeartbeat = req return &roomv1.MicHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}, SeatNo: 2, MicHeartbeatAtMs: 1_700_000_001_000}, nil } func (f *fakeRoomClient) SetMicMute(_ context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) { f.lastSetMicMute = req return &roomv1.SetMicMuteResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil } func (f *fakeRoomClient) ApplyRTCEvent(_ context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) { f.lastRTCEvent = req return &roomv1.ApplyRTCEventResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil } func (f *fakeRoomClient) SetMicSeatLock(_ context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) { f.lastMicSeatLock = req return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil } func (f *fakeRoomClient) SetChatEnabled(_ context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) { f.lastChatEnabled = req return &roomv1.SetChatEnabledResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil } func (f *fakeRoomClient) SetRoomPassword(_ context.Context, req *roomv1.SetRoomPasswordRequest) (*roomv1.SetRoomPasswordResponse, error) { f.lastRoomPassword = req return &roomv1.SetRoomPasswordResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9, ServerTimeMs: 1_700_000_000_009}, Room: &roomv1.RoomSnapshot{ RoomId: req.GetMeta().GetRoomId(), OwnerUserId: req.GetMeta().GetActorUserId(), Mode: "voice", Status: "active", ChatEnabled: true, Locked: req.GetLocked(), MicSeats: []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}}, RoomExt: map[string]string{"title": "Room"}, Version: 9, }, }, nil } func (f *fakeRoomClient) SetRoomAdmin(_ context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) { f.lastSetAdmin = req return &roomv1.SetRoomAdminResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil } func (f *fakeRoomClient) MuteUser(_ context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) { f.lastMute = req return &roomv1.MuteUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 11}}, nil } func (f *fakeRoomClient) KickUser(_ context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) { f.lastKick = req return &roomv1.KickUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 12}}, nil } func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) { f.lastUnban = req return &roomv1.UnbanUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 13}}, nil } func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) { f.lastGift = req if f.sendGiftResp != nil { return f.sendGiftResp, nil } return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil } func (f *fakeRoomClient) FollowRoom(_ context.Context, req *roomv1.FollowRoomRequest) (*roomv1.FollowRoomResponse, error) { f.lastFollowRoom = req return &roomv1.FollowRoomResponse{RoomId: req.GetRoomId(), Followed: true, FollowedAtMs: 1_700_000_000_123, ServerTimeMs: 1_700_000_000_123}, nil } func (f *fakeRoomClient) UnfollowRoom(_ context.Context, req *roomv1.UnfollowRoomRequest) (*roomv1.UnfollowRoomResponse, error) { f.lastUnfollowRoom = req return &roomv1.UnfollowRoomResponse{RoomId: req.GetRoomId(), Followed: false, ServerTimeMs: 1_700_000_000_124}, nil } type fakeUserAuthClient struct { lastLoginThirdParty *userv1.LoginThirdPartyRequest lastRegisterThird *userv1.RegisterThirdPartyRequest lastCheckThird *userv1.CheckThirdPartyRegisteredRequest lastLoginPassword *userv1.LoginPasswordRequest lastSetPassword *userv1.SetPasswordRequest lastQuickCreate *userv1.QuickCreateAccountRequest lastRefresh *userv1.RefreshTokenRequest lastLogout *userv1.LogoutRequest lastBlocked *userv1.RecordLoginBlockedRequest lastWhitelist *userv1.CheckLoginRiskIPWhitelistRequest lastAppHeartbeat *userv1.AppHeartbeatRequest loginPasswordCount int loginThirdCount int refreshCount int logoutCount int blockedCount int whitelisted bool loginErr error loginThirdResp *userv1.AuthResponse checkRegistered bool } type fakeUserProfileClient struct { lastGet *userv1.GetUserRequest lastBusinessLookup *userv1.BusinessUserLookupRequest lastStats *userv1.GetMyProfileStatsRequest lastBatch *userv1.BatchGetUsersRequest batchRequests []*userv1.BatchGetUsersRequest lastRoomBasicBatch *userv1.BatchGetRoomBasicUsersRequest roomBasicRequests []*userv1.BatchGetRoomBasicUsersRequest getRequests []*userv1.GetUserRequest lastComplete *userv1.CompleteOnboardingRequest lastUpdate *userv1.UpdateUserProfileRequest lastBackground *userv1.UpdateUserProfileBackgroundRequest lastContact *userv1.UpdateUserContactInfoRequest lastWithdrawAddress *userv1.UpdateUserWithdrawAddressRequest lastCountry *userv1.ChangeUserCountryRequest lastAdminCountry *userv1.AdminChangeUserCountryRequest lastCreateBlock *userv1.CreateManagerUserBlockRequest lastListBlocks *userv1.ListManagerUserBlocksRequest lastUnblock *userv1.UnblockManagerUserRequest getErr error statsResp *userv1.GetMyProfileStatsResponse businessLookupResp *userv1.BusinessUserLookupResponse businessLookupErr error blocksResp *userv1.ListManagerUserBlocksResponse createBlockResp *userv1.CreateManagerUserBlockResponse unblockResp *userv1.UnblockManagerUserResponse statsErr error completeErr error countryErr error blockErr error regionID int64 withdrawAddress string regionByUserID map[int64]int64 usersByID map[int64]*userv1.User } type fakeTencentIMAccountImporter struct { lastUserID int64 lastNickname string lastFaceURL string err error } func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error { f.lastUserID = userID f.lastNickname = nickname f.lastFaceURL = faceURL return f.err } type fakeTencentIMUserMessagePublisher struct { messages []tencentim.CustomUserMessage err error } func (f *fakeTencentIMUserMessagePublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error { f.messages = append(f.messages, message) return f.err } type fakeUserIdentityClient struct { lastResolve *userv1.ResolveDisplayUserIDRequest resolveByDisplay map[string]int64 resolveErr error } type fakeRoomQueryClient struct { lastList *roomv1.ListRoomsRequest lastListOwners *roomv1.ListRoomsByOwnersRequest lastFeeds *roomv1.ListRoomFeedsRequest lastMyRoom *roomv1.GetMyRoomRequest lastCurrent *roomv1.GetCurrentRoomRequest lastSnapshot *roomv1.GetRoomSnapshotRequest lastBackgrounds *roomv1.ListRoomBackgroundsRequest lastRocket *roomv1.GetRoomRocketRequest lastOnline *roomv1.ListRoomOnlineUsersRequest lastBannedUsers *roomv1.ListRoomBannedUsersRequest resp *roomv1.ListRoomsResponse ownersResp *roomv1.ListRoomsResponse feedsResp *roomv1.ListRoomsResponse myRoomResp *roomv1.GetMyRoomResponse currentResp *roomv1.GetCurrentRoomResponse snapshotResp *roomv1.GetRoomSnapshotResponse backgroundsResp *roomv1.ListRoomBackgroundsResponse rocketResp *roomv1.GetRoomRocketResponse onlineResp *roomv1.ListRoomOnlineUsersResponse bannedUsersResp *roomv1.ListRoomBannedUsersResponse err error ownersErr error feedsErr error myRoomErr error currentErr error snapshotErr error backgroundsErr error rocketErr error onlineErr error bannedUsersErr error } type fakeUserDeviceClient struct { lastBind *userv1.BindPushTokenRequest lastDelete *userv1.DeletePushTokenRequest bindErr error deleteErr error } type fakeStatisticsClient struct { lastAppEvents []gatewayclient.AppTrackingEvent appResult gatewayclient.AppTrackingReportResult appErr error selfEvents []gatewayclient.SelfGameH5Event selfErr error } func (f *fakeStatisticsClient) ReportSelfGameH5Event(_ context.Context, event gatewayclient.SelfGameH5Event) error { f.selfEvents = append(f.selfEvents, event) return f.selfErr } func (f *fakeStatisticsClient) ReportAppTrackingEvents(_ context.Context, events []gatewayclient.AppTrackingEvent) (gatewayclient.AppTrackingReportResult, error) { f.lastAppEvents = append([]gatewayclient.AppTrackingEvent(nil), events...) if f.appErr != nil { return gatewayclient.AppTrackingReportResult{}, f.appErr } if f.appResult.ServerTimeMS == 0 { f.appResult = gatewayclient.AppTrackingReportResult{Accepted: true, Received: len(events), Stored: len(events), Duplicated: 0, ServerTimeMS: 1_778_000_005_000} } return f.appResult, nil } type fakeUserCountryQueryClient struct { last *userv1.ListRegistrationCountriesRequest resp *userv1.ListRegistrationCountriesResponse err error lastBlocked *userv1.ListLoginRiskBlockedCountriesRequest blockedResp *userv1.ListLoginRiskBlockedCountriesResponse blockedErr error } type fakeUserRegionClient struct { last *userv1.GetRegionRequest resp *userv1.RegionResponse err error } type fakeUserHostClient struct { lastSearchAgencies *userv1.SearchAgenciesRequest searchAgenciesResp *userv1.SearchAgenciesResponse searchAgenciesErr error lastApplyAgency *userv1.ApplyToAgencyRequest applyAgencyResp *userv1.ApplyToAgencyResponse applyAgencyErr error lastInviteAgency *userv1.InviteAgencyRequest inviteAgencyResp *userv1.InviteAgencyResponse inviteAgencyErr error lastInviteBD *userv1.InviteBDRequest inviteBDResp *userv1.InviteBDResponse inviteBDErr error lastInviteHost *userv1.InviteHostRequest inviteHostResp *userv1.InviteHostResponse inviteHostErr error lastLeaderBDs *userv1.ListBDLeaderBDsRequest leaderBDsResp *userv1.ListBDLeaderBDsResponse leaderBDsErr error lastLeaderAgencies *userv1.ListBDLeaderAgenciesRequest leaderAgenciesResp *userv1.ListBDLeaderAgenciesResponse leaderAgenciesErr error lastBDAgencies *userv1.ListBDAgenciesRequest bdAgenciesResp *userv1.ListBDAgenciesResponse bdAgenciesErr error lastRoleInvitations *userv1.ListRoleInvitationsRequest roleInvitationsResp *userv1.ListRoleInvitationsResponse roleInvitationsErr error lastProcessInvite *userv1.ProcessRoleInvitationRequest processInviteResp *userv1.ProcessRoleInvitationResponse processInviteErr error last *userv1.GetCoinSellerProfileRequest profile *userv1.CoinSellerProfile err error lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse listCoinSellersErr error lastHost *userv1.GetHostProfileRequest lastBatchHost *userv1.BatchGetHostProfilesRequest hostCalls int batchHostCalls int hostProfile *userv1.HostProfile hostProfiles map[int64]*userv1.HostProfile hostErr error batchHostErr error lastBD *userv1.GetBDProfileRequest bdProfile *userv1.BDProfile bdErr error lastRoleSummary *userv1.GetUserRoleSummaryRequest roleSummary *userv1.UserRoleSummary roleSummaryErr error lastGetAgency *userv1.GetAgencyRequest getAgencyResp *userv1.GetAgencyResponse getAgencyErr error lastUpdateAgency *userv1.UpdateAgencyProfileRequest updateAgencyResp *userv1.UpdateAgencyProfileResponse updateAgencyErr error lastMembers *userv1.GetAgencyMembersRequest membersResp *userv1.GetAgencyMembersResponse membersErr error lastApplications *userv1.GetAgencyApplicationsRequest applicationsResp *userv1.GetAgencyApplicationsResponse applicationsErr error lastReview *userv1.ReviewAgencyApplicationRequest reviewResp *userv1.ReviewAgencyApplicationResponse reviewErr error lastKick *userv1.KickAgencyHostRequest kickResp *userv1.KickAgencyHostResponse kickErr error lastCapability *userv1.CheckBusinessCapabilityRequest capabilities []string capabilityResp *userv1.CheckBusinessCapabilityResponse capabilityErr error } type fakeUserHostAdminClient struct { lastCreateBDLeader *userv1.CreateBDLeaderRequest createBDLeaderResp *userv1.CreateBDLeaderResponse createBDLeaderErr error } type fakeWalletClient struct { last *walletv1.GetBalancesRequest balanceRequests []*walletv1.GetBalancesRequest resp *walletv1.GetBalancesResponse balancesByUserID map[int64]*walletv1.GetBalancesResponse err error lastDirectGift *walletv1.DebitDirectGiftRequest directGiftResp *walletv1.DebitGiftResponse directGiftErr error lastHostSalaryPolicy *walletv1.GetActiveHostSalaryPolicyRequest hostSalaryPolicyResp *walletv1.GetActiveHostSalaryPolicyResponse hostSalaryPolicyErr error lastHostSalaryProgress *walletv1.GetHostSalaryProgressRequest hostSalaryProgressResp *walletv1.GetHostSalaryProgressResponse hostSalaryProgressErr error lastOverview *walletv1.GetWalletOverviewRequest overviewResp *walletv1.GetWalletOverviewResponse overviewErr error lastValueSummary *walletv1.GetWalletValueSummaryRequest valueSummaryResp *walletv1.GetWalletValueSummaryResponse valueSummaryErr error lastGiftWall *walletv1.GetUserGiftWallRequest giftWallResp *walletv1.GetUserGiftWallResponse lastRechargeProducts *walletv1.ListRechargeProductsRequest rechargeProductsResp *walletv1.ListRechargeProductsResponse lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse lastH5Options *walletv1.H5RechargeOptionsRequest h5OptionsResp *walletv1.H5RechargeOptionsResponse lastH5CreateOrder *walletv1.CreateH5RechargeOrderRequest h5CreateOrderResp *walletv1.H5RechargeOrderResponse lastTemporaryCreate *walletv1.CreateTemporaryRechargeOrderRequest temporaryCreateResp *walletv1.H5RechargeOrderResponse lastTemporaryGet *walletv1.GetTemporaryRechargeOrderRequest temporaryGetResp *walletv1.H5RechargeOrderResponse lastTemporaryList *walletv1.ListTemporaryRechargeOrdersRequest temporaryListResp *walletv1.ListTemporaryRechargeOrdersResponse lastH5SubmitTx *walletv1.SubmitH5RechargeTxRequest h5SubmitTxResp *walletv1.H5RechargeOrderResponse lastH5GetOrder *walletv1.GetH5RechargeOrderRequest h5GetOrderResp *walletv1.H5RechargeOrderResponse lastMifaPayNotify *walletv1.HandleMifapayNotifyRequest mifaPayNotifyResp *walletv1.HandleMifapayNotifyResponse lastV5PayNotify *walletv1.HandleV5PayNotifyRequest v5PayNotifyResp *walletv1.HandleV5PayNotifyResponse lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest googleConfirmResp *walletv1.ConfirmGooglePaymentResponse lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse lastTransactions *walletv1.ListWalletTransactionsRequest transactionsResp *walletv1.ListWalletTransactionsResponse lastVipPackages *walletv1.ListVipPackagesRequest vipPackagesResp *walletv1.ListVipPackagesResponse lastMyVip *walletv1.GetMyVipRequest myVipResp *walletv1.GetMyVipResponse vipErr error lastPurchaseVip *walletv1.PurchaseVipRequest purchaseVipResp *walletv1.PurchaseVipResponse lastGrantVip *walletv1.GrantVipRequest grantVipResp *walletv1.GrantVipResponse lastTransfer *walletv1.TransferCoinFromSellerRequest transferResp *walletv1.TransferCoinFromSellerResponse transferErr error lastListExchangeTiers *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest listExchangeTiersResp *walletv1.ListCoinSellerSalaryExchangeRateTiersResponse lastExchangeSalary *walletv1.ExchangeSalaryToCoinRequest exchangeSalaryResp *walletv1.ExchangeSalaryToCoinResponse exchangeSalaryErr error lastTransferSalary *walletv1.TransferSalaryToCoinSellerRequest transferSalaryResp *walletv1.TransferSalaryToCoinSellerResponse transferSalaryErr error lastFreezeSalary *walletv1.FreezeSalaryWithdrawalRequest freezeSalaryResp *walletv1.FreezeSalaryWithdrawalResponse freezeSalaryErr error lastReleaseSalary *walletv1.ReleaseSalaryWithdrawalRequest releaseSalaryResp *walletv1.ReleaseSalaryWithdrawalResponse releaseSalaryErr error lastListResources *walletv1.ListResourcesRequest listResourcesResp *walletv1.ListResourcesResponse lastGetResource *walletv1.GetResourceRequest resourcesByID map[int64]*walletv1.Resource lastGetResourceGroup *walletv1.GetResourceGroupRequest resourceGroupsByID map[int64]*walletv1.ResourceGroup lastResourceShop *walletv1.ListResourceShopItemsRequest resourceShopResp *walletv1.ListResourceShopItemsResponse lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest purchaseShopResp *walletv1.PurchaseResourceShopItemResponse lastListGiftConfigs *walletv1.ListGiftConfigsRequest listGiftConfigsCalls int listGiftConfigsResp *walletv1.ListGiftConfigsResponse lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest listGiftTypesCalls int listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse lastListUserResources *walletv1.ListUserResourcesRequest listUserResourcesCalls int listUserResourcesResp *walletv1.ListUserResourcesResponse lastGrantResource *walletv1.GrantResourceRequest grantResourceResp *walletv1.ResourceGrantResponse grantResourceErr error lastBatchEquipped *walletv1.BatchGetUserEquippedResourcesRequest batchEquippedRequests []*walletv1.BatchGetUserEquippedResourcesRequest batchEquippedResp *walletv1.BatchGetUserEquippedResourcesResponse lastListRedPackets *walletv1.ListRedPacketsRequest listRedPacketsResp *walletv1.ListRedPacketsResponse lastGetRedPacket *walletv1.GetRedPacketRequest getRedPacketResp *walletv1.GetRedPacketResponse lastClaimRedPacket *walletv1.ClaimRedPacketRequest claimRedPacketResp *walletv1.ClaimRedPacketResponse } type fakeWithdrawalApplicationWriter struct { last financewithdrawal.CreateApplicationCommand calls int resp financewithdrawal.Application err error } func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) { f.last = command f.calls++ if f.err != nil { return financewithdrawal.Application{}, f.err } resp := f.resp if resp.ID == 0 { resp.ID = 77 } if resp.AppCode == "" { resp.AppCode = command.AppCode } if resp.UserID == 0 { resp.UserID = command.UserID } if resp.SalaryAssetType == "" { resp.SalaryAssetType = command.SalaryAssetType } if resp.WithdrawAmount == "" { resp.WithdrawAmount = command.WithdrawAmount } if resp.WithdrawAmountMinor == 0 { resp.WithdrawAmountMinor = command.WithdrawAmountMinor } if resp.WithdrawMethod == "" { resp.WithdrawMethod = command.WithdrawMethod } if resp.WithdrawAddress == "" { resp.WithdrawAddress = command.WithdrawAddress } if resp.FreezeCommandID == "" { resp.FreezeCommandID = command.FreezeCommandID } if resp.FreezeTransactionID == "" { resp.FreezeTransactionID = command.FreezeTransactionID } if resp.Status == "" { resp.Status = financewithdrawal.StatusPending } if resp.CreatedAtMS == 0 { resp.CreatedAtMS = command.CreatedAtMS } return resp, nil } type fakeMessageInboxClient struct { lastTabs *activityv1.ListMessageTabsRequest lastList *activityv1.ListInboxMessagesRequest lastRead *activityv1.MarkInboxMessageReadRequest lastReadAll *activityv1.MarkInboxSectionReadRequest lastDelete *activityv1.DeleteInboxMessageRequest tabsResp *activityv1.ListMessageTabsResponse listResp *activityv1.ListInboxMessagesResponse readResp *activityv1.MarkInboxMessageReadResponse readAllResp *activityv1.MarkInboxSectionReadResponse deleteResp *activityv1.DeleteInboxMessageResponse err error } type fakeTaskClient struct { lastList *activityv1.ListUserTasksRequest lastClaim *activityv1.ClaimTaskRewardRequest listResp *activityv1.ListUserTasksResponse claimResp *activityv1.ClaimTaskRewardResponse err error } type fakeAchievementClient struct { lastList *activityv1.ListAchievementsRequest lastBadges *activityv1.ListMyBadgesRequest lastSetDisplay *activityv1.SetBadgeDisplayRequest listResp *activityv1.ListAchievementsResponse badgesResp *activityv1.ListMyBadgesResponse setResp *activityv1.SetBadgeDisplayResponse err error } type fakeWeeklyStarClient struct { lastCurrent *activityv1.GetWeeklyStarCurrentRequest currentResp *activityv1.GetWeeklyStarCurrentResponse lastLeaderboard *activityv1.ListWeeklyStarLeaderboardRequest leaderboardResp *activityv1.ListWeeklyStarLeaderboardResponse lastHistory *activityv1.ListWeeklyStarHistoryRequest historyResp *activityv1.ListWeeklyStarHistoryResponse err error } type fakeCPWeeklyRankClient struct { lastStatus *activityv1.GetCPWeeklyRankStatusRequest statusResp *activityv1.GetCPWeeklyRankStatusResponse err error } type fakeUserCPClient struct { listIntimacyLeaderboardCalls int } type fakeBroadcastClient struct { lastRemove *activityv1.RemoveRegionBroadcastMemberRequest err error } func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) { f.lastLoginPassword = req f.loginPasswordCount++ if f.loginErr != nil { return nil, f.loginErr } return &userv1.AuthResponse{Token: &userv1.AuthToken{UserId: 10001}}, nil } func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) { f.lastLoginThirdParty = req f.loginThirdCount++ if f.loginErr != nil { return nil, f.loginErr } if f.loginThirdResp != nil { return f.loginThirdResp, nil } return &userv1.AuthResponse{ Token: &userv1.AuthToken{ UserId: 10001, DisplayUserId: "100001", SessionId: "sess-1", AccessToken: "access-1", RefreshToken: "refresh-1", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: false, OnboardingStatus: "profile_required", }, IsNewUser: true, ProfileCompleted: false, OnboardingStatus: "profile_required", }, nil } func (f *fakeUserAuthClient) RegisterThirdParty(_ context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) { f.lastRegisterThird = req f.loginThirdCount++ if f.loginErr != nil { return nil, f.loginErr } return &userv1.AuthResponse{ Token: &userv1.AuthToken{ UserId: 10001, DisplayUserId: "100001", SessionId: "sess-register", AccessToken: "access-register", RefreshToken: "refresh-register", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed", }, IsNewUser: true, ProfileCompleted: true, OnboardingStatus: "completed", }, nil } func (f *fakeUserAuthClient) CheckThirdPartyRegistered(_ context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error) { f.lastCheckThird = req if f.loginErr != nil { return nil, f.loginErr } return &userv1.CheckThirdPartyRegisteredResponse{Registered: f.checkRegistered}, nil } func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) { f.lastSetPassword = req return &userv1.SetPasswordResponse{PasswordSet: true}, nil } func (f *fakeUserAuthClient) QuickCreateAccount(_ context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error) { f.lastQuickCreate = req return &userv1.QuickCreateAccountResponse{Token: &userv1.AuthToken{ UserId: 10001, DisplayUserId: "100001", SessionId: "sess-quick", AccessToken: "access-quick", RefreshToken: "refresh-quick", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed", }, PasswordSet: true}, nil } func (f *fakeUserAuthClient) RefreshToken(_ context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) { f.lastRefresh = req f.refreshCount++ return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed"}}, nil } func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error) { f.lastLogout = req f.logoutCount++ return &userv1.LogoutResponse{Revoked: true}, nil } func (f *fakeUserAuthClient) RecordLoginBlocked(_ context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error) { f.lastBlocked = req f.blockedCount++ return &userv1.RecordLoginBlockedResponse{Recorded: true}, nil } func (f *fakeUserAuthClient) CheckLoginRiskIPWhitelist(_ context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error) { f.lastWhitelist = req return &userv1.CheckLoginRiskIPWhitelistResponse{Whitelisted: f.whitelisted}, nil } func (f *fakeUserAuthClient) AppHeartbeat(_ context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error) { f.lastAppHeartbeat = req return &userv1.AppHeartbeatResponse{Accepted: true, HeartbeatAtMs: 1_778_000_005_000, ServerTimeMs: 1_778_000_005_000, Token: &userv1.AuthToken{ UserId: req.GetUserId(), SessionId: req.GetSessionId(), AccessToken: "access-heartbeat", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) { f.lastGet = req f.getRequests = append(f.getRequests, req) if f.getErr != nil { return nil, f.getErr } if f.usersByID != nil { if user := f.usersByID[req.GetUserId()]; user != nil { return &userv1.GetUserResponse{User: user}, nil } } regionID := f.regionID if f.regionByUserID != nil { if mapped := f.regionByUserID[req.GetUserId()]; mapped != 0 { regionID = mapped } } if regionID == 0 { // 测试默认区域固定为 1001,避免新增入口在未配置 fake region 时误走 GLOBAL 桶。 regionID = 1001 } return &userv1.GetUserResponse{User: &userv1.User{ UserId: req.GetUserId(), Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: "100001", Username: "hy", Gender: "female", Avatar: "https://cdn.example/avatar.png", Birth: "2000-01-02", CountryId: 840, Country: "US", CountryName: "United States", RegionId: regionID, WithdrawUsdtTrc20Address: f.withdrawAddress, ProfileCompleted: true, OnboardingStatus: "completed", Invite: &userv1.InviteOverview{ MyInviteCode: "A1B2C3", InviteEnabled: true, InviteCount: 12, ValidInviteCount: 3, ValidInviteThresholdCoin: 80000, }, }}, nil } func (f *fakeUserProfileClient) BusinessUserLookup(_ context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error) { f.lastBusinessLookup = req if f.businessLookupErr != nil { return nil, f.businessLookupErr } if f.businessLookupResp != nil { return f.businessLookupResp, nil } return &userv1.BusinessUserLookupResponse{}, nil } func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) { f.lastStats = req if f.statsErr != nil { return nil, f.statsErr } if f.statsResp != nil { return f.statsResp, nil } return &userv1.GetMyProfileStatsResponse{Stats: &userv1.UserProfileStats{UserId: req.GetUserId()}}, nil } func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) { f.lastBatch = req f.batchRequests = append(f.batchRequests, req) users := make(map[int64]*userv1.User, len(req.GetUserIds())) for _, userID := range req.GetUserIds() { if f.usersByID != nil { if user, ok := f.usersByID[userID]; ok { users[userID] = user continue } } users[userID] = &userv1.User{ UserId: userID, Status: userv1.UserStatus_USER_STATUS_ACTIVE, DisplayUserId: strconv.FormatInt(100000+userID, 10), Username: "user-" + strconv.FormatInt(userID, 10), Gender: "female", Birth: "2000-01-02", CountryId: 840, Country: "US", CountryName: "United States", CountryDisplayName: "United States", Avatar: "https://cdn.example/avatar.png", RegionId: 1001, AppCode: req.GetMeta().GetAppCode(), } } return &userv1.BatchGetUsersResponse{Users: users}, nil } func (f *fakeUserProfileClient) BatchGetRoomBasicUsers(_ context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) { f.lastRoomBasicBatch = req f.roomBasicRequests = append(f.roomBasicRequests, req) users := make(map[int64]*userv1.RoomBasicUser, len(req.GetUserIds())) for _, userID := range req.GetUserIds() { if f.usersByID != nil { if user, ok := f.usersByID[userID]; ok { users[userID] = &userv1.RoomBasicUser{ UserId: user.GetUserId(), Username: user.GetUsername(), Avatar: user.GetAvatar(), DisplayUserId: user.GetDisplayUserId(), } continue } } users[userID] = &userv1.RoomBasicUser{ UserId: userID, Username: "user-" + strconv.FormatInt(userID, 10), Avatar: "https://cdn.example/avatar.png", DisplayUserId: strconv.FormatInt(100000+userID, 10), } } return &userv1.BatchGetRoomBasicUsersResponse{Users: users}, nil } func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) { f.lastComplete = req if f.completeErr != nil { return nil, f.completeErr } return &userv1.CompleteOnboardingResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Username: req.GetUsername(), Avatar: req.GetAvatar(), Country: req.GetCountry(), RegionId: 1001, ProfileCompleted: true, ProfileCompletedAtMs: 4000, OnboardingStatus: "completed", UpdatedAtMs: 4000, }, ProfileCompleted: true, ProfileCompletedAtMs: 4000, OnboardingStatus: "completed", Token: &userv1.AuthToken{ UserId: req.GetUserId(), SessionId: req.GetMeta().GetSessionId(), AccessToken: "access-completed", ExpiresInSec: 1800, TokenType: "Bearer", DisplayUserId: "100001", ProfileCompleted: true, OnboardingStatus: "completed", }, Invite: &userv1.InviteBinding{ Bound: req.GetInviteCode() != "", InviteCode: req.GetInviteCode(), InviterUserId: 10001, }}, nil } func (f *fakeUserProfileClient) UpdateUserProfile(_ context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) { f.lastUpdate = req return &userv1.UpdateUserProfileResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Username: valueOfString(req.Username), Avatar: valueOfString(req.Avatar), Gender: valueOfString(req.Gender), Birth: valueOfString(req.Birth), UpdatedAtMs: 2000, }}, nil } func (f *fakeUserProfileClient) UpdateUserProfileBackground(_ context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error) { f.lastBackground = req return &userv1.UpdateUserProfileBackgroundResponse{User: &userv1.User{ UserId: req.GetUserId(), ProfileBgImg: req.GetProfileBgImg(), UpdatedAtMs: 2100, }}, nil } func (f *fakeUserProfileClient) UpdateUserContactInfo(_ context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) { f.lastContact = req return &userv1.UpdateUserContactInfoResponse{User: &userv1.User{ UserId: req.GetUserId(), ContactInfo: req.GetContactInfo(), UpdatedAtMs: 2200, }}, nil } func (f *fakeUserProfileClient) UpdateUserWithdrawAddress(_ context.Context, req *userv1.UpdateUserWithdrawAddressRequest) (*userv1.UpdateUserWithdrawAddressResponse, error) { f.lastWithdrawAddress = req f.withdrawAddress = req.GetUsdtTrc20Address() return &userv1.UpdateUserWithdrawAddressResponse{User: &userv1.User{ UserId: req.GetUserId(), WithdrawUsdtTrc20Address: req.GetUsdtTrc20Address(), UpdatedAtMs: 2300, }}, nil } func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) { f.lastCountry = req if f.countryErr != nil { return nil, f.countryErr } return &userv1.ChangeUserCountryResponse{User: &userv1.User{ UserId: req.GetUserId(), DisplayUserId: "100001", Country: req.GetCountry(), RegionId: 2002, UpdatedAtMs: 3000, }, NextChangeAllowedAtMs: 3600000, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil } func (f *fakeUserProfileClient) AdminChangeUserCountry(_ context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) { f.lastAdminCountry = req if f.countryErr != nil { return nil, f.countryErr } return &userv1.ChangeUserCountryResponse{User: &userv1.User{ UserId: req.GetTargetUserId(), DisplayUserId: "100001", Country: req.GetCountry(), RegionId: 2002, Status: userv1.UserStatus_USER_STATUS_ACTIVE, UpdatedAtMs: 3000, }, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil } func (f *fakeUserProfileClient) CreateManagerUserBlock(_ context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error) { f.lastCreateBlock = req if f.blockErr != nil { return nil, f.blockErr } if f.createBlockResp != nil { return f.createBlockResp, nil } return &userv1.CreateManagerUserBlockResponse{Block: &userv1.ManagerUserBlock{ BlockId: "block-1", ManagerUserId: req.GetManagerUserId(), TargetUserId: req.GetTargetUserId(), TargetDisplayUserId: "163006", TargetUsername: "blocked", Country: "US", BlockedUntilMs: req.GetBlockedUntilMs(), Status: "active", Reason: req.GetReason(), }}, nil } func (f *fakeUserProfileClient) ListManagerUserBlocks(_ context.Context, req *userv1.ListManagerUserBlocksRequest) (*userv1.ListManagerUserBlocksResponse, error) { f.lastListBlocks = req if f.blockErr != nil { return nil, f.blockErr } if f.blocksResp != nil { return f.blocksResp, nil } return &userv1.ListManagerUserBlocksResponse{}, nil } func (f *fakeUserProfileClient) UnblockManagerUser(_ context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error) { f.lastUnblock = req if f.blockErr != nil { return nil, f.blockErr } if f.unblockResp != nil { return f.unblockResp, nil } return &userv1.UnblockManagerUserResponse{Block: &userv1.ManagerUserBlock{ BlockId: req.GetBlockId(), ManagerUserId: req.GetManagerUserId(), Status: "released", }}, nil } func (f *fakeUserIdentityClient) GetUserIdentity(_ context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) { return &userv1.GetUserIdentityResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: "100001", Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ResolveDisplayUserID(_ context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) { f.lastResolve = req if f.resolveErr != nil { return nil, f.resolveErr } userID := int64(0) if f.resolveByDisplay != nil { userID = f.resolveByDisplay[req.GetDisplayUserId()] } return &userv1.ResolveDisplayUserIDResponse{Identity: &userv1.UserIdentity{ UserId: userID, DisplayUserId: req.GetDisplayUserId(), Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ChangeDisplayUserID(_ context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error) { return &userv1.ChangeDisplayUserIDResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: req.GetNewDisplayUserId(), Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ApplyPrettyDisplayUserID(_ context.Context, req *userv1.ApplyPrettyDisplayUserIDRequest) (*userv1.ApplyPrettyDisplayUserIDResponse, error) { return &userv1.ApplyPrettyDisplayUserIDResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: req.GetPrettyDisplayUserId(), Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeUserIdentityClient) ListAvailablePrettyDisplayIDs(_ context.Context, req *userv1.ListAvailablePrettyDisplayIDsRequest) (*userv1.ListAvailablePrettyDisplayIDsResponse, error) { return &userv1.ListAvailablePrettyDisplayIDsResponse{ Items: []*userv1.PrettyDisplayID{{ PrettyId: "pretty-test", DisplayUserId: "VIP2026", Status: "available", Source: "pool", AppCode: req.GetMeta().GetAppCode(), }}, Total: 1, }, nil } func (f *fakeUserIdentityClient) ApplyPrettyDisplayIDFromPool(_ context.Context, req *userv1.ApplyPrettyDisplayIDFromPoolRequest) (*userv1.ApplyPrettyDisplayIDFromPoolResponse, error) { return &userv1.ApplyPrettyDisplayIDFromPoolResponse{Identity: &userv1.UserIdentity{ UserId: req.GetUserId(), DisplayUserId: "VIP2026", PrettyId: req.GetPrettyId(), PrettyDisplayUserId: "VIP2026", Status: "active", AppCode: req.GetMeta().GetAppCode(), }}, nil } func (f *fakeRoomQueryClient) ListRooms(_ context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) { f.lastList = req if f.err != nil { return nil, f.err } if f.resp != nil { return f.resp, nil } return &roomv1.ListRoomsResponse{}, nil } func (f *fakeRoomQueryClient) ListRoomsByOwners(_ context.Context, req *roomv1.ListRoomsByOwnersRequest) (*roomv1.ListRoomsResponse, error) { f.lastListOwners = req if f.ownersErr != nil { return nil, f.ownersErr } if f.ownersResp != nil { return f.ownersResp, nil } return &roomv1.ListRoomsResponse{}, nil } func (f *fakeRoomQueryClient) ListRoomFeeds(_ context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) { f.lastFeeds = req if f.feedsErr != nil { return nil, f.feedsErr } if f.feedsResp != nil { return f.feedsResp, nil } if f.resp != nil { return f.resp, nil } return &roomv1.ListRoomsResponse{}, nil } func (f *fakeRoomQueryClient) ListRoomGiftLeaderboard(context.Context, *roomv1.ListRoomGiftLeaderboardRequest) (*roomv1.ListRoomGiftLeaderboardResponse, error) { return &roomv1.ListRoomGiftLeaderboardResponse{}, nil } func (f *fakeRoomQueryClient) GetMyRoom(_ context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) { f.lastMyRoom = req if f.myRoomErr != nil { return nil, f.myRoomErr } if f.myRoomResp != nil { return f.myRoomResp, nil } return &roomv1.GetMyRoomResponse{}, nil } func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) { f.lastCurrent = req if f.currentErr != nil { return nil, f.currentErr } if f.currentResp != nil { return f.currentResp, nil } return &roomv1.GetCurrentRoomResponse{}, nil } func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) { f.lastSnapshot = req if f.snapshotErr != nil { return nil, f.snapshotErr } if f.snapshotResp != nil { return f.snapshotResp, nil } return &roomv1.GetRoomSnapshotResponse{}, nil } func (f *fakeRoomQueryClient) ListRoomBackgrounds(_ context.Context, req *roomv1.ListRoomBackgroundsRequest) (*roomv1.ListRoomBackgroundsResponse, error) { f.lastBackgrounds = req if f.backgroundsErr != nil { return nil, f.backgroundsErr } if f.backgroundsResp != nil { return f.backgroundsResp, nil } return &roomv1.ListRoomBackgroundsResponse{}, nil } func (f *fakeRoomQueryClient) GetRoomRocket(_ context.Context, req *roomv1.GetRoomRocketRequest) (*roomv1.GetRoomRocketResponse, error) { f.lastRocket = req if f.rocketErr != nil { return nil, f.rocketErr } if f.rocketResp != nil { return f.rocketResp, nil } return &roomv1.GetRoomRocketResponse{}, nil } func (f *fakeRoomQueryClient) ListRoomOnlineUsers(_ context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) { f.lastOnline = req if f.onlineErr != nil { return nil, f.onlineErr } if f.onlineResp != nil { return f.onlineResp, nil } return &roomv1.ListRoomOnlineUsersResponse{Page: req.GetPage(), PageSize: req.GetPageSize()}, nil } func (f *fakeRoomQueryClient) ListRoomBannedUsers(_ context.Context, req *roomv1.ListRoomBannedUsersRequest) (*roomv1.ListRoomBannedUsersResponse, error) { f.lastBannedUsers = req if f.bannedUsersErr != nil { return nil, f.bannedUsersErr } if f.bannedUsersResp != nil { return f.bannedUsersResp, nil } return &roomv1.ListRoomBannedUsersResponse{Page: req.GetPage(), PageSize: req.GetPageSize()}, nil } func (f *fakeUserDeviceClient) BindPushToken(_ context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) { f.lastBind = req if f.bindErr != nil { return nil, f.bindErr } return &userv1.BindPushTokenResponse{Bound: true, UpdatedAtMs: 1234}, nil } func (f *fakeUserDeviceClient) DeletePushToken(_ context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error) { f.lastDelete = req if f.deleteErr != nil { return nil, f.deleteErr } return &userv1.DeletePushTokenResponse{Deleted: true, UpdatedAtMs: 1235}, nil } func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) { f.last = req if f.err != nil { return nil, f.err } if f.resp != nil { return f.resp, nil } return &userv1.ListRegistrationCountriesResponse{}, nil } func (f *fakeUserCountryQueryClient) ListLoginRiskBlockedCountries(_ context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error) { f.lastBlocked = req if f.blockedErr != nil { return nil, f.blockedErr } if f.blockedResp != nil { return f.blockedResp, nil } return &userv1.ListLoginRiskBlockedCountriesResponse{}, nil } func (f *fakeUserRegionClient) GetRegion(_ context.Context, req *userv1.GetRegionRequest) (*userv1.RegionResponse, error) { f.last = req if f.err != nil { return nil, f.err } if f.resp != nil { return f.resp, nil } return &userv1.RegionResponse{Region: &userv1.Region{RegionId: req.GetRegionId(), Countries: []string{"US"}}}, nil } func (f *fakeMessageInboxClient) ListMessageTabs(_ context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) { f.lastTabs = req if f.err != nil { return nil, f.err } if f.tabsResp != nil { return f.tabsResp, nil } return &activityv1.ListMessageTabsResponse{}, nil } func (f *fakeMessageInboxClient) ListInboxMessages(_ context.Context, req *activityv1.ListInboxMessagesRequest) (*activityv1.ListInboxMessagesResponse, error) { f.lastList = req if f.err != nil { return nil, f.err } if f.listResp != nil { return f.listResp, nil } return &activityv1.ListInboxMessagesResponse{Section: req.GetSection()}, nil } func (f *fakeMessageInboxClient) MarkInboxMessageRead(_ context.Context, req *activityv1.MarkInboxMessageReadRequest) (*activityv1.MarkInboxMessageReadResponse, error) { f.lastRead = req if f.err != nil { return nil, f.err } if f.readResp != nil { return f.readResp, nil } return &activityv1.MarkInboxMessageReadResponse{MessageId: req.GetMessageId(), Read: true, ReadAtMs: 1234}, nil } func (f *fakeMessageInboxClient) MarkInboxSectionRead(_ context.Context, req *activityv1.MarkInboxSectionReadRequest) (*activityv1.MarkInboxSectionReadResponse, error) { f.lastReadAll = req if f.err != nil { return nil, f.err } if f.readAllResp != nil { return f.readAllResp, nil } return &activityv1.MarkInboxSectionReadResponse{Section: req.GetSection(), ReadCount: 2}, nil } func (f *fakeMessageInboxClient) DeleteInboxMessage(_ context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) { f.lastDelete = req if f.err != nil { return nil, f.err } if f.deleteResp != nil { return f.deleteResp, nil } return &activityv1.DeleteInboxMessageResponse{MessageId: req.GetMessageId(), Deleted: true}, nil } func (f *fakeTaskClient) ListUserTasks(_ context.Context, req *activityv1.ListUserTasksRequest) (*activityv1.ListUserTasksResponse, error) { f.lastList = req if f.err != nil { return nil, f.err } if f.listResp != nil { return f.listResp, nil } return &activityv1.ListUserTasksResponse{}, nil } func (f *fakeTaskClient) ClaimTaskReward(_ context.Context, req *activityv1.ClaimTaskRewardRequest) (*activityv1.ClaimTaskRewardResponse, error) { f.lastClaim = req if f.err != nil { return nil, f.err } if f.claimResp != nil { return f.claimResp, nil } return &activityv1.ClaimTaskRewardResponse{}, nil } func (f *fakeAchievementClient) ListAchievements(_ context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error) { f.lastList = req if f.err != nil { return nil, f.err } if f.listResp != nil { return f.listResp, nil } return &activityv1.ListAchievementsResponse{}, nil } func (f *fakeAchievementClient) ListMyBadges(_ context.Context, req *activityv1.ListMyBadgesRequest) (*activityv1.ListMyBadgesResponse, error) { f.lastBadges = req if f.err != nil { return nil, f.err } if f.badgesResp != nil { return f.badgesResp, nil } return &activityv1.ListMyBadgesResponse{}, nil } func (f *fakeAchievementClient) SetBadgeDisplay(_ context.Context, req *activityv1.SetBadgeDisplayRequest) (*activityv1.SetBadgeDisplayResponse, error) { f.lastSetDisplay = req if f.err != nil { return nil, f.err } if f.setResp != nil { return f.setResp, nil } return &activityv1.SetBadgeDisplayResponse{}, nil } func (f *fakeWeeklyStarClient) GetWeeklyStarCurrent(_ context.Context, req *activityv1.GetWeeklyStarCurrentRequest) (*activityv1.GetWeeklyStarCurrentResponse, error) { f.lastCurrent = req if f.err != nil { return nil, f.err } if f.currentResp != nil { return f.currentResp, nil } return &activityv1.GetWeeklyStarCurrentResponse{}, nil } func (f *fakeWeeklyStarClient) ListWeeklyStarLeaderboard(_ context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) { f.lastLeaderboard = req if f.err != nil { return nil, f.err } if f.leaderboardResp != nil { return f.leaderboardResp, nil } return &activityv1.ListWeeklyStarLeaderboardResponse{}, nil } func (f *fakeWeeklyStarClient) ListWeeklyStarHistory(_ context.Context, req *activityv1.ListWeeklyStarHistoryRequest) (*activityv1.ListWeeklyStarHistoryResponse, error) { f.lastHistory = req if f.err != nil { return nil, f.err } if f.historyResp != nil { return f.historyResp, nil } return &activityv1.ListWeeklyStarHistoryResponse{}, nil } func (f *fakeCPWeeklyRankClient) GetCPWeeklyRankStatus(_ context.Context, req *activityv1.GetCPWeeklyRankStatusRequest) (*activityv1.GetCPWeeklyRankStatusResponse, error) { f.lastStatus = req if f.err != nil { return nil, f.err } if f.statusResp != nil { return f.statusResp, nil } return &activityv1.GetCPWeeklyRankStatusResponse{}, nil } func (f *fakeUserCPClient) ListCPApplications(context.Context, *userv1.ListCPApplicationsRequest) (*userv1.ListCPApplicationsResponse, error) { return &userv1.ListCPApplicationsResponse{}, nil } func (f *fakeUserCPClient) AcceptCPApplication(context.Context, *userv1.AcceptCPApplicationRequest) (*userv1.AcceptCPApplicationResponse, error) { return &userv1.AcceptCPApplicationResponse{}, nil } func (f *fakeUserCPClient) RejectCPApplication(context.Context, *userv1.RejectCPApplicationRequest) (*userv1.RejectCPApplicationResponse, error) { return &userv1.RejectCPApplicationResponse{}, nil } func (f *fakeUserCPClient) ListCPRelationships(context.Context, *userv1.ListCPRelationshipsRequest) (*userv1.ListCPRelationshipsResponse, error) { return &userv1.ListCPRelationshipsResponse{}, nil } func (f *fakeUserCPClient) ListCPIntimacyLeaderboard(context.Context, *userv1.ListCPIntimacyLeaderboardRequest) (*userv1.ListCPIntimacyLeaderboardResponse, error) { f.listIntimacyLeaderboardCalls++ return nil, errors.New("ordinary cp intimacy leaderboard must not serve cp weekly activity") } func (f *fakeUserCPClient) PrepareBreakCPRelationship(context.Context, *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) { return &userv1.PrepareBreakCPRelationshipResponse{}, nil } func (f *fakeUserCPClient) ConfirmBreakCPRelationship(context.Context, *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error) { return &userv1.ConfirmBreakCPRelationshipResponse{}, nil } func (f *fakeUserCPClient) CancelBreakCPRelationship(context.Context, *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error) { return &userv1.CancelBreakCPRelationshipResponse{}, nil } func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) { f.lastRemove = req if f.err != nil { return nil, f.err } return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: "hy_lalu_bc_r_1001", Removed: true}, nil } func (f *fakeUserHostClient) SearchAgencies(_ context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) { f.lastSearchAgencies = req if f.searchAgenciesErr != nil { return nil, f.searchAgenciesErr } if f.searchAgenciesResp != nil { return f.searchAgenciesResp, nil } return &userv1.SearchAgenciesResponse{}, nil } func (f *fakeUserHostClient) ApplyToAgency(_ context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) { f.lastApplyAgency = req if f.applyAgencyErr != nil { return nil, f.applyAgencyErr } if f.applyAgencyResp != nil { return f.applyAgencyResp, nil } return &userv1.ApplyToAgencyResponse{}, nil } func (f *fakeUserHostClient) InviteAgency(_ context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error) { f.lastInviteAgency = req if f.inviteAgencyErr != nil { return nil, f.inviteAgencyErr } if f.inviteAgencyResp != nil { return f.inviteAgencyResp, nil } return &userv1.InviteAgencyResponse{}, nil } func (f *fakeUserHostClient) InviteBD(_ context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, error) { f.lastInviteBD = req if f.inviteBDErr != nil { return nil, f.inviteBDErr } if f.inviteBDResp != nil { return f.inviteBDResp, nil } return &userv1.InviteBDResponse{}, nil } func (f *fakeUserHostClient) InviteHost(_ context.Context, req *userv1.InviteHostRequest) (*userv1.InviteHostResponse, error) { f.lastInviteHost = req if f.inviteHostErr != nil { return nil, f.inviteHostErr } if f.inviteHostResp != nil { return f.inviteHostResp, nil } return &userv1.InviteHostResponse{}, nil } func (f *fakeUserHostClient) ListBDLeaderBDs(_ context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) { f.lastLeaderBDs = req if f.leaderBDsErr != nil { return nil, f.leaderBDsErr } if f.leaderBDsResp != nil { return f.leaderBDsResp, nil } return &userv1.ListBDLeaderBDsResponse{}, nil } func (f *fakeUserHostClient) ListBDLeaderAgencies(_ context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error) { f.lastLeaderAgencies = req if f.leaderAgenciesErr != nil { return nil, f.leaderAgenciesErr } if f.leaderAgenciesResp != nil { return f.leaderAgenciesResp, nil } return &userv1.ListBDLeaderAgenciesResponse{}, nil } func (f *fakeUserHostClient) ListBDAgencies(_ context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error) { f.lastBDAgencies = req if f.bdAgenciesErr != nil { return nil, f.bdAgenciesErr } if f.bdAgenciesResp != nil { return f.bdAgenciesResp, nil } return &userv1.ListBDAgenciesResponse{}, nil } func (f *fakeUserHostClient) ListRoleInvitations(_ context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) { f.lastRoleInvitations = req if f.roleInvitationsErr != nil { return nil, f.roleInvitationsErr } if f.roleInvitationsResp != nil { return f.roleInvitationsResp, nil } return &userv1.ListRoleInvitationsResponse{}, nil } func (f *fakeUserHostClient) ProcessRoleInvitation(_ context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) { f.lastProcessInvite = req if f.processInviteErr != nil { return nil, f.processInviteErr } if f.processInviteResp != nil { return f.processInviteResp, nil } return &userv1.ProcessRoleInvitationResponse{}, nil } func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) { f.lastHost = req f.hostCalls++ if f.hostErr != nil { return nil, f.hostErr } if f.hostProfiles != nil { return &userv1.GetHostProfileResponse{HostProfile: f.hostProfiles[req.GetUserId()]}, nil } if f.hostProfile != nil { return &userv1.GetHostProfileResponse{HostProfile: f.hostProfile}, nil } return &userv1.GetHostProfileResponse{}, nil } func (f *fakeUserHostClient) BatchGetHostProfiles(_ context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) { f.lastBatchHost = req f.batchHostCalls++ if f.batchHostErr != nil { return nil, f.batchHostErr } profiles := make(map[int64]*userv1.HostProfile, len(req.GetUserIds())) for _, userID := range req.GetUserIds() { if f.hostProfiles != nil { if profile := f.hostProfiles[userID]; profile != nil { profiles[userID] = profile } continue } if f.hostProfile != nil && (f.hostProfile.GetUserId() == 0 || f.hostProfile.GetUserId() == userID) { profiles[userID] = f.hostProfile } } return &userv1.BatchGetHostProfilesResponse{HostProfiles: profiles}, nil } func (f *fakeUserHostClient) GetBDProfile(_ context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) { f.lastBD = req if f.bdErr != nil { return nil, f.bdErr } if f.bdProfile != nil { return &userv1.GetBDProfileResponse{BdProfile: f.bdProfile}, nil } return &userv1.GetBDProfileResponse{}, nil } func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) { f.last = req if f.err != nil { return nil, f.err } if f.profile != nil { return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: f.profile}, nil } return &userv1.GetCoinSellerProfileResponse{}, nil } func (f *fakeUserHostClient) ListActiveCoinSellersInMyRegion(_ context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) { f.lastListCoinSellers = req if f.listCoinSellersErr != nil { return nil, f.listCoinSellersErr } if f.listCoinSellersResp != nil { return f.listCoinSellersResp, nil } return &userv1.ListActiveCoinSellersInMyRegionResponse{}, nil } func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) { f.lastRoleSummary = req if f.roleSummaryErr != nil { return nil, f.roleSummaryErr } if f.roleSummary != nil { return &userv1.GetUserRoleSummaryResponse{Summary: f.roleSummary}, nil } return &userv1.GetUserRoleSummaryResponse{Summary: &userv1.UserRoleSummary{UserId: req.GetUserId()}}, nil } func (f *fakeUserHostClient) GetAgency(_ context.Context, req *userv1.GetAgencyRequest) (*userv1.GetAgencyResponse, error) { f.lastGetAgency = req if f.getAgencyErr != nil { return nil, f.getAgencyErr } if f.getAgencyResp != nil { return f.getAgencyResp, nil } return &userv1.GetAgencyResponse{}, nil } func (f *fakeUserHostClient) UpdateAgencyProfile(_ context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, error) { f.lastUpdateAgency = req if f.updateAgencyErr != nil { return nil, f.updateAgencyErr } if f.updateAgencyResp != nil { return f.updateAgencyResp, nil } return &userv1.UpdateAgencyProfileResponse{Agency: &userv1.Agency{ AgencyId: req.GetAgencyId(), OwnerUserId: req.GetOperatorUserId(), Name: req.GetName(), Avatar: req.GetAvatar(), Status: "active", }}, nil } func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) { f.lastCapability = req f.capabilities = append(f.capabilities, req.GetCapability()) if f.capabilityErr != nil { return nil, f.capabilityErr } if f.capabilityResp != nil { return f.capabilityResp, nil } return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil } func (f *fakeUserHostClient) sawCapability(capability string) bool { for _, item := range f.capabilities { if item == capability { return true } } return false } func (f *fakeUserHostClient) GetAgencyMembers(_ context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) { f.lastMembers = req if f.membersErr != nil { return nil, f.membersErr } if f.membersResp != nil { return f.membersResp, nil } return &userv1.GetAgencyMembersResponse{}, nil } func (f *fakeUserHostClient) GetAgencyApplications(_ context.Context, req *userv1.GetAgencyApplicationsRequest) (*userv1.GetAgencyApplicationsResponse, error) { f.lastApplications = req if f.applicationsErr != nil { return nil, f.applicationsErr } if f.applicationsResp != nil { return f.applicationsResp, nil } return &userv1.GetAgencyApplicationsResponse{}, nil } func (f *fakeUserHostClient) ReviewAgencyApplication(_ context.Context, req *userv1.ReviewAgencyApplicationRequest) (*userv1.ReviewAgencyApplicationResponse, error) { f.lastReview = req if f.reviewErr != nil { return nil, f.reviewErr } if f.reviewResp != nil { return f.reviewResp, nil } return &userv1.ReviewAgencyApplicationResponse{}, nil } func (f *fakeUserHostClient) KickAgencyHost(_ context.Context, req *userv1.KickAgencyHostRequest) (*userv1.KickAgencyHostResponse, error) { f.lastKick = req if f.kickErr != nil { return nil, f.kickErr } if f.kickResp != nil { return f.kickResp, nil } return &userv1.KickAgencyHostResponse{}, nil } func (f *fakeUserHostAdminClient) CreateBDLeader(_ context.Context, req *userv1.CreateBDLeaderRequest) (*userv1.CreateBDLeaderResponse, error) { f.lastCreateBDLeader = req if f.createBDLeaderErr != nil { return nil, f.createBDLeaderErr } if f.createBDLeaderResp != nil { return f.createBDLeaderResp, nil } return &userv1.CreateBDLeaderResponse{BdProfile: &userv1.BDProfile{ UserId: req.GetTargetUserId(), Role: "bd_leader", RegionId: 1001, Status: "active", CreatedByUserId: req.GetAdminUserId(), }}, nil } func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { f.last = req f.balanceRequests = append(f.balanceRequests, req) if f.err != nil { return nil, f.err } if f.balancesByUserID != nil { if resp, ok := f.balancesByUserID[req.GetUserId()]; ok { return resp, nil } } if f.resp != nil { return f.resp, nil } return &walletv1.GetBalancesResponse{}, nil } func (f *fakeWalletClient) DebitDirectGift(_ context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) { f.lastDirectGift = req if f.directGiftErr != nil { return nil, f.directGiftErr } if f.directGiftResp != nil { return f.directGiftResp, nil } return &walletv1.DebitGiftResponse{}, nil } func (f *fakeWalletClient) GetActiveHostSalaryPolicy(_ context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) { f.lastHostSalaryPolicy = req if f.hostSalaryPolicyErr != nil { return nil, f.hostSalaryPolicyErr } if f.hostSalaryPolicyResp != nil { return f.hostSalaryPolicyResp, nil } return &walletv1.GetActiveHostSalaryPolicyResponse{}, nil } func (f *fakeWalletClient) GetHostSalaryProgress(_ context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) { f.lastHostSalaryProgress = req if f.hostSalaryProgressErr != nil { return nil, f.hostSalaryProgressErr } if f.hostSalaryProgressResp != nil { return f.hostSalaryProgressResp, nil } return &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{HostUserId: req.GetHostUserId()}}, nil } func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) { f.lastOverview = req if f.overviewErr != nil { return nil, f.overviewErr } if f.overviewResp != nil { return f.overviewResp, nil } if f.err != nil { return nil, f.err } balances := []*walletv1.AssetBalance(nil) if f.resp != nil { balances = f.resp.GetBalances() } return &walletv1.GetWalletOverviewResponse{ Balances: balances, FeatureFlags: &walletv1.WalletFeatureFlags{ RechargeEnabled: true, DiamondExchangeEnabled: true, }, }, nil } func (f *fakeWalletClient) GetWalletValueSummary(_ context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) { f.lastValueSummary = req if f.valueSummaryErr != nil { return nil, f.valueSummaryErr } if f.valueSummaryResp != nil { return f.valueSummaryResp, nil } if f.err != nil { return nil, f.err } var coinAmount int64 if f.resp != nil { for _, balance := range f.resp.GetBalances() { if balance.GetAssetType() == "COIN" { coinAmount = balance.GetAvailableAmount() break } } } return &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{CoinAmount: coinAmount}}, nil } func (f *fakeWalletClient) GetUserGiftWall(_ context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) { f.lastGiftWall = req if f.err != nil { return nil, f.err } if f.giftWallResp != nil { return f.giftWallResp, nil } return &walletv1.GetUserGiftWallResponse{}, nil } func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) { f.lastRechargeProducts = req if f.err != nil { return nil, f.err } if f.rechargeProductsResp != nil { return f.rechargeProductsResp, nil } return &walletv1.ListRechargeProductsResponse{}, nil } func (f *fakeWalletClient) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) { f.lastThirdPartyChannels = req if f.err != nil { return nil, f.err } if f.thirdPartyChannelsResp != nil { return f.thirdPartyChannelsResp, nil } return &walletv1.ListThirdPartyPaymentChannelsResponse{}, nil } func (f *fakeWalletClient) ListH5RechargeOptions(_ context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error) { f.lastH5Options = req if f.err != nil { return nil, f.err } if f.h5OptionsResp != nil { return f.h5OptionsResp, nil } return &walletv1.H5RechargeOptionsResponse{}, nil } func (f *fakeWalletClient) CreateH5RechargeOrder(_ context.Context, req *walletv1.CreateH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { f.lastH5CreateOrder = req if f.err != nil { return nil, f.err } if f.h5CreateOrderResp != nil { return f.h5CreateOrderResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: "h5-order-1", Status: "pending"}}, nil } func (f *fakeWalletClient) CreateTemporaryRechargeOrder(_ context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { f.lastTemporaryCreate = req if f.err != nil { return nil, f.err } if f.temporaryCreateResp != nil { return f.temporaryCreateResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: "temporary-order-1", Status: "pending"}}, nil } func (f *fakeWalletClient) GetTemporaryRechargeOrder(_ context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { f.lastTemporaryGet = req if f.err != nil { return nil, f.err } if f.temporaryGetResp != nil { return f.temporaryGetResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), Status: "pending"}}, nil } func (f *fakeWalletClient) ListTemporaryRechargeOrders(_ context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error) { f.lastTemporaryList = req if f.err != nil { return nil, f.err } if f.temporaryListResp != nil { return f.temporaryListResp, nil } return &walletv1.ListTemporaryRechargeOrdersResponse{}, nil } func (f *fakeWalletClient) SubmitH5RechargeTx(_ context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error) { f.lastH5SubmitTx = req if f.err != nil { return nil, f.err } if f.h5SubmitTxResp != nil { return f.h5SubmitTxResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), TxHash: req.GetTxHash(), Status: "pending"}}, nil } func (f *fakeWalletClient) GetH5RechargeOrder(_ context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) { f.lastH5GetOrder = req if f.err != nil { return nil, f.err } if f.h5GetOrderResp != nil { return f.h5GetOrderResp, nil } return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), Status: "pending"}}, nil } func (f *fakeWalletClient) HandleMifapayNotify(_ context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) { f.lastMifaPayNotify = req if f.err != nil { return nil, f.err } if f.mifaPayNotifyResp != nil { return f.mifaPayNotifyResp, nil } return &walletv1.HandleMifapayNotifyResponse{Accepted: true, ResponseText: "SUCCESS", OrderId: "h5-order-1"}, nil } func (f *fakeWalletClient) HandleV5PayNotify(_ context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) { f.lastV5PayNotify = req if f.err != nil { return nil, f.err } if f.v5PayNotifyResp != nil { return f.v5PayNotifyResp, nil } return &walletv1.HandleV5PayNotifyResponse{Accepted: true, ResponseText: "success", OrderId: "h5-order-1"}, nil } func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) { f.lastGoogleConfirm = req if f.err != nil { return nil, f.err } if f.googleConfirmResp != nil { return f.googleConfirmResp, nil } return &walletv1.ConfirmGooglePaymentResponse{}, nil } func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) { f.lastDiamondExchange = req if f.err != nil { return nil, f.err } if f.diamondExchangeResp != nil { return f.diamondExchangeResp, nil } return &walletv1.GetDiamondExchangeConfigResponse{}, nil } func (f *fakeWalletClient) ListWalletTransactions(_ context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) { f.lastTransactions = req if f.err != nil { return nil, f.err } if f.transactionsResp != nil { return f.transactionsResp, nil } return &walletv1.ListWalletTransactionsResponse{}, nil } func (f *fakeWalletClient) ListVipPackages(_ context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) { f.lastVipPackages = req if f.vipErr != nil { return nil, f.vipErr } if f.vipPackagesResp != nil { return f.vipPackagesResp, nil } return &walletv1.ListVipPackagesResponse{}, nil } func (f *fakeWalletClient) GetMyVip(_ context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) { f.lastMyVip = req if f.vipErr != nil { return nil, f.vipErr } if f.myVipResp != nil { return f.myVipResp, nil } return &walletv1.GetMyVipResponse{Vip: &walletv1.UserVip{UserId: req.GetUserId()}}, nil } func (f *fakeWalletClient) PurchaseVip(_ context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error) { f.lastPurchaseVip = req if f.vipErr != nil { return nil, f.vipErr } if f.purchaseVipResp != nil { return f.purchaseVipResp, nil } return &walletv1.PurchaseVipResponse{}, nil } func (f *fakeWalletClient) DebitCPBreakupFee(context.Context, *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) { return &walletv1.DebitCPBreakupFeeResponse{}, nil } func (f *fakeWalletClient) DebitWheelDraw(context.Context, *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) { return &walletv1.DebitWheelDrawResponse{}, nil } func (f *fakeWalletClient) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) { f.lastGrantVip = req if f.vipErr != nil { return nil, f.vipErr } if f.grantVipResp != nil { return f.grantVipResp, nil } return &walletv1.GrantVipResponse{ TransactionId: "vip-grant-tx", Vip: &walletv1.UserVip{UserId: req.GetTargetUserId(), Level: req.GetLevel(), Active: true}, ServerTimeMs: 1_780_000_000_000, }, nil } func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) { f.lastTransfer = req if f.transferErr != nil { return nil, f.transferErr } if f.transferResp != nil { return f.transferResp, nil } return &walletv1.TransferCoinFromSellerResponse{}, nil } func (f *fakeWalletClient) ListCoinSellerSalaryExchangeRateTiers(_ context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) { f.lastListExchangeTiers = req if f.err != nil { return nil, f.err } if f.listExchangeTiersResp != nil { return f.listExchangeTiersResp, nil } return &walletv1.ListCoinSellerSalaryExchangeRateTiersResponse{}, nil } func (f *fakeWalletClient) ExchangeSalaryToCoin(_ context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) { f.lastExchangeSalary = req if f.exchangeSalaryErr != nil { return nil, f.exchangeSalaryErr } if f.exchangeSalaryResp != nil { return f.exchangeSalaryResp, nil } return &walletv1.ExchangeSalaryToCoinResponse{}, nil } func (f *fakeWalletClient) TransferSalaryToCoinSeller(_ context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) { f.lastTransferSalary = req if f.transferSalaryErr != nil { return nil, f.transferSalaryErr } if f.transferSalaryResp != nil { return f.transferSalaryResp, nil } return &walletv1.TransferSalaryToCoinSellerResponse{}, nil } func (f *fakeWalletClient) FreezeSalaryWithdrawal(_ context.Context, req *walletv1.FreezeSalaryWithdrawalRequest) (*walletv1.FreezeSalaryWithdrawalResponse, error) { f.lastFreezeSalary = req if f.freezeSalaryErr != nil { return nil, f.freezeSalaryErr } if f.freezeSalaryResp != nil { return f.freezeSalaryResp, nil } availableAfter, frozenAfter := int64(0), req.GetSalaryUsdMinor() if f.resp != nil { for _, balance := range f.resp.GetBalances() { if balance.GetAssetType() == req.GetSalaryAssetType() { availableAfter = balance.GetAvailableAmount() - req.GetSalaryUsdMinor() frozenAfter = balance.GetFrozenAmount() + req.GetSalaryUsdMinor() break } } } return &walletv1.FreezeSalaryWithdrawalResponse{ TransactionId: "freeze-tx-1", SalaryUsdMinor: req.GetSalaryUsdMinor(), Balance: &walletv1.AssetBalance{ AssetType: req.GetSalaryAssetType(), AvailableAmount: availableAfter, FrozenAmount: frozenAfter, }, }, nil } func (f *fakeWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error) { f.lastReleaseSalary = req if f.releaseSalaryErr != nil { return nil, f.releaseSalaryErr } if f.releaseSalaryResp != nil { return f.releaseSalaryResp, nil } return &walletv1.ReleaseSalaryWithdrawalResponse{ TransactionId: "release-tx-1", SalaryUsdMinor: req.GetSalaryUsdMinor(), Balance: &walletv1.AssetBalance{ AssetType: req.GetSalaryAssetType(), AvailableAmount: req.GetSalaryUsdMinor(), }, }, nil } func (f *fakeWalletClient) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) { f.lastListResources = req if f.err != nil { return nil, f.err } if f.listResourcesResp != nil { return f.listResourcesResp, nil } return &walletv1.ListResourcesResponse{}, nil } func (f *fakeWalletClient) GetResource(_ context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) { f.lastGetResource = req if f.err != nil { return nil, f.err } if f.resourcesByID != nil { return &walletv1.GetResourceResponse{Resource: f.resourcesByID[req.GetResourceId()]}, nil } return &walletv1.GetResourceResponse{Resource: &walletv1.Resource{ ResourceId: req.GetResourceId(), ResourceType: "avatar_frame", }}, nil } func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) { f.lastGetResourceGroup = req if f.err != nil { return nil, f.err } if f.resourceGroupsByID != nil { return &walletv1.GetResourceGroupResponse{Group: f.resourceGroupsByID[req.GetGroupId()]}, nil } return &walletv1.GetResourceGroupResponse{}, nil } func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) { f.lastResourceShop = req if f.err != nil { return nil, f.err } if f.resourceShopResp != nil { return f.resourceShopResp, nil } return &walletv1.ListResourceShopItemsResponse{}, nil } func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) { f.lastPurchaseShop = req if f.err != nil { return nil, f.err } if f.purchaseShopResp != nil { return f.purchaseShopResp, nil } return &walletv1.PurchaseResourceShopItemResponse{}, nil } func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) { f.lastListGiftConfigs = req f.listGiftConfigsCalls++ if f.err != nil { return nil, f.err } if f.listGiftConfigsResp != nil { return f.listGiftConfigsResp, nil } return &walletv1.ListGiftConfigsResponse{}, nil } func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) { f.lastListGiftTypes = req f.listGiftTypesCalls++ if f.err != nil { return nil, f.err } if f.listGiftTypesResp != nil { return f.listGiftTypesResp, nil } return &walletv1.ListGiftTypeConfigsResponse{}, nil } func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) { f.lastGrantResource = req if f.grantResourceErr != nil { return nil, f.grantResourceErr } if f.grantResourceResp != nil { return f.grantResourceResp, nil } return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-1", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), GrantSource: req.GetGrantSource(), GrantSubjectId: strconv.FormatInt(req.GetResourceId(), 10), Status: "succeeded", OperatorUserId: req.GetOperatorUserId()}}, nil } func (f *fakeWalletClient) ListUserResources(_ context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) { f.lastListUserResources = req f.listUserResourcesCalls++ if f.listUserResourcesResp != nil { return f.listUserResourcesResp, nil } return &walletv1.ListUserResourcesResponse{}, nil } func (f *fakeWalletClient) EquipUserResource(context.Context, *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) { return &walletv1.EquipUserResourceResponse{}, nil } func (f *fakeWalletClient) UnequipUserResource(context.Context, *walletv1.UnequipUserResourceRequest) (*walletv1.UnequipUserResourceResponse, error) { return &walletv1.UnequipUserResourceResponse{}, nil } func (f *fakeWalletClient) BatchGetUserEquippedResources(_ context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) { f.lastBatchEquipped = req f.batchEquippedRequests = append(f.batchEquippedRequests, req) if f.err != nil { return nil, f.err } if f.batchEquippedResp != nil { return f.batchEquippedResp, nil } return &walletv1.BatchGetUserEquippedResourcesResponse{}, nil } func (f *fakeWalletClient) GetRedPacketConfig(context.Context, *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error) { return &walletv1.GetRedPacketConfigResponse{}, nil } func (f *fakeWalletClient) CreateRedPacket(context.Context, *walletv1.CreateRedPacketRequest) (*walletv1.CreateRedPacketResponse, error) { return &walletv1.CreateRedPacketResponse{}, nil } func (f *fakeWalletClient) ClaimRedPacket(_ context.Context, req *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) { f.lastClaimRedPacket = req if f.err != nil { return nil, f.err } if f.claimRedPacketResp != nil { return f.claimRedPacketResp, nil } return &walletv1.ClaimRedPacketResponse{}, nil } func (f *fakeWalletClient) ListRedPackets(_ context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) { f.lastListRedPackets = req if f.listRedPacketsResp != nil { return f.listRedPacketsResp, nil } return &walletv1.ListRedPacketsResponse{}, nil } func (f *fakeWalletClient) GetRedPacket(_ context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) { f.lastGetRedPacket = req if f.err != nil { return nil, f.err } if f.getRedPacketResp != nil { return f.getRedPacketResp, nil } return &walletv1.GetRedPacketResponse{}, nil } func valueOfString(value *string) string { if value == nil { return "" } return *value } // fakeRoomGuardClient 只覆盖腾讯云 IM 回调测试需要的守卫 RPC。 type fakeRoomGuardClient struct { speakResp *roomv1.CheckSpeakPermissionResponse presenceResp *roomv1.VerifyRoomPresenceResponse lastSpeak *roomv1.CheckSpeakPermissionRequest lastPresence *roomv1.VerifyRoomPresenceRequest err error } func (f *fakeRoomGuardClient) CheckSpeakPermission(_ context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) { f.lastSpeak = req if f.err != nil { return nil, f.err } if f.speakResp != nil { return f.speakResp, nil } return &roomv1.CheckSpeakPermissionResponse{Allowed: true, RoomVersion: 1}, nil } func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) { f.lastPresence = req if f.err != nil { return nil, f.err } if f.presenceResp != nil { return f.presenceResp, nil } return &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}, nil } type fakeObjectUploader struct { lastKey string lastContent []byte lastSizeBytes int64 lastContentType string err error } func (f *fakeObjectUploader) PutObject(_ context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) { f.lastKey = key f.lastSizeBytes = sizeBytes f.lastContentType = contentType body, err := io.ReadAll(reader) if err != nil { return "", err } f.lastContent = body if f.err != nil { return "", f.err } return "https://media.example.com/" + key, nil } func TestRoutesWriteUnifiedEnvelopeAndGenerateRequestID(t *testing.T) { client := &fakeRoomClient{} profileClient := &fakeUserProfileClient{regionID: 1001} router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":10,"mode":"voice","room_name":" Lobby ","room_avatar":" https://cdn.example.com/room.png ","room_description":" welcome "}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-test") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } generatedRequestID := recorder.Header().Get("X-Request-ID") if generatedRequestID == "" || generatedRequestID == "req-test" { t.Fatalf("response request header mismatch: got %q", recorder.Header().Get("X-Request-ID")) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != httpkit.CodeOK || response.Message != "ok" || response.RequestID != generatedRequestID { t.Fatalf("unexpected envelope: %+v", response) } if client.lastCreate == nil || client.lastCreate.GetMeta().GetRequestId() != generatedRequestID { t.Fatalf("request_id was not propagated to room-service meta: %+v", client.lastCreate) } if client.lastCreate.GetMeta().GetActorUserId() != 42 { t.Fatalf("actor user mismatch: got %d", client.lastCreate.GetMeta().GetActorUserId()) } if roomID := client.lastCreate.GetMeta().GetRoomId(); !strings.HasPrefix(roomID, "lalu_") || roomID == "client-room-ignored" { t.Fatalf("CreateRoom must generate app-scoped room_id on gateway: %q", roomID) } if client.lastCreate.GetRoomShortId() != "100001" { t.Fatalf("CreateRoom must use owner display_user_id as room_short_id: %+v", client.lastCreate) } if client.lastCreate.GetMeta().GetCommandId() != "client-command-ignored" || client.lastCreate.GetMeta().GetCommandId() == generatedRequestID { t.Fatalf("CreateRoom must use client action command_id and keep it separate from request_id: %+v", client.lastCreate.GetMeta()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || client.lastCreate.GetVisibleRegionId() != 1001 { t.Fatalf("CreateRoom must derive visible_region_id from user-service: profile=%+v create=%+v", profileClient.lastGet, client.lastCreate) } if client.lastCreate.GetOwnerCountryCode() != "US" { t.Fatalf("CreateRoom must derive owner_country_code from user-service: %+v", client.lastCreate) } if client.lastCreate.GetRoomName() != "Lobby" || client.lastCreate.GetRoomAvatar() != "https://cdn.example.com/room.png" || client.lastCreate.GetRoomDescription() != "welcome" { t.Fatalf("CreateRoom should trim and forward room profile fields: %+v", client.lastCreate) } } func TestV5PayNotifyReturnsPlainSuccess(t *testing.T) { walletClient := &fakeWalletClient{} handler := NewHandlerWithClients(nil, nil, nil) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"orderNo":"order-1","productType":"INSTAPAY_QR","amount":"20.00","businessType":"0","fee":"0.00","orderStatus":"2","sign":"stub-sign"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/payment/v5pay/notify", bytes.NewReader(body)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("v5pay notify status mismatch: got %d body=%q", recorder.Code, recorder.Body.String()) } if strings.TrimSpace(recorder.Body.String()) != "success" { t.Fatalf("v5pay notify must return plain success, got %q", recorder.Body.String()) } if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") { t.Fatalf("v5pay notify must return text/plain, got %q", contentType) } if walletClient.lastV5PayNotify == nil || walletClient.lastV5PayNotify.GetFields()["orderNo"] != "order-1" || walletClient.lastV5PayNotify.GetFields()["amount"] != "20.00" { t.Fatalf("v5pay notify should forward string fields to wallet-service: %+v", walletClient.lastV5PayNotify) } } func TestRoomCommandsPropagateClientCommandID(t *testing.T) { tests := []struct { name string path string body string commandID string extractMeta func(*fakeRoomClient) *roomv1.RequestMeta }{ { name: "profile_update", path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1","command_id":"cmd-profile-1","room_name":"New Room","seat_count":20}`, commandID: "cmd-profile-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastUpdateProfile.GetMeta() }, }, { name: "join", path: "/api/v1/rooms/join", body: `{"room_id":"room-1","command_id":"cmd-join-1","role":"audience"}`, commandID: "cmd-join-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastJoin.GetMeta() }, }, { name: "leave", path: "/api/v1/rooms/leave", body: `{"room_id":"room-1","command_id":"cmd-leave-1"}`, commandID: "cmd-leave-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastLeave.GetMeta() }, }, { name: "heartbeat", path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1","command_id":"cmd-heartbeat-1"}`, commandID: "cmd-heartbeat-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastHeartbeat.GetMeta() }, }, { name: "mic_up", path: "/api/v1/rooms/mic/up", body: `{"room_id":"room-1","command_id":"cmd-mic-up-1","seat_no":1}`, commandID: "cmd-mic-up-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicUp.GetMeta() }, }, { name: "mic_down", path: "/api/v1/rooms/mic/down", body: `{"room_id":"room-1","command_id":"cmd-mic-down-1","target_user_id":43}`, commandID: "cmd-mic-down-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicDown.GetMeta() }, }, { name: "change_mic", path: "/api/v1/rooms/mic/change", body: `{"room_id":"room-1","command_id":"cmd-change-mic-1","target_user_id":43,"seat_no":2}`, commandID: "cmd-change-mic-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastChangeMicSeat.GetMeta() }, }, { name: "confirm_mic_publishing", path: "/api/v1/rooms/mic/publishing/confirm", body: `{"room_id":"room-1","command_id":"cmd-confirm-mic-1","mic_session_id":"mic-1","room_version":4,"event_time_ms":1700000001000,"source":"client"}`, commandID: "cmd-confirm-mic-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastConfirmMic.GetMeta() }, }, { name: "mic_heartbeat", path: "/api/v1/rooms/mic/heartbeat", body: `{"room_id":"room-1","command_id":"cmd-mic-heartbeat-1","mic_session_id":"mic-1"}`, commandID: "cmd-mic-heartbeat-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicHeartbeat.GetMeta() }, }, { name: "mic_lock", path: "/api/v1/rooms/mic/lock", body: `{"room_id":"room-1","command_id":"cmd-mic-lock-1","seat_no":2,"locked":true}`, commandID: "cmd-mic-lock-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMicSeatLock.GetMeta() }, }, { name: "chat_enabled", path: "/api/v1/rooms/chat/enabled", body: `{"room_id":"room-1","command_id":"cmd-chat-enabled-1","enabled":false}`, commandID: "cmd-chat-enabled-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastChatEnabled.GetMeta() }, }, { name: "room_password", path: "/api/v1/rooms/password", body: `{"room_id":"room-1","command_id":"cmd-room-password-1","locked":true,"password":"1234"}`, commandID: "cmd-room-password-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastRoomPassword.GetMeta() }, }, { name: "set_admin", path: "/api/v1/rooms/admin/set", body: `{"room_id":"room-1","command_id":"cmd-admin-1","target_user_id":43,"enabled":true}`, commandID: "cmd-admin-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastSetAdmin.GetMeta() }, }, { name: "mute", path: "/api/v1/rooms/user/mute", body: `{"room_id":"room-1","command_id":"cmd-mute-1","target_user_id":43,"muted":true}`, commandID: "cmd-mute-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastMute.GetMeta() }, }, { name: "kick", path: "/api/v1/rooms/user/kick", body: `{"room_id":"room-1","command_id":"cmd-kick-1","target_user_id":43,"duration_ms":600000}`, commandID: "cmd-kick-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastKick.GetMeta() }, }, { name: "unban", path: "/api/v1/rooms/user/unban", body: `{"room_id":"room-1","command_id":"cmd-unban-1","target_user_id":43}`, commandID: "cmd-unban-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastUnban.GetMeta() }, }, { name: "gift", path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","command_id":"cmd-gift-1","target_user_id":43,"gift_id":"rose","gift_count":1}`, commandID: "cmd-gift-1", extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta { return client.lastGift.GetMeta() }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } meta := test.extractMeta(client) if meta == nil { t.Fatal("room-service meta was not sent") } if meta.GetCommandId() != test.commandID { t.Fatalf("command_id mismatch: got %q meta=%+v", meta.GetCommandId(), meta) } generatedRequestID := recorder.Header().Get("X-Request-ID") if generatedRequestID == "" || generatedRequestID == "req-"+test.name || meta.GetRequestId() != generatedRequestID { t.Fatalf("request_id must remain trace-only and unchanged: %+v", meta) } if meta.GetActorUserId() != 42 { t.Fatalf("actor user mismatch: %+v", meta) } if test.name == "kick" && client.lastKick.GetDurationMs() != 600000 { t.Fatalf("kick duration_ms was not forwarded: %+v", client.lastKick) } }) } } func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) { roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000}, BillingReceiptId: "receipt-lucky", RoomHeat: 100, CoinBalanceAfter: 8200, LuckyGift: &roomv1.LuckyGiftDrawResult{ Enabled: true, DrawId: "lucky_draw_test", CommandId: "cmd-gift-lucky", PoolId: "super_lucky", GiftId: "rose", RuleVersion: 12, ExperiencePool: "novice", SelectedTierId: "batch", MultiplierPpm: 5_000_000, EffectiveRewardCoins: 500, RewardStatus: "granted", CoinBalanceAfter: 0, CreatedAtMs: 1_779_258_000_000, }, LuckyGifts: []*roomv1.LuckyGiftDrawResult{ {Enabled: true, DrawId: "lucky_draw_1", CommandId: "cmd-gift-lucky:target:43", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_1", TargetUserId: 43}, {Enabled: true, DrawId: "lucky_draw_2", CommandId: "cmd-gift-lucky:target:44", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 3_000_000, EffectiveRewardCoins: 300, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_2", TargetUserId: 44}, }, }} router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { LuckyGift struct { DrawID string `json:"draw_id"` PoolID string `json:"pool_id"` MultiplierPPM int64 `json:"multiplier_ppm"` EffectiveRewardCoins int64 `json:"effective_reward_coins"` RewardStatus string `json:"reward_status"` WalletTransactionID string `json:"wallet_transaction_id"` CoinBalanceAfter int64 `json:"coin_balance_after"` } `json:"lucky_gift"` LuckyGifts []struct { TargetUserID int64 `json:"target_user_id"` MultiplierPPM int64 `json:"multiplier_ppm"` EffectiveRewardCoins int64 `json:"effective_reward_coins"` } `json:"lucky_gifts"` CoinBalanceAfter int64 `json:"coin_balance_after"` } `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String()) } if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 0 { t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift) } if len(envelope.Data.LuckyGifts) != 2 || envelope.Data.LuckyGifts[0].TargetUserID != 43 || envelope.Data.LuckyGifts[0].MultiplierPPM != 2_000_000 || envelope.Data.LuckyGifts[1].TargetUserID != 44 || envelope.Data.LuckyGifts[1].MultiplierPPM != 3_000_000 { t.Fatalf("lucky_gifts response mismatch: %+v", envelope.Data.LuckyGifts) } if envelope.Data.CoinBalanceAfter != 8200 { t.Fatalf("send gift response must expose sender coin_balance_after: %+v", envelope.Data) } } func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) { roomClient := &fakeRoomClient{} hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}} profileClient := &fakeUserProfileClient{regionID: 1001} handler := NewHandlerWithClients(roomClient, nil, nil, profileClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host","target_user_id":43,"gift_id":"rose","gift_count":2}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 1 || hostClient.lastBatchHost.GetUserIds()[0] != 43 { t.Fatalf("gateway must batch query target host profile before sending gift: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls) } if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 { t.Fatalf("sender country/profile must come from one BatchGetUsers call: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests)) } if roomClient.lastGift == nil || !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 || roomClient.lastGift.GetTargetAgencyOwnerUserId() != 30001 { t.Fatalf("gateway must inject active host period scope: %+v", roomClient.lastGift) } } func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) { roomClient := &fakeRoomClient{} hostClient := &fakeUserHostClient{} handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-non-host","target_user_id":43,"gift_id":"rose","gift_count":2}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 { t.Fatalf("non-host lookup must still use batch host query: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls) } if roomClient.lastGift == nil || roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 0 { t.Fatalf("non-host target must not receive host period scope: %+v", roomClient.lastGift) } } func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) { roomClient := &fakeRoomClient{} router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-multi","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if roomClient.lastGift == nil || roomClient.lastGift.GetTargetUserId() != 43 || len(roomClient.lastGift.GetTargetUserIds()) != 2 || roomClient.lastGift.GetTargetUserIds()[0] != 43 || roomClient.lastGift.GetTargetUserIds()[1] != 44 { t.Fatalf("gateway must forward all target_user_ids: %+v", roomClient.lastGift) } } func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) { roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 15, ServerTimeMs: 1_779_258_000_000}, BillingReceiptId: "receipt-batch", RoomHeat: 500, CoinBalanceAfter: 7600, BatchDisplay: &roomv1.SendGiftBatchDisplay{ EventId: "evt-batch", SenderUserId: 42, GiftId: "rose", GiftCount: 2, TotalGiftValue: 400, TotalCoinSpent: 400, BillingReceiptId: "receipt-batch", TargetUserIds: []int64{43, 44}, CommandId: "cmd-gift-batch", GiftAnimationUrl: "https://cdn.example/rose.svga", GiftEffectTypes: []string{"room"}, RoomHeat: 500, Targets: []*roomv1.SendGiftBatchTarget{ {TargetUserId: 43, TargetGiftValue: 220, CommandId: "cmd-gift-batch:target:43"}, {TargetUserId: 44, TargetGiftValue: 180, CommandId: "cmd-gift-batch:target:44"}, }, }, }} router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2,"pool_id":"super_lucky"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if roomClient.lastGift == nil || roomClient.lastGift.GetDisplayMode() != "batch" || len(roomClient.lastGift.GetTargetUserIds()) != 2 || roomClient.lastGift.GetPoolId() != "super_lucky" { t.Fatalf("gateway must forward batch display mode and targets: %+v", roomClient.lastGift) } var envelope struct { Code string `json:"code"` Data struct { CoinBalanceAfter int64 `json:"coin_balance_after"` BatchDisplay struct { EventID string `json:"event_id"` TargetUserIDs []int64 `json:"target_user_ids"` Targets []struct { TargetUserID int64 `json:"target_user_id"` TargetGiftValue int64 `json:"target_gift_value"` CommandID string `json:"command_id"` } `json:"targets"` } `json:"batch_display"` } `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String()) } if envelope.Code != "OK" || envelope.Data.CoinBalanceAfter != 7600 || envelope.Data.BatchDisplay.EventID != "evt-batch" || len(envelope.Data.BatchDisplay.TargetUserIDs) != 2 || len(envelope.Data.BatchDisplay.Targets) != 2 { t.Fatalf("batch send response mismatch: %+v", envelope.Data) } } func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { roomClient := &fakeRoomClient{} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: { UserId: 42, DisplayUserId: "100042", PrettyDisplayUserId: "888042", Username: "Robot Sender", Avatar: "https://cdn.example/sender.png", CountryId: 840, RegionId: 1001, }, 43: { UserId: 43, DisplayUserId: "100043", PrettyDisplayUserId: "888043", Username: "Robot Receiver", Avatar: "https://cdn.example/receiver.png", CountryId: 840, RegionId: 1001, }, }} router := NewHandlerWithClients(roomClient, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-profile","target_user_id":43,"gift_id":"rose","gift_count":1}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } sender := roomClient.lastGift.GetSenderDisplayProfile() if sender.GetUserId() != 42 || sender.GetUsername() != "Robot Sender" || sender.GetAvatar() == "" || sender.GetDisplayUserId() != "100042" || sender.GetPrettyDisplayUserId() != "888042" { t.Fatalf("gateway must inject sender display profile snapshot: %+v", sender) } targets := roomClient.lastGift.GetTargetDisplayProfiles() if len(targets) != 1 || targets[0].GetUserId() != 43 || targets[0].GetUsername() != "Robot Receiver" || targets[0].GetAvatar() == "" || targets[0].GetDisplayUserId() != "100043" { t.Fatalf("gateway must inject target display profile snapshots: %+v", targets) } if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 || len(profileClient.lastBatch.GetUserIds()) != 2 || profileClient.lastBatch.GetUserIds()[0] != 42 || profileClient.lastBatch.GetUserIds()[1] != 43 { t.Fatalf("gift user snapshots must use one batch profile request: get=%+v batch=%+v calls=%d", profileClient.lastGet, profileClient.lastBatch, len(profileClient.batchRequests)) } } func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) { roomClient := &fakeRoomClient{} hostClient := &fakeUserHostClient{hostProfiles: map[int64]*userv1.HostProfile{ 43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}, 44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002}, }} handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-multi-host","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 2 || hostClient.lastBatchHost.GetUserIds()[0] != 43 || hostClient.lastBatchHost.GetUserIds()[1] != 44 { t.Fatalf("gateway must resolve multi-target host profiles in one batch: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls) } scopes := roomClient.lastGift.GetTargetHostScopes() if len(scopes) != 2 || !scopes[0].GetTargetIsHost() || scopes[0].GetTargetHostRegionId() != 8801 || !scopes[1].GetTargetIsHost() || scopes[1].GetTargetHostRegionId() != 8802 { t.Fatalf("gateway must inject each target host scope: %+v", scopes) } if !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 { t.Fatalf("legacy first-target host scope must remain populated: %+v", roomClient.lastGift) } } func TestSendGiftFallsBackToSingleHostLookupWhenBatchRPCIsUnimplemented(t *testing.T) { roomClient := &fakeRoomClient{} hostClient := &fakeUserHostClient{ batchHostErr: status.Error(codes.Unimplemented, "unknown method BatchGetHostProfiles"), hostProfiles: map[int64]*userv1.HostProfile{ 43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}, 44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002}, }, } handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host-fallback","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 2 { t.Fatalf("unimplemented batch RPC should fall back to one single lookup per target: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls) } scopes := roomClient.lastGift.GetTargetHostScopes() if len(scopes) != 2 || scopes[0].GetTargetHostRegionId() != 8801 || scopes[1].GetTargetHostRegionId() != 8802 { t.Fatalf("fallback must preserve each target host scope: %+v", scopes) } } func TestSendGiftPropagatesBatchHostProfileErrors(t *testing.T) { roomClient := &fakeRoomClient{} hostClient := &fakeUserHostClient{batchHostErr: xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host profile lookup failed"))} handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host-error","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code == http.StatusOK || roomClient.lastGift != nil { t.Fatalf("batch host lookup errors must block SendGift: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String()) } if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 { t.Fatalf("non-unimplemented batch errors must not fall back to single lookup: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls) } } func TestSendGiftRequiresSenderCountryRegionFromBatchUserProfile(t *testing.T) { roomClient := &fakeRoomClient{} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, RegionId: 1001}, 43: {UserId: 43, Status: userv1.UserStatus_USER_STATUS_ACTIVE, CountryId: 840, RegionId: 1001}, }} router := NewHandlerWithClients(roomClient, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-sender-country","target_user_id":43,"gift_id":"rose","gift_count":1}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest || roomClient.lastGift != nil { t.Fatalf("sender missing country must block SendGift before room RPC: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String()) } if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 { t.Fatalf("sender country validation must use batch profile path: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests)) } } func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) { previousNow := roomapi.TimeNow roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } defer func() { roomapi.TimeNow = previousNow }() roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7, ServerTimeMs: 1_700_000_000_123}, User: &roomv1.RoomUser{UserId: 42, Role: "audience", JoinedAtMs: 1_700_000_000_000, LastSeenAtMs: 1_700_000_000_100}, Room: &roomv1.RoomSnapshot{ RoomId: "room_1001", OwnerUserId: 101, Mode: "voice", Status: "active", ChatEnabled: true, Locked: true, MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing", MicSessionId: "mic-102"}, {SeatNo: 2, Locked: true}}, OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}, {UserId: 102, Role: "admin"}}, GiftRank: []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}}, RoomExt: map[string]string{"title": "Live Room", "cover_url": "https://cdn.example/cover.png", "description": "welcome"}, Heat: 88, Version: 7, RoomShortId: "100101", }, }} profileCardResource := &walletv1.Resource{ ResourceId: 8104, ResourceCode: "profile_card_gold", ResourceType: "profile_card", Name: "Gold Profile Card", AssetUrl: "https://cdn.example/profile-card.png", AnimationUrl: "https://cdn.example/profile-card.mp4", MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"content_top":143,"content_bottom":1667,"content_height":1525}}`, GrantStrategy: "extend_expiry", Status: "active", } micSeatResource := &walletv1.Resource{ ResourceId: 8106, ResourceCode: "mic_seat_wave_gold", ResourceType: "mic_seat_animation", Name: "Gold Mic Wave", AssetUrl: "https://cdn.example/mic-seat.png", AnimationUrl: "https://cdn.example/mic-seat.svga", MetadataJson: `{"usage":"mic_seat"}`, GrantStrategy: "set_active_flag", Status: "active", } shortBadgeResource := &walletv1.Resource{ ResourceId: 8105, ResourceCode: "badge_tile_gold", ResourceType: "badge", Name: "Gold Tile Badge", PreviewUrl: "https://cdn.example/badge-tile.png", MetadataJson: `{"badge_form":"tile","badge_kind":"normal"}`, GrantStrategy: "set_active_flag", Status: "active", } walletClient := &fakeWalletClient{ batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{ Users: []*walletv1.UserEquippedResources{{ UserId: 42, Resources: []*walletv1.UserResourceEntitlement{ { UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999, }, { UserId: 42, EntitlementId: "ent-badge-tile", ResourceId: 8105, Resource: shortBadgeResource, Equipped: true, ExpiresAtMs: 1999999999999, }, { UserId: 42, EntitlementId: "ent-mic-seat-wave", ResourceId: 8106, Resource: micSeatResource, Equipped: true, ExpiresAtMs: 1999999999999, }, }, }}, }, } profileClient := &fakeUserProfileClient{} handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient) handler.SetWalletClient(walletClient) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: 2 * time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_1001","command_id":"cmd-join","role":"audience","password":"1234"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-join-initial") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode join response failed: %v", err) } data := response.Data.(map[string]any) room := data["room"].(map[string]any) if room["room_id"] != "room_1001" || room["im_group_id"] != "room_1001" || room["title"] != "Live Room" || room["online_count"].(float64) != 2 || room["locked"] != true { t.Fatalf("unexpected initial room data: %+v", room) } if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" { t.Fatalf("join must forward room password to room-service: %+v", roomClient.lastJoin) } joinProfile := roomClient.lastJoin.GetActorDisplayProfile() if joinProfile.GetUserId() != 42 || joinProfile.GetNickname() != "hy" || joinProfile.GetAvatar() != "https://cdn.example/avatar.png" || joinProfile.GetDisplayUserId() != "100001" { t.Fatalf("join must inject actor display profile snapshot: %+v", joinProfile) } seats := data["seats"].([]any) if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" { t.Fatalf("join response must include first-screen seats: %+v", seats) } rank := data["contribution_rank"].([]any) if len(rank) != 1 || rank[0].(map[string]any)["user_id"] != "301" { t.Fatalf("join response must include first-screen contribution rank: %+v", rank) } im := data["im"].(map[string]any) if im["group_id"] != "room_1001" || im["need_join_group"] != true { t.Fatalf("unexpected im block: %+v", im) } rtc := data["rtc"].(map[string]any) token := rtc["token"].(map[string]any) if rtc["available"] != true || token["str_room_id"] != "room_1001" || token["rtc_user_id"] != "42" { t.Fatalf("join response must embed usable rtc token: rtc=%+v token=%+v", rtc, token) } if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,42,102,301" { t.Fatalf("join response must batch only first-screen profile users: %+v", profileClient.lastBatch) } hasRoomDisplayResourceQuery := false for _, req := range walletClient.batchEquippedRequests { if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,badge,mic_seat_animation" { hasRoomDisplayResourceQuery = true break } } if !hasRoomDisplayResourceQuery { t.Fatalf("join response must batch room wearable resources including mic_seat_animation: %+v", walletClient.batchEquippedRequests) } profiles, profilesOK := data["profiles"].([]any) if !profilesOK || len(profiles) == 0 { t.Fatalf("join response must include room display profiles: %+v", data["profiles"]) } var currentProfile map[string]any for _, item := range profiles { profile, ok := item.(map[string]any) if !ok || profile["user_id"] != "42" { continue } currentProfile = profile break } profileCard, profileCardOK := currentProfile["profile_card"].(map[string]any) metadataJSON, _ := profileCard["metadata_json"].(string) if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) { t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile) } micSeatAnimation, micSeatAnimationOK := currentProfile["mic_seat_animation"].(map[string]any) if !micSeatAnimationOK || micSeatAnimation["resource_id"] != float64(8106) || micSeatAnimation["animation_url"] != "https://cdn.example/mic-seat.svga" { t.Fatalf("join profile must include mic_seat_animation resource: %+v", currentProfile) } badges, badgesOK := currentProfile["badges"].([]any) if !badgesOK || len(badges) != 1 { t.Fatalf("join profile must include equipped badge list: %+v", currentProfile["badges"]) } badge := badges[0].(map[string]any) badgeResource := badge["resource"].(map[string]any) if badge["badge_form"] != "tile" || badgeResource["preview_url"] != "https://cdn.example/badge-tile.png" { t.Fatalf("join profile must include equipped tile badge resource: %+v", badge) } } func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing.T) { roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{ Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8, ServerTimeMs: 1_700_000_010_123}, User: &roomv1.RoomUser{UserId: 42, Role: "audience", JoinedAtMs: 1_700_000_010_000, LastSeenAtMs: 1_700_000_010_100}, Room: &roomv1.RoomSnapshot{ RoomId: "room_lite", OwnerUserId: 101, Mode: "voice", Status: "active", ChatEnabled: true, MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing"}, {SeatNo: 2, Locked: true}}, GiftRank: []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}}, RoomExt: map[string]string{"title": "Lite Room", "cover_url": "https://cdn.example/lite.png"}, Heat: 88, Version: 8, RoomShortId: "100102", OnlineCount: 200, Locked: true, }, }} walletClient := &fakeWalletClient{batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{}} profileClient := &fakeUserProfileClient{} handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient) handler.SetWalletClient(walletClient) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: 2 * time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_lite","command_id":"cmd-join-lite","role":"audience","response_mode":"lite"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-join-lite") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || roomClient.lastJoin.GetEntryVehicle() != nil { t.Fatalf("lite join must pass response_mode and skip entry vehicle: %+v", roomClient.lastJoin) } if len(walletClient.batchEquippedRequests) != 0 { t.Fatalf("lite join must not query wallet appearance or vehicle resources: %+v", walletClient.batchEquippedRequests) } if len(profileClient.batchRequests) != 0 { t.Fatalf("lite join must not call full BatchGetUsers: %+v", profileClient.batchRequests) } if profileClient.lastRoomBasicBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastRoomBasicBatch.GetUserIds()), ",") != "101,42,102" { t.Fatalf("lite join must batch only owner/viewer/mic basic users: %+v", profileClient.lastRoomBasicBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode lite join response failed: %v", err) } data := response.Data.(map[string]any) if _, exists := data["contribution_rank"]; exists { t.Fatalf("lite join must not include contribution rank: %+v", data["contribution_rank"]) } room := data["room"].(map[string]any) if room["room_id"] != "room_lite" || room["online_count"].(float64) != 200 { t.Fatalf("lite join must keep core room fields and online_count: %+v", room) } profiles := data["profiles"].([]any) if len(profiles) != 3 { t.Fatalf("lite join must include basic profiles for owner/viewer/mic only: %+v", profiles) } viewerProfile := map[string]any{} for _, item := range profiles { profile := item.(map[string]any) if profile["user_id"] == "42" { viewerProfile = profile } if profile["user_id"] == "301" { t.Fatalf("lite join must not hydrate contribution-rank users: %+v", profiles) } } if viewerProfile["username"] != "user-42" || viewerProfile["avatar"] == "" || viewerProfile["display_user_id"] == "" { t.Fatalf("lite profile must carry only basic display fields: %+v", viewerProfile) } if _, exists := viewerProfile["avatar_frame"]; exists { t.Fatalf("lite profile must not carry appearance fields: %+v", viewerProfile) } } func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{ RegionId: 1001, Countries: []string{"AE", "US", "TR"}, }}} countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{ Countries: []*userv1.Country{ {CountryId: 1, CountryCode: "AE", CountryName: "United Arab Emirates", CountryDisplayName: "UAE", Flag: "🇦🇪"}, {CountryId: 2, CountryCode: "US", CountryName: "United States", CountryDisplayName: "US", Flag: "🇺🇸"}, {CountryId: 3, CountryCode: "TR", CountryName: "Türkiye", CountryDisplayName: "TR", Flag: "🇹🇷"}, }, }} queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{ Rooms: []*roomv1.RoomListItem{ {RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US"}, }, NextCursor: "cursor-2", }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetRoomQueryClient(queryClient) handler.SetUserRegionClient(regionClient) handler.SetUserCountryQueryClient(countryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&cursor=cursor-1&q=room&country=US&visible_region_id=9999", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-list") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("ListRooms must fetch current user from user-service: %+v", profileClient.lastGet) } if queryClient.lastList == nil { t.Fatal("room query client was not called") } if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 || queryClient.lastList.GetViewerCountryCode() != "US" { t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList) } if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" || queryClient.lastList.GetCountryCode() != "US" { t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList) } if regionClient.last == nil || regionClient.last.GetRegionId() != 1001 || countryClient.last == nil { t.Fatalf("ListRooms must fetch region countries and country metadata: region=%+v countries=%+v", regionClient.last, countryClient.last) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode room list response failed: %v", err) } data := response.Data.(map[string]any) countries := data["countries"].([]any) if len(countries) != 3 { t.Fatalf("room list must expose current region countries: %+v", countries) } for index, want := range []string{"US", "AE", "TR"} { country := countries[index].(map[string]any) if country["country_code"] != want { t.Fatalf("country order mismatch at %d: want %s got %+v", index, want, countries) } } rooms := data["rooms"].([]any) first := rooms[0].(map[string]any) if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" { t.Fatalf("room list must expose room IM group id: %+v", first) } } func TestGetRoomRocketEmbedsRewardResourceGroups(t *testing.T) { queryClient := &fakeRoomQueryClient{rocketResp: &roomv1.GetRoomRocketResponse{ Rocket: &roomv1.RoomRocketInfo{ Enabled: true, LaunchDelayMs: 1200, Levels: []*roomv1.RoomRocketLevel{{ Level: 1, FuelThreshold: 100, InRoomRewards: []*roomv1.RoomRocketRewardItem{{ RewardItemId: "in-room-coin", ResourceGroupId: 23, Weight: 100, DisplayName: "1888 Coins", }}, }}, State: &roomv1.RoomRocketState{ CurrentLevel: 1, CurrentFuel: 0, FuelThreshold: 100, Status: "charging", LastRewards: []*roomv1.RoomRocketRewardGrant{{ RewardRole: "igniter", UserId: 42, RewardItemId: "chat-bubble", ResourceGroupId: 24, DisplayName: "Blue Bubble", Status: "succeeded", }}, }, }, ServerTimeMs: 1_780_000_000_000, }} walletClient := &fakeWalletClient{resourceGroupsByID: map[int64]*walletv1.ResourceGroup{ 23: { GroupId: 23, GroupCode: "coin_1888", Name: "Coin Reward", Status: "active", Items: []*walletv1.ResourceGroupItem{{ GroupItemId: 2301, ItemType: "wallet_asset", WalletAssetType: "COIN", WalletAssetAmount: 1888, Quantity: 1, }}, }, 24: { GroupId: 24, GroupCode: "bubble_7d", Name: "Chat Bubble", Status: "active", Items: []*walletv1.ResourceGroupItem{{ GroupItemId: 2401, ItemType: "resource", ResourceId: 8101, Resource: &walletv1.Resource{ ResourceId: 8101, ResourceCode: "bubble_blue", ResourceType: "chat_bubble", Name: "Blue Bubble", Status: "active", AssetUrl: "https://cdn.example/bubble.png", }, Quantity: 1, DurationMs: 604800000, }}, }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room_1001/rocket", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-rocket") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastRocket == nil || queryClient.lastRocket.GetViewerUserId() != 42 || queryClient.lastRocket.GetRoomId() != "room_1001" { t.Fatalf("GetRoomRocket must forward authenticated viewer and room: %+v", queryClient.lastRocket) } var envelope httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode rocket response failed: %v", err) } data := envelope.Data.(map[string]any) level := data["levels"].([]any)[0].(map[string]any) inRoomReward := level["in_room_rewards"].([]any)[0].(map[string]any) inRoomGroup := inRoomReward["resource_group"].(map[string]any) if inRoomReward["resource_group_id"].(float64) != 23 || inRoomGroup["group_id"].(float64) != 23 { t.Fatalf("rocket reward must embed resource group id 23: %+v", inRoomReward) } coinItem := inRoomGroup["items"].([]any)[0].(map[string]any) if coinItem["item_type"] != "wallet_asset" || coinItem["wallet_asset_type"] != "COIN" || coinItem["wallet_asset_amount"].(float64) != 1888 { t.Fatalf("rocket reward group must expose wallet asset amount: %+v", coinItem) } state := data["state"].(map[string]any) lastReward := state["last_rewards"].([]any)[0].(map[string]any) lastGroup := lastReward["resource_group"].(map[string]any) resourceItem := lastGroup["items"].([]any)[0].(map[string]any) resource := resourceItem["resource"].(map[string]any) if lastReward["resource_group_id"].(float64) != 24 || resource["resource_type"] != "chat_bubble" || resource["asset_url"] != "https://cdn.example/bubble.png" || resourceItem["duration_ms"].(float64) != 604800000 { t.Fatalf("rocket last reward must expose resource material and duration: reward=%+v item=%+v", lastReward, resourceItem) } } func TestListRoomsRejectsCountryOutsideUserRegion(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{ RegionId: 1001, Countries: []string{"US", "AE"}, }}} countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{ Countries: []*userv1.Country{ {CountryId: 1, CountryCode: "US", Flag: "🇺🇸"}, {CountryId: 2, CountryCode: "AE", Flag: "🇦🇪"}, {CountryId: 3, CountryCode: "CN", Flag: "🇨🇳"}, }, }} queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetRoomQueryClient(queryClient) handler.SetUserRegionClient(regionClient) handler.SetUserCountryQueryClient(countryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&country=CN", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastList != nil { t.Fatalf("invalid country must not reach room-service: %+v", queryClient.lastList) } } func TestSetRoomPasswordForwardsPasswordAndReturnsLockedState(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/password", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-password","locked":true,"password":" 1234 "}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-password") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if client.lastRoomPassword == nil || !client.lastRoomPassword.GetLocked() || client.lastRoomPassword.GetPassword() != "1234" { t.Fatalf("room password request was not normalized and forwarded: %+v", client.lastRoomPassword) } if strings.Contains(recorder.Body.String(), "password_hash") || strings.Contains(recorder.Body.String(), "room_password_hash") { t.Fatalf("set password response must not expose password hash: %s", recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode room password response failed: %v", err) } data := response.Data.(map[string]any) room := data["room"].(map[string]any) if room["locked"] != true { t.Fatalf("set password response must expose locked state: %+v", room) } } func TestListRoomsRejectsMineFeedTabs(t *testing.T) { for _, tab := range []string{"visited", "friend", "following", "followed", "me"} { t.Run(tab, func(t *testing.T) { queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab="+tab+"&limit=3", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastList != nil || queryClient.lastFeeds != nil { t.Fatalf("invalid public list tab must not reach room-service: list=%+v feeds=%+v", queryClient.lastList, queryClient.lastFeeds) } }) } } func TestListRoomFeedsSupportsMineFeedTabs(t *testing.T) { for _, tab := range []string{"visited", "friend", "following", "followed"} { t.Run(tab, func(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} socialClient := &fakeUserSocialClient{} queryClient := &fakeRoomQueryClient{feedsResp: &roomv1.ListRoomsResponse{ Rooms: []*roomv1.RoomListItem{{RoomId: "room-feed", VisibleRegionId: 1001, Heat: 7}}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetRoomQueryClient(queryClient) if tab == "friend" || tab == "following" { handler.SetUserSocialClient(socialClient) } router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/feeds?tab="+tab+"&limit=3", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-feed-"+tab) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastFeeds == nil || queryClient.lastFeeds.GetTab() != tab || queryClient.lastFeeds.GetViewerUserId() != 42 || queryClient.lastFeeds.GetVisibleRegionId() != 1001 { t.Fatalf("feed tab was not forwarded with authenticated user and region: %+v", queryClient.lastFeeds) } switch tab { case "visited", "followed": if len(queryClient.lastFeeds.GetRelatedUsers()) != 0 || socialClient.lastFollowing != nil || socialClient.lastFriends != nil { t.Fatalf("%s feed must not load social relations: feeds=%+v social=%+v/%+v", tab, queryClient.lastFeeds, socialClient.lastFollowing, socialClient.lastFriends) } case "following": if socialClient.lastFollowing == nil || socialClient.lastFollowing.GetUserId() != 42 { t.Fatalf("following feed must load following relation facts: %+v", socialClient.lastFollowing) } if got := queryClient.lastFeeds.GetRelatedUsers(); len(got) != 1 || got[0].GetUserId() != 10002 || got[0].GetRelationUpdatedAtMs() != 8000 { t.Fatalf("following related users mismatch: %+v", got) } case "friend": if socialClient.lastFriends == nil || socialClient.lastFriends.GetUserId() != 42 { t.Fatalf("friend feed must load friend relation facts: %+v", socialClient.lastFriends) } if got := queryClient.lastFeeds.GetRelatedUsers(); len(got) != 1 || got[0].GetUserId() != 10002 || got[0].GetRelationUpdatedAtMs() != 10000 { t.Fatalf("friend related users mismatch: %+v", got) } } }) } } func TestListRoomFeedsRelationCursorContinuesUserSocialScan(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} socialClient := &fakeUserSocialClient{} queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetRoomQueryClient(queryClient) handler.SetUserSocialClient(socialClient) router := handler.Routes(auth.NewVerifier("secret")) cursorPayload, _ := json.Marshal(map[string]any{ "tab": "following", "query": "music", "updated_at_ms": int64(8000), "subject_user_id": int64(10002), "room_id": "room-feed", }) cursor := base64.RawURLEncoding.EncodeToString(cursorPayload) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/feeds?tab=following&q=music&cursor="+cursor, nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if socialClient.lastFollowing == nil || socialClient.lastFollowing.GetCursorUpdatedAtMs() != 8000 || socialClient.lastFollowing.GetCursorUserId() != 10002 { t.Fatalf("following cursor was not propagated to user-service: %+v", socialClient.lastFollowing) } } func TestRoomFollowActionsUseAuthenticatedUser(t *testing.T) { for _, test := range []struct { name string method string }{ {name: "follow", method: http.MethodPost}, {name: "unfollow", method: http.MethodDelete}, } { t.Run(test.name, func(t *testing.T) { roomClient := &fakeRoomClient{} router := NewHandler(roomClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(test.method, "/api/v1/rooms/room-follow/follow", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode room follow response failed: %v", err) } data := response.Data.(map[string]any) switch test.method { case http.MethodPost: if roomClient.lastFollowRoom == nil || roomClient.lastFollowRoom.GetUserId() != 42 || roomClient.lastFollowRoom.GetRoomId() != "room-follow" { t.Fatalf("follow room request must use authenticated user and path room: %+v", roomClient.lastFollowRoom) } if data["is_followed"] != true || data["room_id"] != "room-follow" { t.Fatalf("follow response mismatch: %+v", data) } case http.MethodDelete: if roomClient.lastUnfollowRoom == nil || roomClient.lastUnfollowRoom.GetUserId() != 42 || roomClient.lastUnfollowRoom.GetRoomId() != "room-follow" { t.Fatalf("unfollow room request must use authenticated user and path room: %+v", roomClient.lastUnfollowRoom) } if data["is_followed"] != false || data["room_id"] != "room-follow" { t.Fatalf("unfollow response mismatch: %+v", data) } } }) } } func TestGetMyRoomUsesAuthenticatedOwnerAndReturnsIMGroup(t *testing.T) { queryClient := &fakeRoomQueryClient{myRoomResp: &roomv1.GetMyRoomResponse{ HasRoom: true, Room: &roomv1.RoomListItem{ RoomId: "room-owner", OwnerUserId: 42, Title: "Owner Room", Status: "active", SeatCount: 8, }, ServerTimeMs: 1_700_000_000_123, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/me", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-my-room") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastMyRoom == nil || queryClient.lastMyRoom.GetOwnerUserId() != 42 { t.Fatalf("my room must use authenticated owner: %+v", queryClient.lastMyRoom) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode my room response failed: %v", err) } data := response.Data.(map[string]any) if data["has_room"] != true { t.Fatalf("my room must preserve has_room=true: %+v", data) } room := data["room"].(map[string]any) if room["room_id"] != "room-owner" || room["im_group_id"] != "room-owner" { t.Fatalf("my room must expose room id and im group id: %+v", room) } } func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) { queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?limit=bad", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-list-limit") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-list-limit") if queryClient.lastList != nil { t.Fatalf("invalid limit must not reach room-service: %+v", queryClient.lastList) } } func TestBindPushTokenUsesAuthenticatedUserAndDeviceMeta(t *testing.T) { deviceClient := &fakeUserDeviceClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserDeviceClient(deviceClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"device_id":"dev-1","push_token":"push-1","provider":"fcm","platform":"android","app_version":"1.2.3","language":"en-US","timezone":"America/Los_Angeles"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/devices/push-token", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-bind-push") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if deviceClient.lastBind == nil || deviceClient.lastBind.GetUserId() != 42 || deviceClient.lastBind.GetMeta().GetDeviceId() != "dev-1" { t.Fatalf("push token bind must use authenticated user and device meta: %+v", deviceClient.lastBind) } if deviceClient.lastBind.GetPushToken() != "push-1" || deviceClient.lastBind.GetPlatform() != "android" || deviceClient.lastBind.GetProvider() != "fcm" { t.Fatalf("push token bind fields mismatch: %+v", deviceClient.lastBind) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode bind response failed: %v", err) } data, ok := response.Data.(map[string]any) if !ok || data["bound"] != true || data["updated_at_ms"].(float64) != 1234 { t.Fatalf("push token response must keep explicit fields: %+v", response.Data) } } func TestAppHeartbeatUsesAuthenticatedSession(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, userClient, nil, &fakeUserProfileClient{}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/app/heartbeat", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Device-ID", "dev-heartbeat") request.Header.Set("X-Request-ID", "req-app-heartbeat") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if userClient.lastAppHeartbeat == nil || userClient.lastAppHeartbeat.GetUserId() != 42 || userClient.lastAppHeartbeat.GetSessionId() != "sess-test" { t.Fatalf("app heartbeat must use authenticated user and session: %+v", userClient.lastAppHeartbeat) } if userClient.lastAppHeartbeat.GetMeta().GetDeviceId() != "dev-heartbeat" { t.Fatalf("app heartbeat must pass device meta: %+v", userClient.lastAppHeartbeat.GetMeta()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode app heartbeat response failed: %v", err) } data, ok := response.Data.(map[string]any) if !ok || data["accepted"] != true || data["heartbeat_at_ms"].(float64) != 1_778_000_005_000 { t.Fatalf("app heartbeat response mismatch: %+v", response.Data) } if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || data["access_token"] != "access-heartbeat" { t.Fatalf("app heartbeat must expose renewed access token: header=%q data=%+v", recorder.Header().Get("X-Hyapp-Access-Token"), data) } } func TestReportAppEventsUsesAuthenticatedUserDimensions(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 210} statisticsClient := &fakeStatisticsClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetStatisticsClient(statisticsClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"events":[{"event_id":"evt-login-1","event_name":"banner_click","event_type":"click","screen":"home","target_type":"banner","target_id":"17","device_id":"dev-body","user_id":999,"country_id":1,"region_id":2,"platform":"ios","app_version":"1.2.3","language":"en-US","timezone":"Asia/Shanghai","duration_ms":12,"success":true,"properties":{"slot":"home"}}]}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami")) request.Header.Set("X-Device-ID", "dev-header") request.Header.Set("X-Request-ID", "req-app-events") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if len(statisticsClient.lastAppEvents) != 1 { t.Fatalf("statistics app events not called: %+v", statisticsClient.lastAppEvents) } event := statisticsClient.lastAppEvents[0] if event.AppCode != "fami" || event.UserID != 42 || event.CountryID != 840 || event.RegionID != 210 { t.Fatalf("authenticated event must use server dimensions: %+v", event) } if event.DeviceID != "dev-body" || event.EventName != "banner_click" || event.Platform != "ios" || string(event.Properties) != `{"slot":"home"}` { t.Fatalf("authenticated event fields mismatch: %+v properties=%s", event, string(event.Properties)) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode app events response failed: %v", err) } data := response.Data.(map[string]any) if data["accepted"] != true || data["received"].(float64) != 1 || data["stored"].(float64) != 1 || data["duplicated"].(float64) != 0 { t.Fatalf("app events response mismatch: %+v", data) } } func TestReportAppEventsAllowsAnonymousWithDeviceID(t *testing.T) { statisticsClient := &fakeStatisticsClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetStatisticsClient(statisticsClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"events":[{"event_id":"evt-anon-1","event_name":"splash_view"}]}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body)) request.Header.Set("X-Device-ID", "dev-anon") request.Header.Set("X-App-Platform", "android") request.Header.Set("X-App-Version", "2.0.1") request.Header.Set("Accept-Language", "ar-EG,ar;q=0.9") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if len(statisticsClient.lastAppEvents) != 1 { t.Fatalf("statistics app events not called: %+v", statisticsClient.lastAppEvents) } event := statisticsClient.lastAppEvents[0] if event.UserID != 0 || event.DeviceID != "dev-anon" || event.Platform != "android" || event.AppVersion != "2.0.1" || event.Language != "ar-EG" { t.Fatalf("anonymous event must use device/header fields: %+v", event) } } func TestReportAppEventsRejectsInvalidRequests(t *testing.T) { for _, test := range []struct { name string body []byte }{ {name: "missing device anonymous", body: []byte(`{"events":[{"event_id":"evt-1","event_name":"open"}]}`)}, {name: "missing event id", body: []byte(`{"events":[{"event_name":"open","device_id":"dev-1"}]}`)}, {name: "missing event name", body: []byte(`{"events":[{"event_id":"evt-1","device_id":"dev-1"}]}`)}, {name: "too many events", body: mustAppEventsBody(t, 51)}, } { t.Run(test.name, func(t *testing.T) { statisticsClient := &fakeStatisticsClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetStatisticsClient(statisticsClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(test.body)) request.Header.Set("X-Request-ID", "req-invalid-app-events") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-invalid-app-events") if len(statisticsClient.lastAppEvents) != 0 { t.Fatalf("invalid app events must not reach statistics-service: %+v", statisticsClient.lastAppEvents) } }) } } func TestReportAppEventsUpstreamFailure(t *testing.T) { statisticsClient := &fakeStatisticsClient{appErr: errors.New("statistics unavailable")} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetStatisticsClient(statisticsClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"events":[{"event_id":"evt-upstream","event_name":"open","device_id":"dev-1"}]}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-app-events-upstream") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-app-events-upstream") } func TestDeletePushTokenUsesAuthenticatedUser(t *testing.T) { deviceClient := &fakeUserDeviceClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserDeviceClient(deviceClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"device_id":"dev-1","push_token":"push-1"}`) request := httptest.NewRequest(http.MethodDelete, "/api/v1/devices/push-token", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-delete-push") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if deviceClient.lastDelete == nil || deviceClient.lastDelete.GetUserId() != 42 || deviceClient.lastDelete.GetDeviceId() != "dev-1" || deviceClient.lastDelete.GetPushToken() != "push-1" { t.Fatalf("push token delete fields mismatch: %+v", deviceClient.lastDelete) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode delete response failed: %v", err) } data, ok := response.Data.(map[string]any) if !ok || data["deleted"] != true || data["updated_at_ms"].(float64) != 1235 { t.Fatalf("push token delete response must keep explicit fields: %+v", response.Data) } } func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) { queryClient := &fakeRoomQueryClient{currentResp: &roomv1.GetCurrentRoomResponse{ HasCurrentRoom: true, RoomId: "room-current", RoomVersion: 9, Role: "audience", NeedJoinImGroup: true, ServerTimeMs: 123456, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/current?user_id=999", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-current-room") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastCurrent == nil { t.Fatal("current room query was not sent") } if queryClient.lastCurrent.GetUserId() != 42 || queryClient.lastCurrent.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-current-room") { t.Fatalf("current room must use authenticated user and request meta: %+v", queryClient.lastCurrent) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode current room response failed: %v", err) } data, ok := response.Data.(map[string]any) if !ok || data["has_current_room"] != true || data["need_rtc_token"] != false || data["room_id"] != "room-current" { t.Fatalf("current room response must keep explicit booleans: %+v", response.Data) } } func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) { queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{ Room: &roomv1.RoomSnapshot{ RoomId: "room-snapshot", OwnerUserId: 42, Status: "active", Version: 7, MicSeats: []*roomv1.SeatState{ {SeatNo: 1, UserId: 42}, {SeatNo: 2}, }, RoomExt: map[string]string{"title": "Snapshot Room"}, }, ServerTimeMs: 123456, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/snapshot?room_id=room-snapshot&viewer_user_id=999", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-snapshot") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastSnapshot == nil { t.Fatal("room snapshot query was not sent") } if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-room-snapshot") { t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode snapshot response failed: %v", err) } data, ok := response.Data.(map[string]any) if !ok { t.Fatalf("snapshot response must be wrapped in app DTO: %+v", response.Data) } room, ok := data["room"].(map[string]any) if !ok || room["seat_count"] != float64(2) || room["title"] != "Snapshot Room" { t.Fatalf("snapshot room DTO must include derived room fields: %+v", data["room"]) } seats, ok := data["seats"].([]any) if !ok || len(seats) != 2 { t.Fatalf("snapshot DTO must include seat list: %+v", data["seats"]) } if data["is_followed"] != false { t.Fatalf("snapshot DTO must expose is_followed=false explicitly: %+v", data) } } func TestGetRoomDetailIncludesFollowState(t *testing.T) { queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{ Room: &roomv1.RoomSnapshot{ RoomId: "room-detail", OwnerUserId: 1001, Status: "active", ChatEnabled: true, Version: 9, MicSeats: []*roomv1.SeatState{{SeatNo: 1, UserId: 42}}, OnlineUsers: []*roomv1.RoomUser{ {UserId: 42, Role: "audience", JoinedAtMs: 1000, LastSeenAtMs: 2000}, }, RoomExt: map[string]string{"title": "Detail Room"}, }, ServerTimeMs: 123456, IsFollowed: true, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-detail/detail", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-detail") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastSnapshot == nil || queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-detail" { t.Fatalf("detail must read snapshot with authenticated viewer: %+v", queryClient.lastSnapshot) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode detail response failed: %v", err) } data := response.Data.(map[string]any) if data["is_followed"] != true { t.Fatalf("detail response must expose room follow state: %+v", data) } } func TestListRoomOnlineUsersIncludesProfileGenderAndAge(t *testing.T) { queryClient := &fakeRoomQueryClient{onlineResp: &roomv1.ListRoomOnlineUsersResponse{ Items: []*roomv1.RoomOnlineUser{ { UserId: 42, Role: "audience", RoomRole: "admin", GiftValue: 188800, JoinedAtMs: 1000, LastSeenAtMs: 2000, }, }, Total: 1, Page: 1, PageSize: 20, ServerTimeMs: 3000, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-online/online-users?page=1&page_size=20&sort=gift_value", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-online-users") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastOnline == nil || queryClient.lastOnline.GetRoomId() != "room-online" || queryClient.lastOnline.GetViewerUserId() != 42 || queryClient.lastOnline.GetSort() != "gift_value" { t.Fatalf("online list request mismatch: %+v", queryClient.lastOnline) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode online users response failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 { t.Fatalf("online users response mismatch: %+v", response) } first, ok := items[0].(map[string]any) if !ok || first["user_id"] != "42" || first["room_role"] != "admin" || first["gift_value"] != float64(188800) { t.Fatalf("online user item mismatch: %+v", first) } profile, ok := first["profile"].(map[string]any) age, ageOK := profile["age"].(float64) if !ok || profile["gender"] != "female" || !ageOK || age <= 0 { t.Fatalf("online user profile must expose gender and positive age: %+v", first["profile"]) } if profile["country"] != "US" || profile["country_name"] != "United States" || profile["country_display_name"] != "United States" || profile["country_flag"] != "🇺🇸" { t.Fatalf("online user profile must expose country code, name, display name, and flag: %+v", profile) } if _, exists := profile["birth"]; exists { t.Fatalf("online user profile must not expose birth: %+v", profile) } } func TestListRoomBannedUsersIncludesProfileCountryAndUnbanTime(t *testing.T) { queryClient := &fakeRoomQueryClient{bannedUsersResp: &roomv1.ListRoomBannedUsersResponse{ Items: []*roomv1.RoomBannedUser{ { UserId: 43, ActorUserId: 42, CreatedAtMs: 1000, UnbanAtMs: 7000, RemainingMs: 6000, Permanent: false, }, }, Total: 1, Page: 1, PageSize: 20, ServerTimeMs: 2000, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-banned/banned-users?page=1&page_size=20", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-banned-users") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if queryClient.lastBannedUsers == nil || queryClient.lastBannedUsers.GetRoomId() != "room-banned" || queryClient.lastBannedUsers.GetViewerUserId() != 42 || queryClient.lastBannedUsers.GetPageSize() != 20 { t.Fatalf("banned users request mismatch: %+v", queryClient.lastBannedUsers) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode banned users response failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 { t.Fatalf("banned users response mismatch: %+v", response) } first, ok := items[0].(map[string]any) if !ok || first["user_id"] != "43" || first["actor_user_id"] != "42" || first["unban_at_ms"] != float64(7000) || first["remaining_ms"] != float64(6000) { t.Fatalf("banned user item mismatch: %+v", first) } if first["username"] != "user-43" || first["avatar"] != "https://cdn.example/avatar.png" || first["country"] != "US" || first["country_flag"] != "🇺🇸" { t.Fatalf("banned user profile fields mismatch: %+v", first) } } func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) { queryClient := &fakeRoomQueryClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/snapshot?room_id=room:bad", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-snapshot-invalid") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-snapshot-invalid") if queryClient.lastSnapshot != nil { t.Fatalf("invalid room_id must not reach room-service: %+v", queryClient.lastSnapshot) } } func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T) { countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{ Countries: []*userv1.Country{ { CountryId: 86, CountryName: "China", CountryCode: "CN", IsoAlpha3: "CHN", IsoNumeric: "156", CountryDisplayName: "中国", PhoneCountryCode: "+86", Enabled: true, Flag: "CN", SortOrder: 10, }, }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserCountryQueryClient(countryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/countries", nil) request.Header.Set("X-Request-ID", "req-countries") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-countries") { t.Fatalf("country query request metadata mismatch: %+v", countryClient.last) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } countries, ok := data["countries"].([]any) if !ok || len(countries) != 1 { t.Fatalf("countries response shape mismatch: %+v", data) } country, ok := countries[0].(map[string]any) if !ok || country["country_code"] != "CN" || country["iso_numeric"] != "156" || country["phone_country_code"] != "+86" || country["enabled"] != true { t.Fatalf("country metadata mismatch: %+v", country) } } func TestListLoginRiskBlockedCountriesIsPublic(t *testing.T) { countryClient := &fakeUserCountryQueryClient{blockedResp: &userv1.ListLoginRiskBlockedCountriesResponse{ Countries: []*userv1.LoginRiskBlockedCountry{ {CountryCode: "CN", Keyword: "中国", UpdatedAtMs: 1700000000000}, }, UpdatedAtMs: 1700000000000, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserCountryQueryClient(countryClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/login-risk/blocked-countries", nil) request.Header.Set("X-Request-ID", "req-risk-blocks") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if countryClient.lastBlocked == nil || countryClient.lastBlocked.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-risk-blocks") { t.Fatalf("login risk block request metadata mismatch: %+v", countryClient.lastBlocked) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } countries, ok := data["countries"].([]any) if !ok || len(countries) != 1 || data["updated_at_ms"].(float64) != 1700000000000 { t.Fatalf("blocked countries response shape mismatch: %+v", data) } country, ok := countries[0].(map[string]any) if !ok || country["country_code"] != "CN" || country["keyword"] != "中国" { t.Fatalf("blocked country metadata mismatch: %+v", country) } } func TestListH5LinksReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{Links: []appconfig.H5Link{ {Key: "host-center", Label: "Host Center", URL: "https://h5.example.com/host", UpdatedAtMs: 1700000000000}, {Key: "agency-center", Label: "Agency Center", URL: "https://h5.example.com/agency", UpdatedAtMs: 1700000001000}, }}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) request.Header.Set("X-Request-ID", "req-h5-links") request.Header.Set("X-App-Package", "com.org.laluparty") request.Header.Set("X-App-Platform", "android") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["total"].(float64) != 2 { t.Fatalf("h5 total mismatch: %+v", data) } items, ok := data["items"].([]any) if !ok || len(items) != 2 { t.Fatalf("h5 items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["key"] != "host-center" || first["url"] != "https://h5.example.com/host" || first["updated_at_ms"].(float64) != 1700000000000 { t.Fatalf("h5 item mismatch: %+v", first) } } func TestListH5LinksUsesBDLeaderPositionAliasForAdminItem(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{ Links: []appconfig.H5Link{ {Key: "admin", Label: "Admin Center", URL: "https://h5.example.com/admin", UpdatedAtMs: 1700000000000}, {Key: "bd", Label: "BD Center", URL: "https://h5.example.com/bd", UpdatedAtMs: 1700000001000}, }, PositionAliases: map[string]string{"lalu:42": "Senior Admin"}, }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-h5-links-alias") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } items, ok := data["items"].([]any) if !ok || len(items) != 2 { t.Fatalf("h5 items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["key"] != "admin" || first["label"] != "Senior Admin" || first["url"] != "https://h5.example.com/admin" { t.Fatalf("admin h5 alias mismatch: %+v", first) } } func TestListH5LinksRequiresConfigReader(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil) request.Header.Set("X-Request-ID", "req-h5-links-missing") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-h5-links-missing") } func TestListExploreTabsReturnsEnabledAdminConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{ExploreTabs: []appconfig.ExploreTab{ {ID: 7, AppCode: "lalu", Tab: "推荐", H5URL: "https://h5.example.com/explore/recommend", Enabled: true, SortOrder: 1, UpdatedAtMs: 1700000000000}, {ID: 8, AppCode: "lalu", Tab: "游戏", H5URL: "https://h5.example.com/explore/games", Enabled: true, SortOrder: 2, UpdatedAtMs: 1700000001000}, }}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/explore-tabs", nil) request.Header.Set("X-Request-ID", "req-explore-tabs") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["total"].(float64) != 2 { t.Fatalf("unexpected explore response: %+v", response) } items, ok := data["items"].([]any) if !ok || len(items) != 2 { t.Fatalf("explore items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["tab"] != "推荐" || first["h5_url"] != "https://h5.example.com/explore/recommend" || first["enabled"] != true { t.Fatalf("explore item mismatch: %+v", first) } } func TestListExploreTabsRequiresConfigReader(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/explore-tabs", nil) request.Header.Set("X-Request-ID", "req-explore-tabs-missing") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-explore-tabs-missing") } func TestAppBootstrapIsPublicAndLightweight(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil) request.Header.Set("X-Request-ID", "req-bootstrap") request.Header.Set("X-App-Code", "lalu") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["app_code"] != "lalu" || data["server_time_ms"].(float64) <= 0 || data["force_upgrade"] != false || data["maintenance"] != false { t.Fatalf("bootstrap base fields mismatch: %+v", data) } if _, exists := data["banners"]; exists { t.Fatalf("bootstrap must not return large config payloads: %+v", data) } } func TestAppBootstrapResolvesFamiPackageFallback(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil) request.Header.Set("X-Request-ID", "req-bootstrap-fami") request.Header.Set("X-App-Package", "com.chat.fami") request.Header.Set("X-App-Platform", "android") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["app_code"] != "fami" { t.Fatalf("fami bootstrap app_code mismatch: %+v", response) } } type captureAppConfigReader struct { appconfig.StaticReader bannerQuery appconfig.BannerQuery splashQuery appconfig.SplashScreenQuery popupQuery appconfig.PopupQuery } func (r *captureAppConfigReader) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) { r.bannerQuery = query return r.StaticReader.ListBanners(ctx, query) } func (r *captureAppConfigReader) ListSplashScreens(ctx context.Context, query appconfig.SplashScreenQuery) ([]appconfig.SplashScreen, error) { r.splashQuery = query return r.StaticReader.ListSplashScreens(ctx, query) } func (r *captureAppConfigReader) ListPopups(ctx context.Context, query appconfig.PopupQuery) ([]appconfig.Popup, error) { r.popupQuery = query return r.StaticReader.ListPopups(ctx, query) } func TestListAppBannersReturnsAdminAppConfig(t *testing.T) { profileClient := &fakeUserProfileClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{ { ID: 8, CoverURL: "https://cdn.example.com/banner.png", RoomSmallImageURL: "https://cdn.example.com/banner-small.png", BannerType: "h5", DisplayScope: "home,room,me", DisplayScopes: []string{"home", "room", "me"}, Param: "https://h5.example.com/activity", Platform: "android", SortOrder: 10, Description: "room banner", StartsAtMs: 1700000000000, EndsAtMs: 1800000000000, UpdatedAtMs: 1700000002000, }, }}} handler.SetAppConfigReader(reader) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?display_scope=me&platform=android&country=CN®ion_id=1", nil) request.Header.Set("X-Request-ID", "req-app-banners") request.Header.Set("X-App-Package", "com.org.laluparty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if reader.bannerQuery.DisplayScope != "me" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 0 || reader.bannerQuery.Country != "" { t.Fatalf("banner query mismatch: %+v", reader.bannerQuery) } if profileClient.lastGet != nil { t.Fatalf("anonymous banner request must not query user profile: %+v", profileClient.lastGet) } if data["total"].(float64) != 1 { t.Fatalf("banner total mismatch: %+v", data) } items, ok := data["items"].([]any) if !ok || len(items) != 1 { t.Fatalf("banner items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["cover_url"] != "https://cdn.example.com/banner.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/activity" { t.Fatalf("banner item mismatch: %+v", first) } if first["display_scope"] != "home,room,me" || first["room_small_image_url"] != "https://cdn.example.com/banner-small.png" { t.Fatalf("banner display fields mismatch: %+v", first) } displayScopes, ok := first["display_scopes"].([]any) if !ok || len(displayScopes) != 3 || displayScopes[0] != "home" || displayScopes[1] != "room" || displayScopes[2] != "me" { t.Fatalf("banner display scopes mismatch: %+v", first) } if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000002000 { t.Fatalf("banner metadata mismatch: %+v", first) } } func TestListAppBannersUsesAuthenticatedUserRegion(t *testing.T) { profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: { UserId: 42, AppCode: "lalu", RegionId: 1, Country: "AE", }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{ { ID: 16, CoverURL: "https://cdn.example.com/middle-east-banner.png", BannerType: "h5", DisplayScope: "home", Param: "https://h5.example.com/middle-east", Platform: "android", SortOrder: 1, RegionID: 1, CountryCode: "AE", }, }}} handler.SetAppConfigReader(reader) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?display_scope=home&platform=android&country=US®ion_id=999", nil) request.Header.Set("X-Request-ID", "req-app-banners-auth") request.Header.Set("X-App-Code", "lalu") request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("user profile request mismatch: %+v", profileClient.lastGet) } if profileClient.lastGet.GetMeta().GetAppCode() != "lalu" { t.Fatalf("user profile app_code mismatch: %+v", profileClient.lastGet.GetMeta()) } if reader.bannerQuery.DisplayScope != "home" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 1 || reader.bannerQuery.Country != "AE" { t.Fatalf("banner query mismatch: %+v", reader.bannerQuery) } } func TestListSplashScreensReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{SplashScreens: []appconfig.SplashScreen{ { ID: 12, CoverURL: "https://cdn.example.com/splash.png", SplashType: "h5", Param: "https://h5.example.com/splash", Platform: "ios", SortOrder: 2, RegionID: 7, CountryCode: "BR", Description: "launch splash", DisplayDurationMs: 4500, StartsAtMs: 1700000000000, EndsAtMs: 1800000000000, UpdatedAtMs: 1700000003000, }, }}} handler.SetAppConfigReader(reader) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/splash-screens?platform=ios&country=BR®ion_id=7", nil) request.Header.Set("X-Request-ID", "req-app-splash") request.Header.Set("X-App-Code", "lalu") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if reader.splashQuery.AppCode != "lalu" || reader.splashQuery.Platform != "ios" || reader.splashQuery.RegionID != 7 || reader.splashQuery.Country != "BR" { t.Fatalf("splash query mismatch: %+v", reader.splashQuery) } if data["total"].(float64) != 1 { t.Fatalf("splash total mismatch: %+v", data) } items, ok := data["items"].([]any) if !ok || len(items) != 1 { t.Fatalf("splash items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["cover_url"] != "https://cdn.example.com/splash.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/splash" { t.Fatalf("splash item mismatch: %+v", first) } if first["platform"] != "ios" || first["sort_order"].(float64) != 2 || first["region_id"].(float64) != 7 || first["country_code"] != "BR" { t.Fatalf("splash scope mismatch: %+v", first) } if first["display_duration_ms"].(float64) != 4500 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000003000 { t.Fatalf("splash metadata mismatch: %+v", first) } } func TestSplashScreenJSONIncludesEmptyScopeFields(t *testing.T) { payload, err := json.Marshal(appconfig.SplashScreen{ ID: 99, CoverURL: "https://cdn.example.com/splash.png", SplashType: "app", Platform: "android", UpdatedAtMs: 1700000003000, }) if err != nil { t.Fatalf("marshal splash screen failed: %v", err) } var item map[string]any if err := json.Unmarshal(payload, &item); err != nil { t.Fatalf("unmarshal splash screen failed: %v", err) } for _, key := range []string{"region_id", "country_code", "description", "display_duration_ms", "starts_at_ms", "ends_at_ms"} { if _, ok := item[key]; !ok { t.Fatalf("splash json missing %s: %+v", key, item) } } } func TestListPopupsReturnsAdminAppConfig(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Popups: []appconfig.Popup{ { ID: 21, Code: "new-user-popup", Name: "新人活动", ImageURL: "https://cdn.example.com/popup.png", JumpType: "h5", JumpURL: "https://h5.example.com/new-user", DisplayPeriodDays: 2, SortOrder: 3, StartsAtMs: 1700000000000, EndsAtMs: 1800000000000, UpdatedAtMs: 1700000004000, }, }}} handler.SetAppConfigReader(reader) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/popups", nil) request.Header.Set("X-Request-ID", "req-app-popups") request.Header.Set("X-App-Code", "lalu") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if reader.popupQuery.AppCode != "lalu" { t.Fatalf("popup query mismatch: %+v", reader.popupQuery) } if data["total"].(float64) != 1 { t.Fatalf("popup total mismatch: %+v", data) } items, ok := data["items"].([]any) if !ok || len(items) != 1 { t.Fatalf("popup items shape mismatch: %+v", data) } first, ok := items[0].(map[string]any) if !ok || first["code"] != "new-user-popup" || first["name"] != "新人活动" || first["jump_type"] != "h5" || first["jump_url"] != "https://h5.example.com/new-user" { t.Fatalf("popup item mismatch: %+v", first) } if first["image_url"] != "https://cdn.example.com/popup.png" || first["display_period_days"].(float64) != 2 || first["sort_order"].(float64) != 3 { t.Fatalf("popup display fields mismatch: %+v", first) } if first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000004000 { t.Fatalf("popup metadata mismatch: %+v", first) } } func TestGetAppVersionReturnsLatestVersionAndUpdateDecision(t *testing.T) { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAppConfigReader(appconfig.StaticReader{Version: appconfig.Version{ AppCode: "lalu", Platform: "ios", Version: "1.2.0", BuildNumber: 120, ForceUpdate: true, DownloadURL: "https://apps.apple.com/app/lalu", Description: "bug fixes", UpdatedAtMs: 1700000003000, }}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/version?build_number=100", nil) request.Header.Set("X-Request-ID", "req-app-version") request.Header.Set("X-App-Code", "lalu") request.Header.Set("X-App-Platform", "ios") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["platform"] != "ios" || data["version"] != "1.2.0" || data["build_number"] != float64(120) || data["current_build_number"] != float64(100) || data["has_update"] != true || data["force_update"] != true || data["download_url"] != "https://apps.apple.com/app/lalu" { t.Fatalf("app version response mismatch: %+v", data) } } func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{ UserId: 42, IsHost: true, IsAgency: true, IsManager: true, IsBd: true, IsBdLeader: true, IsCoinSeller: true, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-identity") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastRoleSummary.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-host-identity") { t.Fatalf("host identity role summary request mismatch: %+v", hostClient.lastRoleSummary) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["is_host"] != true || data["is_agent"] != true || data["is_manager"] != true || data["is_bd"] != true || data["is_bd_leader"] != true || data["is_coin_seller"] != true { t.Fatalf("host identity response mismatch: %+v", response) } } func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) { hostClient := &fakeUserHostClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-identity-empty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["is_host"] != false || data["is_agent"] != false || data["is_manager"] != false || data["is_bd"] != false || data["is_bd_leader"] != false || data["is_coin_seller"] != false { t.Fatalf("empty host identity response mismatch: %+v", response) } } func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) { hostClient := &fakeUserHostClient{searchAgenciesResp: &userv1.SearchAgenciesResponse{Agencies: []*userv1.Agency{ { AgencyId: 7001, OwnerUserId: 901, RegionId: 30, Name: "Admin Seed Agency", Avatar: "https://cdn.example/agency.png", HostCount: 3, Status: "active", JoinEnabled: true, MaxHosts: 100, CreatedAtMs: 1700000000000, UpdatedAtMs: 1700000001000, }, }}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=150", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-agency-search") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 100 { t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) items := data["items"].([]any) first := items[0].(map[string]any) if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || first["avatar"] != "https://cdn.example/agency.png" || first["host_count"] != float64(3) || data["page_size"] != float64(100) { t.Fatalf("host agency search response mismatch: %+v", response) } if _, exists := first["max_hosts"]; exists { t.Fatalf("host agency search response must not expose max_hosts: %+v", first) } } func TestSearchHostAgenciesAllowsEmptyKeywordForCountryList(t *testing.T) { hostClient := &fakeUserHostClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-agency-list") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "" || hostClient.lastSearchAgencies.GetPageSize() != 100 { t.Fatalf("host agency list request mismatch: %+v", hostClient.lastSearchAgencies) } } func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) { hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{ { UserId: 801, DisplayUserId: "900801", Username: "Seller One", Avatar: "https://cdn.example/avatar.png", CountryId: 86, CountryCode: "CN", CountryName: "China", CountryDisplayName: "中国", RegionId: 30, RegionCode: "east-asia", RegionName: "East Asia", Status: "active", MerchantAssetType: "COIN_SELLER_COIN", ContactInfo: "+65 1234 5678", UpdatedAtMs: 1700000001000, }, { UserId: 802, DisplayUserId: "900802", Username: "Seller Without Contact", RegionId: 30, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", UpdatedAtMs: 1700000002000, }, }}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-sellers", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-coin-sellers") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 { t.Fatalf("coin seller list request mismatch: %+v", hostClient.lastListCoinSellers) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) items := data["items"].([]any) first := items[0].(map[string]any) if data["total"] != float64(2) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" || first["whatsapp_phone"] != "+65 1234 5678" || first["whatsapp_url"] != "https://wa.me/6512345678" { t.Fatalf("coin seller list response mismatch: %+v", response) } second := items[1].(map[string]any) if second["whatsapp_phone"] != "" || second["whatsapp_url"] != "" { t.Fatalf("empty contact whatsapp fields must be stable empty strings: %+v", second) } } func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) { hostClient := &fakeUserHostClient{ hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}, listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{ { UserId: 42, DisplayUserId: "163006", Username: "self seller", RegionId: 30, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }, }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/coin-sellers/search?identity=HOST&display_user_id=163006", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-salary-search-self") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 { t.Fatalf("salary wallet seller search request mismatch: %+v", hostClient.lastListCoinSellers) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) seller := data["seller"].(map[string]any) if seller["user_id"] != "42" || seller["display_user_id"] != "163006" || seller["merchant_asset_type"] != "COIN_SELLER_COIN" { t.Fatalf("self salary seller search response mismatch: %+v", response) } } func TestSalaryWalletWithdrawAddressGetAndSave(t *testing.T) { profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) router := handler.Routes(auth.NewVerifier("secret")) getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/withdraw-address", nil) getRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) getRequest.Header.Set("X-Request-ID", "req-salary-withdraw-address-get") getRecorder := httptest.NewRecorder() router.ServeHTTP(getRecorder, getRequest) if getRecorder.Code != http.StatusOK { t.Fatalf("get status mismatch: got %d body=%s", getRecorder.Code, getRecorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("withdraw address get request mismatch: %+v", profileClient.lastGet) } var getResponse httpkit.ResponseEnvelope if err := json.NewDecoder(getRecorder.Body).Decode(&getResponse); err != nil { t.Fatalf("decode get response failed: %v", err) } getData := getResponse.Data.(map[string]any) if getData["withdraw_usdt_trc20_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || getData["usdt_trc20_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || getData["has_withdraw_usdt_trc20_address"] != true { t.Fatalf("withdraw address get response mismatch: %+v", getResponse) } saveBody := []byte(`{"payment_address":"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"}`) saveRequest := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw-address", bytes.NewReader(saveBody)) saveRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) saveRequest.Header.Set("X-Request-ID", "req-salary-withdraw-address-save") saveRecorder := httptest.NewRecorder() router.ServeHTTP(saveRecorder, saveRequest) if saveRecorder.Code != http.StatusOK { t.Fatalf("save status mismatch: got %d body=%s", saveRecorder.Code, saveRecorder.Body.String()) } if profileClient.lastWithdrawAddress == nil || profileClient.lastWithdrawAddress.GetUserId() != 42 || profileClient.lastWithdrawAddress.GetUsdtTrc20Address() != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" { t.Fatalf("withdraw address save request mismatch: %+v", profileClient.lastWithdrawAddress) } var saveResponse httpkit.ResponseEnvelope if err := json.NewDecoder(saveRecorder.Body).Decode(&saveResponse); err != nil { t.Fatalf("decode save response failed: %v", err) } saveData := saveResponse.Data.(map[string]any) if saveData["withdraw_usdt_trc20_address"] != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" || saveData["has_withdraw_usdt_trc20_address"] != true { t.Fatalf("withdraw address save response mismatch: %+v", saveResponse) } } func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{ {AssetType: "HOST_SALARY_USD", AvailableAmount: 6000}, }}} hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}} profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"} withdrawalWriter := &fakeWithdrawalApplicationWriter{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) handler.SetWithdrawalApplicationWriter(withdrawalWriter) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-salary-withdraw") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "HOST_SALARY_USD" { t.Fatalf("salary withdraw balance request mismatch: %+v", walletClient.last) } if walletClient.lastFreezeSalary == nil || walletClient.lastFreezeSalary.GetUserId() != 42 || walletClient.lastFreezeSalary.GetSalaryAssetType() != "HOST_SALARY_USD" || walletClient.lastFreezeSalary.GetSalaryUsdMinor() != 5000 || walletClient.lastFreezeSalary.GetWithdrawalRef() == "" || !strings.Contains(walletClient.lastFreezeSalary.GetCommandId(), walletClient.lastFreezeSalary.GetWithdrawalRef()) { t.Fatalf("salary withdraw must freeze wallet balance before application: %+v", walletClient.lastFreezeSalary) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || profileClient.lastWithdrawAddress != nil { t.Fatalf("salary withdraw address lookup mismatch: get=%+v update=%+v", profileClient.lastGet, profileClient.lastWithdrawAddress) } if withdrawalWriter.calls != 1 || withdrawalWriter.last.AppCode != "lalu" || withdrawalWriter.last.UserID != 42 || withdrawalWriter.last.SalaryAssetType != "HOST_SALARY_USD" || withdrawalWriter.last.WithdrawAmount != "50.00" || withdrawalWriter.last.WithdrawAmountMinor != 5000 || withdrawalWriter.last.WithdrawMethod != financewithdrawal.MethodUSDTTRC20 || withdrawalWriter.last.WithdrawAddress != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || withdrawalWriter.last.FreezeCommandID != walletClient.lastFreezeSalary.GetCommandId() || withdrawalWriter.last.FreezeTransactionID != "freeze-tx-1" || withdrawalWriter.last.CreatedAtMS <= 0 { t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) balanceAfter, _ := data["salary_balance_after"].(map[string]any) if data["application_id"] != "77" || data["status"] != "pending" || data["withdraw_amount"] != "50.00" || data["withdraw_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || data["freeze_transaction_id"] != "freeze-tx-1" || data["salary_usd_minor"] != float64(5000) || balanceAfter["available_amount"] != float64(1000) || balanceAfter["frozen_amount"] != float64(5000) { t.Fatalf("withdraw application response mismatch: %+v", response) } } func TestSalaryWalletWithdrawSavesSubmittedAddressBeforeApplication(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{ {AssetType: "HOST_SALARY_USD", AvailableAmount: 7500}, }}} hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}} profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"} withdrawalWriter := &fakeWithdrawalApplicationWriter{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) handler.SetWithdrawalApplicationWriter(withdrawalWriter) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd_minor":7500,"payment_address":"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-salary-withdraw-new-address") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastWithdrawAddress == nil || profileClient.lastWithdrawAddress.GetUserId() != 42 || profileClient.lastWithdrawAddress.GetUsdtTrc20Address() != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" || profileClient.lastGet != nil { t.Fatalf("submitted withdraw address save mismatch: get=%+v update=%+v", profileClient.lastGet, profileClient.lastWithdrawAddress) } if withdrawalWriter.calls != 1 || withdrawalWriter.last.WithdrawAmount != "75.00" || withdrawalWriter.last.WithdrawAmountMinor != 7500 || withdrawalWriter.last.FreezeTransactionID != "freeze-tx-1" || withdrawalWriter.last.WithdrawAddress != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" { t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls) } } func TestSalaryWalletWithdrawRejectsInsufficientBalance(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{ {AssetType: "HOST_SALARY_USD", AvailableAmount: 4999}, }}} hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}} profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"} withdrawalWriter := &fakeWithdrawalApplicationWriter{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) handler.SetWithdrawalApplicationWriter(withdrawalWriter) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-salary-withdraw-insufficient") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if withdrawalWriter.calls != 0 || walletClient.lastFreezeSalary != nil || profileClient.lastGet != nil || profileClient.lastWithdrawAddress != nil { t.Fatalf("insufficient balance must stop before freeze/address/application: calls=%d freeze=%+v get=%+v update=%+v", withdrawalWriter.calls, walletClient.lastFreezeSalary, profileClient.lastGet, profileClient.lastWithdrawAddress) } } func TestSalaryWalletTransferMapsEachIdentityToSalaryAssetAndAllowsSelfSeller(t *testing.T) { tests := []struct { name string identity string assetType string configure func(*fakeUserHostClient) assertCalls func(*testing.T, *fakeUserHostClient) }{ { name: "host", identity: "HOST", assetType: "HOST_SALARY_USD", configure: func(hostClient *fakeUserHostClient) { hostClient.hostProfile = &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30} }, assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { t.Helper() if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 { t.Fatalf("host identity request mismatch: %+v", hostClient.lastHost) } }, }, { name: "agency", identity: "AGENCY", assetType: "AGENCY_SALARY_USD", configure: func(hostClient *fakeUserHostClient) { hostClient.roleSummary = &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001} hostClient.getAgencyResp = &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active", RegionId: 30}} }, assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { t.Helper() if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { t.Fatalf("agency identity requests mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency) } }, }, { name: "bd", identity: "BD", assetType: "BD_SALARY_USD", configure: func(hostClient *fakeUserHostClient) { hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 30} }, assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { t.Helper() if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" { t.Fatalf("bd identity request mismatch: %+v", hostClient.lastBD) } }, }, { name: "bd-leader-admin", identity: "BD_LEADER", assetType: "ADMIN_SALARY_USD", configure: func(hostClient *fakeUserHostClient) { hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30} }, assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { t.Helper() if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd_leader" { t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD) } }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { walletClient := &fakeWalletClient{transferSalaryResp: &walletv1.TransferSalaryToCoinSellerResponse{ TransactionId: "wtx-salary-" + test.name, SourceSalaryBalanceAfter: 950, SellerBalanceAfter: 40500, SalaryUsdMinor: 50, CoinAmount: 40500, CoinPerUsd: 81000, RateMinUsdMinor: 10, RateMaxUsdMinor: 10000, }} hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{ { UserId: 42, DisplayUserId: "163006", Username: "self seller", RegionId: 30, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }, }}} test.configure(hostClient) handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"command_id":"cmd-salary-self-` + test.name + `","identity":"` + test.identity + `","target_display_user_id":"163006","amount_usd":"0.50","reason":"salary transfer"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/transfer-to-coin-seller", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-salary-transfer-self-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } test.assertCalls(t, hostClient) if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 { t.Fatalf("coin seller search request mismatch: %+v", hostClient.lastListCoinSellers) } if walletClient.lastTransferSalary == nil || walletClient.lastTransferSalary.GetSourceUserId() != 42 || walletClient.lastTransferSalary.GetSellerUserId() != 42 || walletClient.lastTransferSalary.GetSalaryAssetType() != test.assetType || walletClient.lastTransferSalary.GetSalaryUsdMinor() != 50 || walletClient.lastTransferSalary.GetRegionId() != 30 { t.Fatalf("salary transfer wallet command mismatch: %+v", walletClient.lastTransferSalary) } }) } } func TestSalaryWalletHistoryMapsIdentityToSalaryAsset(t *testing.T) { walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{ Total: 2, Transactions: []*walletv1.WalletTransaction{ { EntryId: 31, TransactionId: "wtx-admin-salary-in", BizType: "host_salary_settlement", AssetType: "ADMIN_SALARY_USD", AvailableDelta: 1100, AvailableAfter: 1100, CreatedAtMs: 1_781_000_000_000, }, { EntryId: 32, TransactionId: "wtx-admin-salary-out", BizType: "salary_transfer_to_coin_seller", AssetType: "ADMIN_SALARY_USD", AvailableDelta: -500, AvailableAfter: 600, CounterpartyUserId: 9001, CreatedAtMs: 1_781_000_060_000, }, }, }} hostClient := &fakeUserHostClient{ bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30}, } profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 9001: { UserId: 9001, DisplayUserId: "163001", Username: "Seller One", Avatar: "https://cdn.example/seller.png", AppCode: "lalu", }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/history?identity=BD_LEADER&page=2&page_size=10", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-salary-history") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd_leader" || hostClient.lastBD.GetUserId() != 42 { t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD) } if walletClient.lastTransactions == nil || walletClient.lastTransactions.GetUserId() != 42 || walletClient.lastTransactions.GetAssetType() != "ADMIN_SALARY_USD" || walletClient.lastTransactions.GetPage() != 2 || walletClient.lastTransactions.GetPageSize() != 10 { t.Fatalf("salary wallet history request mismatch: %+v", walletClient.lastTransactions) } if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 { t.Fatalf("salary history counterparty profile request mismatch: %+v", profileClient.lastBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || data["identity"] != "BD_LEADER" || data["salary_asset_type"] != "ADMIN_SALARY_USD" || len(items) != 2 { t.Fatalf("salary wallet history response mismatch: %+v", response) } second := items[1].(map[string]any) if second["available_delta"] != float64(-500) || second["available_after"] != float64(600) || second["counterparty_display_user_id"] != "163001" { t.Fatalf("salary wallet history item mismatch: %+v", second) } } func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) { hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{ ApplicationId: 8001, CommandId: "cmd-apply-agency", ApplicantUserId: 42, AgencyId: 7001, RegionId: 30, Status: "pending", CreatedAtMs: 1700000000000, UpdatedAtMs: 1700000001000, }}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/host/agency-applications", bytes.NewReader([]byte(`{"command_id":"cmd-apply-agency","agency_id":"7001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-agency-apply") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastApplyAgency == nil || hostClient.lastApplyAgency.GetUserId() != 42 || hostClient.lastApplyAgency.GetCommandId() != "cmd-apply-agency" || hostClient.lastApplyAgency.GetAgencyId() != 7001 { t.Fatalf("host agency apply request mismatch: %+v", hostClient.lastApplyAgency) } } func TestHostCenterAgencyUsesCurrentHostAgencyAndOwnerProfile(t *testing.T) { hostClient := &fakeUserHostClient{ hostProfile: &userv1.HostProfile{ UserId: 42, Status: "active", RegionId: 30, CurrentAgencyId: 7001, }, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{ AgencyId: 7001, OwnerUserId: 99, RegionId: 30, Name: "Yumi Star Agency", Avatar: "https://cdn.example/agency.png", Status: "active", }}, } profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 99: {UserId: 99, DisplayUserId: "163003", Username: "agency-owner", Avatar: "https://cdn.example/agency-owner.png"}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/agency?agency_id=9999", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-center-agency") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 { t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost) } if hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { t.Fatalf("agency request must use current host agency: %+v", hostClient.lastGetAgency) } if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 99 { t.Fatalf("owner profile request mismatch: %+v", profileClient.lastBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) agency := data["agency"].(map[string]any) if agency["agency_id"] != "7001" || agency["short_id"] != "163003" || agency["avatar"] != "https://cdn.example/agency.png" || agency["name"] != "Yumi Star Agency" { t.Fatalf("host center agency response mismatch: %+v", agency) } } func TestGetAgencyProfileReturnsIndependentAgencyAvatar(t *testing.T) { hostClient := &fakeUserHostClient{ hostProfile: &userv1.HostProfile{UserId: 88, Status: "active", RegionId: 30, CurrentAgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{ AgencyId: 7001, OwnerUserId: 99, RegionId: 30, Name: "Independent Agency", Avatar: "https://cdn.example/agency-avatar.png", HostCount: 3, Status: "active", JoinEnabled: true, }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agencies/profile?user_id=88", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-agency-profile") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 88 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { t.Fatalf("agency profile upstream requests mismatch: host=%+v agency=%+v", hostClient.lastHost, hostClient.lastGetAgency) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) agency := data["agency"].(map[string]any) if agency["agency_id"] != "7001" || agency["owner_user_id"] != "99" || agency["name"] != "Independent Agency" || agency["avatar"] != "https://cdn.example/agency-avatar.png" || agency["host_count"] != float64(3) { t.Fatalf("agency profile response mismatch: %+v", agency) } } func TestGetAgencyProfileReturnsNullWhenTargetNotActiveHost(t *testing.T) { hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 88, Status: "inactive", RegionId: 30, CurrentAgencyId: 7001}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agencies/profile?user_id=88", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-agency-profile-empty") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastGetAgency != nil { t.Fatalf("inactive host must not fetch agency: %+v", hostClient.lastGetAgency) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) if data["agency"] != nil { t.Fatalf("inactive host must return null agency: %+v", data) } } func TestListAgencyRoomsUsesActiveAgencyHosts(t *testing.T) { hostClient := &fakeUserHostClient{ hostProfile: &userv1.HostProfile{UserId: 88, Status: "active", RegionId: 30, CurrentAgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{ AgencyId: 7001, OwnerUserId: 99, RegionId: 30, Name: "Independent Agency", Avatar: "https://cdn.example/agency-avatar.png", Status: "active", }}, membersResp: &userv1.GetAgencyMembersResponse{Memberships: []*userv1.AgencyMembership{ {MembershipId: 1, AgencyId: 7001, HostUserId: 101, Status: "active"}, {MembershipId: 2, AgencyId: 7001, HostUserId: 102, Status: "inactive"}, {MembershipId: 3, AgencyId: 7001, HostUserId: 99, Status: "active"}, }}, } roomClient := &fakeRoomQueryClient{ownersResp: &roomv1.ListRoomsResponse{ Rooms: []*roomv1.RoomListItem{{ RoomId: "room-101", OwnerUserId: 101, Title: "Agency Room", CoverUrl: "https://cdn.example/room.png", Mode: "voice", Status: "active", Heat: 88, OnlineCount: 12, SeatCount: 8, OccupiedSeatCount: 3, RoomShortId: "M20", CountryCode: "AE", }}, NextCursor: "next-owner-room", }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) handler.SetRoomQueryClient(roomClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agencies/rooms?user_id=88&tab=new&limit=120&cursor=cursor-1&query=karaoke", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-agency-rooms") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastMembers == nil || hostClient.lastMembers.GetAgencyId() != 7001 || hostClient.lastMembers.GetStatus() != "active" { t.Fatalf("agency members request mismatch: %+v", hostClient.lastMembers) } if roomClient.lastListOwners == nil { t.Fatal("agency rooms must call ListRoomsByOwners") } ownerIDs := roomClient.lastListOwners.GetOwnerUserIds() if len(ownerIDs) != 2 || ownerIDs[0] != 99 || ownerIDs[1] != 101 { t.Fatalf("owner ids mismatch: got %v request=%+v", roomClient.lastListOwners.GetOwnerUserIds(), roomClient.lastListOwners) } if roomClient.lastListOwners.GetViewerUserId() != 42 || roomClient.lastListOwners.GetTab() != "new" || roomClient.lastListOwners.GetLimit() != 50 || roomClient.lastListOwners.GetCursor() != "cursor-1" || roomClient.lastListOwners.GetQuery() != "karaoke" { t.Fatalf("agency rooms request mismatch: %+v", roomClient.lastListOwners) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) rooms := data["rooms"].([]any) first := rooms[0].(map[string]any) if data["agency_id"] != "7001" || data["next_cursor"] != "next-owner-room" || first["room_id"] != "room-101" || first["owner_user_id"] != "101" || first["country_flag"] != "🇦🇪" { t.Fatalf("agency rooms response mismatch: %+v", response) } } func TestUpdateAgencyProfileForwardsOwnerCommand(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, RegionId: 30, Name: "Old Agency", Avatar: "https://cdn.example/old.png", Status: "active"}}, updateAgencyResp: &userv1.UpdateAgencyProfileResponse{Agency: &userv1.Agency{ AgencyId: 7001, OwnerUserId: 42, RegionId: 30, Name: "New Agency", Avatar: "https://cdn.example/new.png", Status: "active", }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/agencies/profile/update", bytes.NewReader([]byte(`{"command_id":"cmd-agency-profile","name":" New Agency ","avatar":" https://cdn.example/new.png "}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-agency-profile-update") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastUpdateAgency == nil || hostClient.lastUpdateAgency.GetCommandId() != "cmd-agency-profile" || hostClient.lastUpdateAgency.GetOperatorUserId() != 42 || hostClient.lastUpdateAgency.GetAgencyId() != 7001 || hostClient.lastUpdateAgency.GetName() != "New Agency" || hostClient.lastUpdateAgency.GetAvatar() != "https://cdn.example/new.png" { t.Fatalf("update agency profile request mismatch: %+v", hostClient.lastUpdateAgency) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) agency := data["agency"].(map[string]any) if agency["agency_id"] != "7001" || agency["name"] != "New Agency" || agency["avatar"] != "https://cdn.example/new.png" { t.Fatalf("update agency profile response mismatch: %+v", agency) } } func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) { hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{ UserId: 42, Status: "active", RegionId: 30, CurrentAgencyId: 7001, }} walletClient := &fakeWalletClient{ hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{ Found: true, Policy: &walletv1.HostSalaryPolicy{ PolicyId: 9001, Name: "Host Growth Policy", RegionId: 30, Status: "active", SettlementMode: "half_month", SettlementTriggerMode: "manual", GiftCoinToDiamondRatio: "1.0000", ResidualDiamondToUsdRate: "0.0100", EffectiveFromMs: 1700000000000, Levels: []*walletv1.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUsdMinor: 1500, HostCoinReward: 200, AgencySalaryUsdMinor: 300, Status: "active", SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUsdMinor: 4500, HostCoinReward: 500, AgencySalaryUsdMinor: 900, Status: "active", SortOrder: 2}, }, }, }, hostSalaryProgressResp: &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{ HostUserId: 42, CycleKey: "2026-06", RegionId: 30, AgencyOwnerUserId: 99, TotalDiamonds: 1200, GiftDiamondTotal: 1200, }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-host-policy") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 { t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost) } if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 30 || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" { t.Fatalf("host salary policy request mismatch: %+v", walletClient.lastHostSalaryPolicy) } if walletClient.lastHostSalaryProgress == nil || walletClient.lastHostSalaryProgress.GetHostUserId() != 42 || walletClient.lastHostSalaryProgress.GetAppCode() == "" || walletClient.lastHostSalaryProgress.GetRequestId() == "" { t.Fatalf("host salary progress request mismatch: %+v", walletClient.lastHostSalaryProgress) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) policy := data["policy"].(map[string]any) levels := policy["levels"].([]any) firstLevel := levels[0].(map[string]any) progress := data["progress"].(map[string]any) levelProgress := data["level_progress"].(map[string]any) if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["settlement_mode"] != "half_month" || policy["settlement_trigger_mode"] != "manual" || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 { t.Fatalf("host policy response mismatch: %+v", data) } if progress["cycle_key"] != "2026-06" || progress["total_diamonds"] != float64(1200) { t.Fatalf("host salary progress response mismatch: %+v", progress) } if levelProgress["current_level"] != float64(1) || levelProgress["current_value"] != float64(1200) || levelProgress["next_level"] != float64(2) || levelProgress["needed_for_next_level"] != float64(1800) { t.Fatalf("host level progress response mismatch: %+v", levelProgress) } } func TestHostCenterPlatformPolicyRejectsNonActiveHost(t *testing.T) { hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{ UserId: 42, Status: "pending", RegionId: 30, }} walletClient := &fakeWalletClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastHostSalaryPolicy != nil { t.Fatalf("wallet should not be called for non-active host: %+v", walletClient.lastHostSalaryPolicy) } if walletClient.lastHostSalaryProgress != nil { t.Fatalf("progress should not be called for non-active host: %+v", walletClient.lastHostSalaryProgress) } } func TestAgencyCenterOverviewAllowsOwnerWithoutManagerProfile(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{ AgencyId: 7001, OwnerUserId: 42, RegionId: 30, ParentBdUserId: 77, Name: "Yumi Star Agency", Status: "active", HostCount: 3, }}, applicationsResp: &userv1.GetAgencyApplicationsResponse{Applications: []*userv1.AgencyApplication{ {ApplicationId: 9001, ApplicantUserId: 100, AgencyId: 7001, Status: "pending"}, {ApplicationId: 9002, ApplicantUserId: 101, AgencyId: 7001, Status: "pending"}, }}, } walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{ 42: {Balances: []*walletv1.AssetBalance{{AssetType: "AGENCY_SALARY_USD", AvailableAmount: 1288075}}}, }} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 77: {UserId: 77, DisplayUserId: "880001", Username: "Mila BD", Avatar: "https://cdn.example/bd.png"}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetUserHostClient(hostClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/overview", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-agency-center-overview") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { t.Fatalf("agency owner resolution mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency) } if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "AGENCY_SALARY_USD" { t.Fatalf("agency salary request mismatch: %+v", walletClient.last) } if hostClient.lastApplications == nil || hostClient.lastApplications.GetAgencyId() != 7001 || hostClient.lastApplications.GetStatus() != "pending" { t.Fatalf("pending applications request mismatch: %+v", hostClient.lastApplications) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) salary := data["salary"].(map[string]any) bd := data["bd"].(map[string]any) if salary["asset_type"] != "AGENCY_SALARY_USD" || salary["available_amount"] != float64(1288075) || salary["display_amount"] != 12880.75 || data["pending_application_count"] != float64(2) || bd["display_user_id"] != "880001" { t.Fatalf("overview response mismatch: %+v", data) } } func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{ AgencyId: 7001, OwnerUserId: 42, RegionId: 31, Name: "Yumi Star Agency", Status: "active", }}, } walletClient := &fakeWalletClient{ hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{ Found: true, Policy: &walletv1.HostSalaryPolicy{ PolicyId: 9101, Name: "Agency Region Policy", RegionId: 31, Status: "active", SettlementMode: "half_month", SettlementTriggerMode: "manual", GiftCoinToDiamondRatio: "1.0000", ResidualDiamondToUsdRate: "0.0100", Levels: []*walletv1.HostSalaryPolicyLevel{ {LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUsdMinor: 1200, HostCoinReward: 100, AgencySalaryUsdMinor: 240, Status: "active", SortOrder: 1}, {LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUsdMinor: 3600, HostCoinReward: 300, AgencySalaryUsdMinor: 720, Status: "active", SortOrder: 2}, }, }, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/platform-policy", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-agency-policy") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { t.Fatalf("agency owner resolution mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency) } if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 31 || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" { t.Fatalf("agency policy request mismatch: %+v", walletClient.lastHostSalaryPolicy) } if walletClient.lastHostSalaryProgress != nil { t.Fatalf("agency policy should not request host progress: %+v", walletClient.lastHostSalaryProgress) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) policy := data["policy"].(map[string]any) levels := policy["levels"].([]any) secondLevel := levels[1].(map[string]any) if data["found"] != true || data["agency_region_id"] != float64(31) || policy["policy_id"] != "9101" || len(levels) != 2 || secondLevel["agency_salary_usd"] != 7.2 { t.Fatalf("agency policy response mismatch: %+v", data) } } func TestBDCenterOverviewUsesBDSalaryAndDirectAgencies(t *testing.T) { hostClient := &fakeUserHostClient{ bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001}, bdAgenciesResp: &userv1.ListBDAgenciesResponse{Agencies: []*userv1.Agency{ {AgencyId: 7001, OwnerUserId: 99, ParentBdUserId: 42, Name: "Yumi Star Agency", Status: "active"}, }}, } walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{ 42: {Balances: []*walletv1.AssetBalance{{AssetType: "BD_SALARY_USD", AvailableAmount: 4862080}}}, }} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: {UserId: 42, DisplayUserId: "163008", Username: "BD_Jasmine", Avatar: "https://cdn.example/bd.png"}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetUserHostClient(hostClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/bd-center/overview", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-bd-center-overview") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" || hostClient.lastBDAgencies == nil || hostClient.lastBDAgencies.GetBdUserId() != 42 { t.Fatalf("bd center requests mismatch: profile=%+v agencies=%+v", hostClient.lastBD, hostClient.lastBDAgencies) } if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "BD_SALARY_USD" { t.Fatalf("bd salary request mismatch: %+v", walletClient.last) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) profile := data["profile"].(map[string]any) salary := data["salary"].(map[string]any) overview := data["overview"].(map[string]any) if profile["display_user_id"] != "163008" || salary["asset_type"] != "BD_SALARY_USD" || salary["display_amount"] != 48620.8 || overview["total_agency"] != float64(1) { t.Fatalf("bd center overview response mismatch: %+v", data) } } func TestBDCenterInviteAgencyCreatesPendingInvitation(t *testing.T) { hostClient := &fakeUserHostClient{ bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001}, inviteAgencyResp: &userv1.InviteAgencyResponse{Invitation: &userv1.RoleInvitation{ InvitationId: 9001, CommandId: "cmd-bd-invite-agency", InvitationType: "agency", Status: "pending", InviterUserId: 42, TargetUserId: 99, AgencyName: "Target Agency", }}, } profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 99: {UserId: 99, DisplayUserId: "163099", Username: "Target Agency"}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/bd/invitations/agency", bytes.NewReader([]byte(`{"command_id":"cmd-bd-invite-agency","target_user_id":"99"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd" || hostClient.lastInviteAgency == nil || hostClient.lastInviteAgency.GetInviterUserId() != 42 || hostClient.lastInviteAgency.GetTargetUserId() != 99 || hostClient.lastInviteAgency.GetAgencyName() != "Target Agency" { t.Fatalf("bd invite agency request mismatch: %+v", hostClient.lastInviteAgency) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) invitation := data["invitation"].(map[string]any) if invitation["status"] != "pending" || invitation["target_user_id"] != "99" { t.Fatalf("bd invite response mismatch: %+v", data) } } func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) { hostClient := &fakeUserHostClient{ bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001}, inviteBDResp: &userv1.InviteBDResponse{Invitation: &userv1.RoleInvitation{ InvitationId: 9002, CommandId: "cmd-bd-self", InvitationType: "bd", Status: "pending", InviterUserId: 42, InviterBdUserId: 42, TargetUserId: 42, ParentLeaderUserId: 42, }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/bd-leader/invitations/bd", bytes.NewReader([]byte(`{"command_id":"cmd-bd-self","target_user_id":"42"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-bd-self") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd_leader" || hostClient.lastInviteBD == nil || hostClient.lastInviteBD.GetInviterUserId() != 42 || hostClient.lastInviteBD.GetTargetUserId() != 42 { t.Fatalf("self invite must be forwarded to user-service: %+v", hostClient.lastInviteBD) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) invitation := data["invitation"].(map[string]any) if invitation["status"] != "pending" || invitation["target_user_id"] != "42" || invitation["parent_leader_user_id"] != "42" { t.Fatalf("bd self invite response mismatch: %+v", data) } } func TestAgencyCenterInviteHostCreatesPendingInvitation(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}}, inviteHostResp: &userv1.InviteHostResponse{Invitation: &userv1.RoleInvitation{ InvitationId: 9010, CommandId: "cmd-host-invite", InvitationType: "host", Status: "pending", InviterUserId: 42, TargetUserId: 100, AgencyName: "Voice Agency", }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/invitations/host", bytes.NewReader([]byte(`{"command_id":"cmd-host-invite","target_user_id":"100"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastInviteHost == nil || hostClient.lastInviteHost.GetInviterUserId() != 42 || hostClient.lastInviteHost.GetTargetUserId() != 100 { t.Fatalf("host invite request mismatch: %+v", hostClient.lastInviteHost) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } invitation := response.Data.(map[string]any)["invitation"].(map[string]any) if invitation["status"] != "pending" || invitation["invitation_type"] != "host" { t.Fatalf("host invite response mismatch: %+v", response.Data) } } func TestRoleInvitationRegionMismatchEnvelopeUsesConcreteMessage(t *testing.T) { regionErr := xerr.ToGRPCError(xerr.New(xerr.RegionMismatch, "target region does not match inviter region")) tests := []struct { name string path string body string hostSetup func() *fakeUserHostClient reached func(*fakeUserHostClient) bool }{ { name: "agency_owner_invite_host", path: "/api/v1/agency-center/invitations/host", body: `{"command_id":"cmd-host-region","target_user_id":"100"}`, hostSetup: func() *fakeUserHostClient { return &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}}, inviteHostErr: regionErr, } }, reached: func(client *fakeUserHostClient) bool { return client.lastInviteHost != nil }, }, { name: "bd_invite_agency", path: "/api/v1/bd/invitations/agency", body: `{"command_id":"cmd-agency-region","target_user_id":"100","agency_name":"Target Agency"}`, hostSetup: func() *fakeUserHostClient { return &fakeUserHostClient{ bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001}, inviteAgencyErr: regionErr, } }, reached: func(client *fakeUserHostClient) bool { return client.lastInviteAgency != nil }, }, { name: "bd_leader_invite_bd", path: "/api/v1/bd-leader/invitations/bd", body: `{"command_id":"cmd-bd-region","target_user_id":"100"}`, hostSetup: func() *fakeUserHostClient { return &fakeUserHostClient{ bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001}, inviteBDErr: regionErr, } }, reached: func(client *fakeUserHostClient) bool { return client.lastInviteBD != nil }, }, { name: "target_accept_invitation_after_region_changed", path: "/api/v1/role-invitations/9011/process", body: `{"command_id":"cmd-accept-region","action":"accept"}`, hostSetup: func() *fakeUserHostClient { return &fakeUserHostClient{processInviteErr: regionErr} }, reached: func(client *fakeUserHostClient) bool { return client.lastProcessInvite != nil }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { hostClient := test.hostSetup() handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusForbidden, string(xerr.RegionMismatch), "不是同一个地区", "req-"+test.name) if !test.reached(hostClient) { t.Fatalf("route did not reach role-invitation RPC for %s", test.name) } }) } } func TestRoleInvitationsListAndProcessForwardCurrentUser(t *testing.T) { hostClient := &fakeUserHostClient{ roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{ InvitationId: 9011, InvitationType: "agency", Status: "pending", InviterUserId: 77, TargetUserId: 42, AgencyName: "Target Agency", CreatedAtMs: 1700000000000, }}}, processInviteResp: &userv1.ProcessRoleInvitationResponse{ Invitation: &userv1.RoleInvitation{ InvitationId: 9011, InvitationType: "agency", Status: "accepted", InviterUserId: 77, TargetUserId: 42, }, Agency: &userv1.Agency{AgencyId: 7008, OwnerUserId: 42, Status: "active", Name: "Target Agency"}, Membership: &userv1.AgencyMembership{MembershipId: 3008, AgencyId: 7008, HostUserId: 42, MembershipType: "owner", Status: "active"}, }, } profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 77: {UserId: 77, DisplayUserId: "163077", Username: "Leader Rose"}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/role-invitations?status=pending", nil) listRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) listRecorder := httptest.NewRecorder() router.ServeHTTP(listRecorder, listRequest) if listRecorder.Code != http.StatusOK { t.Fatalf("list status mismatch: got %d body=%s", listRecorder.Code, listRecorder.Body.String()) } if hostClient.lastRoleInvitations == nil || hostClient.lastRoleInvitations.GetTargetUserId() != 42 || hostClient.lastRoleInvitations.GetStatus() != "pending" { t.Fatalf("list invitations request mismatch: %+v", hostClient.lastRoleInvitations) } processRequest := httptest.NewRequest(http.MethodPost, "/api/v1/role-invitations/9011/process", bytes.NewReader([]byte(`{"command_id":"cmd-accept-9011","action":"accept"}`))) processRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) processRecorder := httptest.NewRecorder() router.ServeHTTP(processRecorder, processRequest) if processRecorder.Code != http.StatusOK { t.Fatalf("process status mismatch: got %d body=%s", processRecorder.Code, processRecorder.Body.String()) } if hostClient.lastProcessInvite == nil || hostClient.lastProcessInvite.GetActorUserId() != 42 || hostClient.lastProcessInvite.GetInvitationId() != 9011 || hostClient.lastProcessInvite.GetAction() != "accept" { t.Fatalf("process invitation request mismatch: %+v", hostClient.lastProcessInvite) } } func TestAgencyCenterOverviewRejectsNonOwner(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 99, Status: "active"}}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) handler.SetWalletClient(&fakeWalletClient{}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/overview", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != httpkit.CodePermissionDenied || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { t.Fatalf("non-owner response mismatch: response=%+v getAgency=%+v", response, hostClient.lastGetAgency) } } func TestAgencyCenterHostsReturnsActiveHostSalary(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}}, membersResp: &userv1.GetAgencyMembersResponse{Memberships: []*userv1.AgencyMembership{ {MembershipId: 1, AgencyId: 7001, HostUserId: 42, MembershipType: "owner", Status: "active", JoinedAtMs: 10}, {MembershipId: 2, AgencyId: 7001, HostUserId: 100, MembershipType: "host", Status: "active", JoinedAtMs: 20}, {MembershipId: 3, AgencyId: 7001, HostUserId: 101, MembershipType: "host", Status: "active", JoinedAtMs: 30}, }}, } walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{ 100: {Balances: []*walletv1.AssetBalance{{AssetType: "HOST_SALARY_USD", AvailableAmount: 305025}}}, 101: {Balances: []*walletv1.AssetBalance{{AssetType: "HOST_SALARY_USD", AvailableAmount: 428050}}}, }} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 100: {UserId: 100, DisplayUserId: "163100", Username: "Lina_voice", Avatar: "https://cdn.example/lina.png"}, 101: {UserId: 101, DisplayUserId: "163101", Username: "Nora_live", Avatar: "https://cdn.example/nora.png"}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetUserHostClient(hostClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/hosts", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.lastMembers == nil || hostClient.lastMembers.GetAgencyId() != 7001 || hostClient.lastMembers.GetStatus() != "active" { t.Fatalf("members request mismatch: %+v", hostClient.lastMembers) } if len(walletClient.balanceRequests) != 2 || walletClient.balanceRequests[0].GetUserId() != 100 || walletClient.balanceRequests[1].GetUserId() != 101 { t.Fatalf("host salary requests mismatch: %+v", walletClient.balanceRequests) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) items := data["items"].([]any) first := items[0].(map[string]any) salary := first["salary"].(map[string]any) if data["total"] != float64(2) || first["host_user_id"] != "100" || first["display_user_id"] != "163100" || first["username"] != "Lina_voice" || salary["display_amount"] != 3050.25 { t.Fatalf("hosts response mismatch: %+v", data) } } func TestAgencyCenterReviewAndRemoveForwardOwnerCommands(t *testing.T) { hostClient := &fakeUserHostClient{ roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001}, getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}}, reviewResp: &userv1.ReviewAgencyApplicationResponse{Application: &userv1.AgencyApplication{ ApplicationId: 9001, AgencyId: 7001, Status: "approved", }}, kickResp: &userv1.KickAgencyHostResponse{Membership: &userv1.AgencyMembership{ MembershipId: 2, AgencyId: 7001, HostUserId: 100, Status: "ended", }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetUserHostClient(hostClient) handler.SetWalletClient(&fakeWalletClient{}) router := handler.Routes(auth.NewVerifier("secret")) reviewRequest := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/applications/9001/review", bytes.NewReader([]byte(`{"command_id":"cmd-review","decision":"approve","reason":"ok"}`))) reviewRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) reviewRecorder := httptest.NewRecorder() router.ServeHTTP(reviewRecorder, reviewRequest) if reviewRecorder.Code != http.StatusOK { t.Fatalf("review status mismatch: got %d body=%s", reviewRecorder.Code, reviewRecorder.Body.String()) } if hostClient.lastReview == nil || hostClient.lastReview.GetReviewerUserId() != 42 || hostClient.lastReview.GetApplicationId() != 9001 || hostClient.lastReview.GetCommandId() != "cmd-review" || hostClient.lastReview.GetDecision() != "approve" { t.Fatalf("review request mismatch: %+v", hostClient.lastReview) } removeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/hosts/100/remove", bytes.NewReader([]byte(`{"command_id":"cmd-remove","reason":"agency_center"}`))) removeRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) removeRecorder := httptest.NewRecorder() router.ServeHTTP(removeRecorder, removeRequest) if removeRecorder.Code != http.StatusOK { t.Fatalf("remove status mismatch: got %d body=%s", removeRecorder.Code, removeRecorder.Body.String()) } if hostClient.lastKick == nil || hostClient.lastKick.GetOperatorUserId() != 42 || hostClient.lastKick.GetAgencyId() != 7001 || hostClient.lastKick.GetHostUserId() != 100 || hostClient.lastKick.GetCommandId() != "cmd-remove" { t.Fatalf("remove request mismatch: %+v", hostClient.lastKick) } } func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) { client := &fakeRoomClient{} router := NewHandler(client).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","command_id":"cmd-confirm","target_user_id":43,"mic_session_id":"mic-session-1","room_version":12,"event_time_ms":1700000001234,"source":"client"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/publishing/confirm", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-confirm-mic") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-confirm-mic") if client.lastConfirmMic == nil { t.Fatal("ConfirmMicPublishing request was not sent") } if client.lastConfirmMic.GetTargetUserId() != 43 || client.lastConfirmMic.GetMicSessionId() != "mic-session-1" || client.lastConfirmMic.GetRoomVersion() != 12 || client.lastConfirmMic.GetEventTimeMs() != 1700000001234 || client.lastConfirmMic.GetSource() != "client" { t.Fatalf("confirm fields mismatch: %+v", client.lastConfirmMic) } } func TestMicHeartbeatForwardsCurrentSession(t *testing.T) { client := &fakeRoomClient{} router := NewHandler(client).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","command_id":"cmd-mic-heartbeat","target_user_id":43,"mic_session_id":"mic-session-1"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/heartbeat", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-mic-heartbeat") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-mic-heartbeat") if client.lastMicHeartbeat == nil { t.Fatal("MicHeartbeat request was not sent") } if client.lastMicHeartbeat.GetTargetUserId() != 43 || client.lastMicHeartbeat.GetMicSessionId() != "mic-session-1" { t.Fatalf("mic heartbeat fields mismatch: %+v", client.lastMicHeartbeat) } } func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","username":"hy","gender":"male","country":"CN","invite_code":"INV-1","device_id":"dev-1","device":"iPhone 15","os_version":"iOS 18.1","avatar":"https://cdn.example/avatar.png","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-auth-third") request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1") request.Header.Set("X-Real-IP", "129.226.91.24") request.Header.Set("User-Agent", "hyapp-test/1.0") request.Header.Set("CF-IPCountry", "sg") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } generatedRequestID := assertGeneratedRequestID(t, recorder, "req-auth-third") if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID { t.Fatalf("unexpected envelope: %+v", response) } req := userClient.lastLoginThirdParty if req == nil { t.Fatal("LoginThirdParty request was not sent") } if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" { t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta()) } if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" { t.Fatalf("third-party registration fields were not propagated: %+v", req) } if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" { t.Fatalf("Firebase provider and ID token credential were not propagated: %+v", req) } if req.GetDevice() != "iPhone 15" || req.GetOsVersion() != "iOS 18.1" || req.GetAvatar() == "" || req.GetBirth() != "2000-01-02" || req.GetAppVersion() != "1.2.3" || req.GetBuildNumber() != "123" { t.Fatalf("third-party device/profile fields were not propagated: %+v", req) } if req.GetSource() != "campaign-a" || req.GetInstallChannel() != "app_store" || req.GetCampaign() != "spring_launch" || req.GetPlatform() != "ios" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" { t.Fatalf("third-party profile fields were not propagated: %+v", req) } } func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","username":"hy","avatar":"https://cdn.example/avatar.png","gender":"male","country":"CN","invite_code":"INV-1","device":"iPhone 15","os_version":"iOS 18.1","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/register", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-auth-register") request.Header.Set("X-Forwarded-For", "203.0.113.10") request.Header.Set("User-Agent", "hyapp-test/1.0") request.Header.Set("CF-IPCountry", "sg") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } generatedRequestID := assertGeneratedRequestID(t, recorder, "req-auth-register") if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID { t.Fatalf("unexpected envelope: %+v", response) } data, ok := response.Data.(map[string]any) if !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" || data["access_token"] != "access-register" { t.Fatalf("register must return completed token data: %+v", response.Data) } req := userClient.lastRegisterThird if req == nil { t.Fatal("RegisterThirdParty request was not sent") } if userClient.lastLoginThirdParty != nil { t.Fatalf("register endpoint must not call LoginThirdParty: %+v", userClient.lastLoginThirdParty) } if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetCountryByIp() != "SG" { t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta()) } if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" || req.GetDeviceId() != "dev-1" { t.Fatalf("provider credential device fields mismatch: %+v", req) } if req.GetUsername() != "hy" || req.GetAvatar() == "" || req.GetGender() != "male" || req.GetCountry() != "CN" || req.GetInviteCode() != "INV-1" { t.Fatalf("profile fields mismatch: %+v", req) } if req.GetPlatform() != "ios" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" || req.GetInstallChannel() != "app_store" || req.GetCampaign() != "spring_launch" { t.Fatalf("source fields mismatch: %+v", req) } } func TestCheckThirdPartyRegisteredOnlyReturnsRegisteredFlag(t *testing.T) { userClient := &fakeUserAuthClient{checkRegistered: true} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/registered/check", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-auth-check") request.Header.Set("X-Forwarded-For", "203.0.113.10") request.Header.Set("User-Agent", "hyapp-test/1.0") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["registered"] != true { t.Fatalf("unexpected registered check response: %+v", response) } req := userClient.lastCheckThird if req == nil { t.Fatal("CheckThirdPartyRegistered request was not sent") } if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" { t.Fatalf("provider credential mismatch: %+v", req) } if userClient.lastLoginThirdParty != nil || userClient.lastRegisterThird != nil { t.Fatalf("check endpoint must not login or register: login=%+v register=%+v", userClient.lastLoginThirdParty, userClient.lastRegisterThird) } } func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil) request.RemoteAddr = "10.0.0.9:54123" request.Header.Set("X-Forwarded-For", "114.8.228.33, 10.0.0.1") request.Header.Set("X-Real-IP", "129.226.91.24") if got := clientIP(request); got != "114.8.228.33" { t.Fatalf("clientIP must use the first public X-Forwarded-For address before proxy X-Real-IP, got %q", got) } } func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.ProviderIPLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","platform":"ios"}`) for i := range 2 { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-third-rate") request.Header.Set("X-Forwarded-For", "203.0.113.10") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-third-rate") } } if userClient.loginThirdCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.loginThirdCount) } } func TestAccountLoginRateLimitByAccount(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.DisplayUserIDIPLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"100001","password":"bad","device_id":"dev-1"}`) for i := range 2 { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-password-rate") request.Header.Set("X-Forwarded-For", "203.0.113.20") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-password-rate") } } if userClient.loginPasswordCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.loginPasswordCount) } } func TestAccountLoginAcceptsAccountField(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-account-login") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-account-login") if userClient.lastLoginPassword == nil || userClient.lastLoginPassword.GetDisplayUserId() != "123456" { t.Fatalf("account was not propagated as display_user_id: %+v", userClient.lastLoginPassword) } if userClient.lastLoginPassword.GetPassword() != "1234567" || userClient.lastLoginPassword.GetMeta().GetDeviceId() != "ios" { t.Fatalf("password login payload mismatch: %+v", userClient.lastLoginPassword) } if userClient.lastLoginPassword.GetMeta().GetPlatform() != "ios" || userClient.lastLoginPassword.GetMeta().GetLanguage() != "en-US" || userClient.lastLoginPassword.GetMeta().GetTimezone() != "America/Los_Angeles" { t.Fatalf("password login risk meta mismatch: %+v", userClient.lastLoginPassword.GetMeta()) } } func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"password":"secret-pass"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/quick-create", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-quick-create") request.Header.Set("X-App-Code", "lalu") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != httpkit.CodeOK || response.RequestID != assertGeneratedRequestID(t, recorder, "req-quick-create") { t.Fatalf("unexpected envelope: %+v", response) } req := userClient.lastQuickCreate if req == nil { t.Fatal("QuickCreateAccount request was not sent") } if req.GetPassword() != "secret-pass" || req.GetUsername() == "" || req.GetAvatar() == "" { t.Fatalf("quick create profile defaults were not populated: %+v", req) } if req.GetCountry() != "SG" || req.GetGender() != "unknown" || req.GetDeviceId() == "" { t.Fatalf("quick create required defaults mismatch: %+v", req) } if req.GetPlatform() != "android" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" { t.Fatalf("quick create login context mismatch: %+v", req) } if req.GetMeta().GetAppCode() != "lalu" || req.GetMeta().GetDeviceId() != req.GetDeviceId() { t.Fatalf("quick create meta mismatch: %+v", req.GetMeta()) } data, ok := response.Data.(map[string]any) if !ok || data["uid"] != "100001" || data["account"] != "100001" || data["access_token"] != "access-quick" || data["password_set"] != true { t.Fatalf("quick create response mismatch: %+v", response.Data) } } func TestLoginRiskFastGuardBlocksLanguage(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetLoginRisk(LoginRiskConfig{ Enabled: true, FastGuard: LoginRiskFastGuardConfig{ BlockedLanguagePrimaryTags: []string{"zh"}, }, }) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"zh-CN","timezone":"America/Los_Angeles"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-login-risk-language") request.Header.Set("X-Forwarded-For", "203.0.113.50") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "req-login-risk-language") if userClient.loginPasswordCount != 0 { t.Fatalf("blocked login must not reach LoginPassword, count=%d", userClient.loginPasswordCount) } if userClient.blockedCount != 1 || userClient.lastBlocked.GetBlockReason() != loginRiskBlockLanguage { t.Fatalf("blocked audit mismatch: count=%d req=%+v", userClient.blockedCount, userClient.lastBlocked) } } func TestLoginRiskIPWhitelistBypassesFastGuard(t *testing.T) { userClient := &fakeUserAuthClient{whitelisted: true} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetLoginRisk(LoginRiskConfig{ Enabled: true, FastGuard: LoginRiskFastGuardConfig{ BlockedLanguagePrimaryTags: []string{"zh"}, }, }) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"zh-CN","timezone":"America/Los_Angeles"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-login-risk-whitelist") request.Header.Set("X-Forwarded-For", "203.0.113.52") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-login-risk-whitelist") if userClient.loginPasswordCount != 1 || userClient.blockedCount != 0 { t.Fatalf("whitelisted IP must bypass fast guard: login=%d blocked=%d", userClient.loginPasswordCount, userClient.blockedCount) } if userClient.lastWhitelist == nil || userClient.lastWhitelist.GetClientIp() != "203.0.113.52" { t.Fatalf("whitelist check request mismatch: %+v", userClient.lastWhitelist) } } func TestLoginRiskIPCacheBlocksLogin(t *testing.T) { userClient := &fakeUserAuthClient{} cache := newMemoryLoginRiskCache() cache.setIPDecision("lalu", "203.0.113.51", loginRiskDecision{Decision: "blocked", Country: "CN"}) handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"google","credential":"token","device_id":"dev-1","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-login-risk-cache") request.Header.Set("X-Forwarded-For", "203.0.113.51") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "req-login-risk-cache") if userClient.loginThirdCount != 0 { t.Fatalf("blocked login must not reach LoginThirdParty, count=%d", userClient.loginThirdCount) } if userClient.lastBlocked.GetBlockReason() != loginRiskBlockIPCache { t.Fatalf("blocked audit reason mismatch: %+v", userClient.lastBlocked) } } func TestSessionDenylistRejectsAuthenticatedRequest(t *testing.T) { cache := newMemoryLoginRiskCache() cache.setRevokedSession("lalu", "sess-test") handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-session-revoked") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.SessionRevoked), "req-session-revoked") } func TestRefreshRateLimitByDeviceID(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.DeviceIDLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1"}`) for i := range 2 { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-refresh-rate") request.Header.Set("X-Forwarded-For", "203.0.113.30") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-refresh-rate") } } if userClient.refreshCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.refreshCount) } } func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) { userClient := &fakeUserAuthClient{} handler := NewHandler(&fakeRoomClient{}, userClient) handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) { config.SessionIDIPLimit = 1 })) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"session_id":"sess-1","refresh_token":"refresh-1"}`) for i := range 2 { request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-logout-rate") request.Header.Set("X-Forwarded-For", "203.0.113.40") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if i == 0 && recorder.Code != http.StatusOK { t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String()) } if i == 1 { assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-logout-rate") } } if userClient.logoutCount != 1 { t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.logoutCount) } } func TestSetPasswordRequiresTokenAndPropagatesActorUserID(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"password":"secret-pass"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/set", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-set-password") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if userClient.lastSetPassword == nil || userClient.lastSetPassword.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", userClient.lastSetPassword) } if userClient.lastSetPassword.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-set-password") { t.Fatalf("request_id was not propagated: %+v", userClient.lastSetPassword.GetMeta()) } } func TestUpdateProfileUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"female","birth":"2000-01-02","profile_bg_img":"https://cdn.example/bg.png","user_id":99}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile/update", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastUpdate == nil || profileClient.lastUpdate.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastUpdate) } if profileClient.lastUpdate.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile") || valueOfString(profileClient.lastUpdate.Username) != "hy" || valueOfString(profileClient.lastUpdate.Gender) != "female" || valueOfString(profileClient.lastUpdate.Birth) != "2000-01-02" { t.Fatalf("profile update request mismatch: %+v", profileClient.lastUpdate) } if profileClient.lastBackground != nil { t.Fatalf("profile update must not modify profile_bg_img: %+v", profileClient.lastBackground) } } func TestUpdateProfileBackgroundUsesAuthenticatedUserIDAndURL(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"url":"https://cdn.example/profile-bg.png","user_id":99}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile-bg-img", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile-bg") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastBackground == nil || profileClient.lastBackground.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated to profile background update: %+v", profileClient.lastBackground) } if profileClient.lastBackground.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-bg") || profileClient.lastBackground.GetProfileBgImg() != "https://cdn.example/profile-bg.png" { t.Fatalf("profile background request mismatch: %+v", profileClient.lastBackground) } var response httpkit.ResponseEnvelope if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" { t.Fatalf("profile background response mismatch: %+v", response) } } func TestUpdateContactInfoUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"whatsapp_phone":"+65 1234 5678","user_id":99}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/contact-info", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-contact") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastContact == nil || profileClient.lastContact.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated to contact update: %+v", profileClient.lastContact) } if profileClient.lastContact.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-contact") || profileClient.lastContact.GetContactInfo() != "+65 1234 5678" { t.Fatalf("contact update request mismatch: %+v", profileClient.lastContact) } var response httpkit.ResponseEnvelope if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["contact_info"] != "+65 1234 5678" || data["whatsapp_phone"] != "+65 1234 5678" { t.Fatalf("contact update response mismatch: %+v", response) } } func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: { UserId: 42, DisplayUserId: "100001", Username: "hy", Gender: "female", ProfileCompleted: true, OnboardingStatus: "completed", CreatedAtMs: 1_770_000_000_000, ProfileBgImg: "https://cdn.example/profile-bg.png", }}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile-get") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastGet) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" || data["created_at_ms"] != float64(1_770_000_000_000) || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" { t.Fatalf("profile response mismatch: %+v", response) } } func TestGetMyInviteOverviewUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/invite-overview", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-invite-overview") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastGet) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["my_invite_code"] != "A1B2C3" || data["invite_enabled"] != true || data["invite_count"] != float64(12) || data["valid_invite_count"] != float64(3) { t.Fatalf("invite overview response mismatch: %+v", response) } } func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) { messageClient := &fakeMessageInboxClient{tabsResp: &activityv1.ListMessageTabsResponse{Sections: []*activityv1.MessageTabSection{ {Section: "user", Title: "用户", UnreadCount: 0, Source: "tencent_im_sdk"}, {Section: "system", Title: "系统", UnreadCount: 2, Source: "backend"}, {Section: "activity", Title: "活动", UnreadCount: 1, Source: "backend"}, }}, listResp: &activityv1.ListInboxMessagesResponse{ Section: "system", Items: []*activityv1.InboxMessage{{ MessageId: "msg-1", Title: "提现审核通过", Summary: "你的提现申请已通过。", ActionType: "wallet_withdraw_detail", ActionParam: "withdrawal_id=wd_01", SentAtMs: 1777996800000, }}, NextPageToken: "cursor-2", }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetMessageInboxClient(messageClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/messages/tabs", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-message-tabs") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetAppCode() != "lalu" || messageClient.lastTabs.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-message-tabs") { t.Fatalf("message tabs request mismatch: %+v", messageClient.lastTabs) } request = httptest.NewRequest(http.MethodGet, "/api/v1/messages?section=system&page_size=20&page_token=cursor-1", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-message-list") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("list status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if messageClient.lastList == nil || messageClient.lastList.GetUserId() != 42 || messageClient.lastList.GetSection() != "system" || messageClient.lastList.GetPageSize() != 20 || messageClient.lastList.GetPageToken() != "cursor-1" { t.Fatalf("message list request mismatch: %+v", messageClient.lastList) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode list response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" { t.Fatalf("message list envelope mismatch: %+v", response) } } func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) { taskClient := &fakeTaskClient{listResp: &activityv1.ListUserTasksResponse{ Sections: []*activityv1.TaskSection{{ Section: "daily", Items: []*activityv1.TaskItem{{ TaskId: "daily-send-message", TaskType: "daily", Category: "interaction", MetricType: "send_message", Title: "发消息", TargetValue: 1, TargetUnit: "次", ProgressValue: 1, RewardCoinAmount: 10, Status: "claimable", Claimable: true, TaskDay: "2026-05-09", ServerTimeMs: 1778256000000, NextRefreshAtMs: 1778342400000, SortOrder: 10, Version: 2, }}, ServerTimeMs: 1778256000000, NextRefreshAtMs: 1778342400000, }}, ServerTimeMs: 1778256000000, NextRefreshAtMs: 1778342400000, }, claimResp: &activityv1.ClaimTaskRewardResponse{ ClaimId: "claim-1", TaskId: "daily-send-message", TaskType: "daily", TaskDay: "2026-05-09", RewardCoinAmount: 10, Status: "granted", WalletTransactionId: "wtx-1", GrantedAtMs: 1778256000100, Claimed: true, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetTaskClient(taskClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/tabs", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-task-tabs") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("task tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if taskClient.lastList == nil || taskClient.lastList.GetUserId() != 42 || taskClient.lastList.GetMeta().GetAppCode() != "lalu" || taskClient.lastList.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-task-tabs") { t.Fatalf("task tabs request mismatch: %+v", taskClient.lastList) } request = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim", bytes.NewReader([]byte(`{"task_id":"daily-send-message","task_type":"daily","task_day":"2026-05-09","command_id":"cmd-task-claim"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-task-claim") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("task claim status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if taskClient.lastClaim == nil || taskClient.lastClaim.GetUserId() != 42 || taskClient.lastClaim.GetTaskId() != "daily-send-message" || taskClient.lastClaim.GetTaskType() != "daily" || taskClient.lastClaim.GetTaskDay() != "2026-05-09" || taskClient.lastClaim.GetCommandId() != "cmd-task-claim" { t.Fatalf("task claim request mismatch: %+v", taskClient.lastClaim) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode task claim response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["task_id"] != "daily-send-message" || data["status"] != "granted" || data["wallet_transaction_id"] != "wtx-1" { t.Fatalf("task claim envelope mismatch: %+v", response) } } func TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) { badgeResource := &walletv1.Resource{ ResourceId: 8001, ResourceCode: "badge_first_gift", ResourceType: "badge", Name: "First Gift", Status: "active", Grantable: true, GrantStrategy: "new_entitlement", UsageScopes: []string{"badge"}, AssetUrl: "https://cdn.example/badge.png", MetadataJson: `{"badge_form":"tile"}`, SortOrder: 10, } achievementClient := &fakeAchievementClient{ listResp: &activityv1.ListAchievementsResponse{ Achievements: []*activityv1.UserAchievement{{ Definition: &activityv1.AchievementDefinition{ AchievementId: "ach_first_gift", CollectionId: "ordinary", CollectionType: "ordinary", AchievementType: "ordinary", Title: "First Gift", Status: "active", Version: 1, PrimaryBadgeResourceId: 8001, RewardResourceGroupId: 9001, }, CycleKey: "lifetime", ProgressValue: 1, TargetValue: 1, UserStatus: "unlocked", RewardStatus: "granted", }}, Total: 1, ServerTimeMs: 1778256000000, }, badgesResp: &activityv1.ListMyBadgesResponse{ ProfileTileBadges: []*activityv1.BadgeDisplayItem{{ Slot: "profile_tile", Position: 1, BadgeForm: "tile", ResourceId: 8001, SourceType: "achievement", SourceId: "ach_first_gift", PinMode: "user", }}, ServerTimeMs: 1778256000000, }, } walletClient := &fakeWalletClient{ resourcesByID: map[int64]*walletv1.Resource{8001: badgeResource}, resourceGroupsByID: map[int64]*walletv1.ResourceGroup{9001: { GroupId: 9001, GroupCode: "ach_first_gift_reward", Name: "First Gift Reward", Status: "active", Items: []*walletv1.ResourceGroupItem{{ GroupItemId: 1, ItemType: "resource", ResourceId: 8001, Resource: badgeResource, Quantity: 1, SortOrder: 10, }}, }}, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAchievementClient(achievementClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/achievements?page=2&page_size=5", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("achievement status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var achievementEnvelope struct { Code string `json:"code"` Data struct { Achievements []struct { Definition struct { PrimaryBadgeResource struct { ResourceID int64 `json:"resource_id"` AssetURL string `json:"asset_url"` } `json:"primary_badge_resource"` RewardResourceGroup struct { GroupID int64 `json:"group_id"` Items []struct { Resource struct { ResourceID int64 `json:"resource_id"` AssetURL string `json:"asset_url"` } `json:"resource"` } `json:"items"` } `json:"reward_resource_group"` } `json:"definition"` } `json:"achievements"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&achievementEnvelope); err != nil { t.Fatalf("decode achievement response failed: %v", err) } if achievementClient.lastList == nil || achievementClient.lastList.GetUserId() != 42 || achievementClient.lastList.GetPage() != 2 || achievementClient.lastList.GetPageSize() != 5 { t.Fatalf("achievement request mismatch: %+v", achievementClient.lastList) } firstDefinition := achievementEnvelope.Data.Achievements[0].Definition if achievementEnvelope.Code != httpkit.CodeOK || firstDefinition.PrimaryBadgeResource.AssetURL != "https://cdn.example/badge.png" || firstDefinition.RewardResourceGroup.GroupID != 9001 || firstDefinition.RewardResourceGroup.Items[0].Resource.ResourceID != 8001 { t.Fatalf("achievement response must embed resource materials: %+v", achievementEnvelope) } request = httptest.NewRequest(http.MethodGet, "/api/v1/badges/me", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("badge status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var badgeEnvelope struct { Code string `json:"code"` Data struct { ProfileTileBadges []struct { ResourceID int64 `json:"resource_id"` Resource struct { ResourceID int64 `json:"resource_id"` AssetURL string `json:"asset_url"` } `json:"resource"` } `json:"profile_tile_badges"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&badgeEnvelope); err != nil { t.Fatalf("decode badge response failed: %v", err) } if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 { t.Fatalf("badge request mismatch: %+v", achievementClient.lastBadges) } if badgeEnvelope.Code != httpkit.CodeOK || len(badgeEnvelope.Data.ProfileTileBadges) != 1 || badgeEnvelope.Data.ProfileTileBadges[0].Resource.AssetURL != "https://cdn.example/badge.png" { t.Fatalf("badge response must embed resource materials: %+v", badgeEnvelope) } } func TestAchievementListAllowsExplicitUserIDWithoutToken(t *testing.T) { achievementClient := &fakeAchievementClient{ listResp: &activityv1.ListAchievementsResponse{ Total: 0, ServerTimeMs: 1778256000000, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetAchievementClient(achievementClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/achievements?user_id=77&page=3&page_size=6&collection_type=event", nil) request.Header.Set("X-App-Code", "lalu") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("achievement status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if achievementClient.lastList == nil || achievementClient.lastList.GetUserId() != 77 || achievementClient.lastList.GetPage() != 3 || achievementClient.lastList.GetPageSize() != 6 || achievementClient.lastList.GetCollectionType() != "event" { t.Fatalf("achievement user_id request mismatch: %+v", achievementClient.lastList) } achievementClient.lastList = nil request = httptest.NewRequest(http.MethodGet, "/api/v1/achievements?page=1&page_size=6", nil) request.Header.Set("X-App-Code", "lalu") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusUnauthorized { t.Fatalf("anonymous achievement without user_id must stay unauthorized: got %d body=%s", recorder.Code, recorder.Body.String()) } if achievementClient.lastList != nil { t.Fatalf("anonymous achievement without user_id must not reach upstream: %+v", achievementClient.lastList) } } func TestPublicUserProfileRouteUsesPathUserID(t *testing.T) { profileClient := &fakeUserProfileClient{ usersByID: map[int64]*userv1.User{ 77: { UserId: 77, DisplayUserId: "100077", Username: "target-user", Avatar: "https://cdn.example/target.png", }, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-id/77/profile", nil) request.Header.Set("X-App-Code", "lalu") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("public profile status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 77 { t.Fatalf("public profile must use path user_id: %+v", profileClient.lastGet) } } func TestCPWeeklyRankActivityUsesWeeklyScoreAndActivityClient(t *testing.T) { cpWeeklyClient := &fakeCPWeeklyRankClient{ statusResp: &activityv1.GetCPWeeklyRankStatusResponse{ Config: &activityv1.CPWeeklyRankConfig{ ActivityCode: "cp_weekly_rank", Enabled: true, RelationType: "cp", TopCount: 3, }, Entries: []*activityv1.CPWeeklyRankEntry{ { Rank: 1, RelationshipId: "weekly_delta_high", RelationType: "cp", Score: 100, UserA: &activityv1.CPWeeklyRankUser{UserId: 7003, DisplayUserId: "7003", Username: "Weekly A", Avatar: "a.png"}, UserB: &activityv1.CPWeeklyRankUser{UserId: 7004, DisplayUserId: "7004", Username: "Weekly B", Avatar: "b.png"}, FormedAtMs: 1780000000000, UpdatedAtMs: 1780876800000, FirstScoredAtMs: 1780876800000, LastScoredAtMs: 1781481599999, }, { Rank: 2, RelationshipId: "weekly_total_high", RelationType: "cp", Score: 10, UserA: &activityv1.CPWeeklyRankUser{UserId: 7001, DisplayUserId: "7001", Username: "Total A", Avatar: "c.png"}, UserB: &activityv1.CPWeeklyRankUser{UserId: 7002, DisplayUserId: "7002", Username: "Total B", Avatar: "d.png"}, FormedAtMs: 1780000000000, UpdatedAtMs: 1780876800000, FirstScoredAtMs: 1780876800000, LastScoredAtMs: 1780876800000, }, }, PeriodStartMs: 1780876800000, PeriodEndMs: 1781481600000, ServerTimeMs: 1780880000000, }, } userCPClient := &fakeUserCPClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetCPWeeklyRankClient(cpWeeklyClient) handler.SetUserCPClient(userCPClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/cp-weekly-rank?limit=2", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("cp weekly rank status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { Entries []struct { Rank int64 `json:"rank"` RelationshipID string `json:"relationship_id"` Score int64 `json:"score"` } `json:"entries"` Period struct { StartMS int64 `json:"start_ms"` EndMS int64 `json:"end_ms"` } `json:"period"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode cp weekly rank response failed: %v", err) } if cpWeeklyClient.lastStatus == nil || cpWeeklyClient.lastStatus.GetLimit() != 2 || cpWeeklyClient.lastStatus.GetMeta().GetAppCode() != "lalu" { t.Fatalf("cp weekly rank must call activity-service with request meta: %+v", cpWeeklyClient.lastStatus) } if userCPClient.listIntimacyLeaderboardCalls != 0 { t.Fatalf("cp weekly activity must not read ordinary cumulative intimacy leaderboard, calls=%d", userCPClient.listIntimacyLeaderboardCalls) } if envelope.Code != httpkit.CodeOK || len(envelope.Data.Entries) != 2 { t.Fatalf("cp weekly rank envelope mismatch: %+v", envelope) } if envelope.Data.Entries[0].RelationshipID != "weekly_delta_high" || envelope.Data.Entries[0].Score != 100 || envelope.Data.Entries[1].Score != 10 { t.Fatalf("cp weekly rank must expose activity weekly score unchanged: %+v", envelope.Data.Entries) } if envelope.Data.Period.StartMS != 1780876800000 || envelope.Data.Period.EndMS != 1781481600000 { t.Fatalf("cp weekly rank must expose activity period: %+v", envelope.Data.Period) } } func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) { giftResource := &walletv1.Resource{ ResourceId: 7308, ResourceCode: "weekly_star_gift_308", ResourceType: "gift", Name: "Star Ball Gift", Status: "active", AssetUrl: "https://cdn.example/gift-308.svga", PreviewUrl: "https://cdn.example/gift-308.png", SortOrder: 1, } rewardResource := &walletv1.Resource{ ResourceId: 8101, ResourceCode: "weekly_star_frame", ResourceType: "avatar_frame", Name: "Weekly Star Frame", Status: "active", Grantable: true, GrantStrategy: "new_entitlement", WalletAssetType: "FRAME", UsageScopes: []string{"profile"}, AssetUrl: "https://cdn.example/weekly-frame.png", PreviewUrl: "https://cdn.example/weekly-frame-preview.png", SortOrder: 10, } weeklyStarClient := &fakeWeeklyStarClient{ currentResp: &activityv1.GetWeeklyStarCurrentResponse{ Cycle: &activityv1.WeeklyStarCycle{ CycleId: "wstar-test", ActivityCode: "weekly_star", RegionId: 686, Title: "Weekly Star", Status: "active", StartMs: 1780848000000, EndMs: 1781452740000, Gifts: []*activityv1.WeeklyStarGift{{ GiftId: "308", SortOrder: 1, }}, Rewards: []*activityv1.WeeklyStarReward{{ RankNo: 1, ResourceGroupId: 23, }}, }, TopEntries: []*activityv1.WeeklyStarLeaderboardEntry{{ RankNo: 1, UserId: 88, Score: 120000, FirstScoredAtMs: 1780849000000, LastScoredAtMs: 1780849900000, }}, ServerTimeMs: 1780916324006, }, } walletClient := &fakeWalletClient{ listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{ Gifts: []*walletv1.GiftConfig{{ AppCode: "lalu", GiftId: "308", ResourceId: giftResource.GetResourceId(), Resource: giftResource, Status: "active", Name: "Star Ball", SortOrder: 7, CoinPrice: 100000, }}, Total: 1, }, resourceGroupsByID: map[int64]*walletv1.ResourceGroup{23: { GroupId: 23, GroupCode: "weekly_star_top1", Name: "Top 1 Resource Group", Status: "active", Description: "Top 1 reward materials", Items: []*walletv1.ResourceGroupItem{{ GroupItemId: 501, ItemType: "resource", ResourceId: 8101, Resource: rewardResource, Quantity: 1, DurationMs: 7 * 24 * int64(time.Hour/time.Millisecond), SortOrder: 1, }}, }}, } userClient := &fakeUserProfileClient{ regionID: 686, usersByID: map[int64]*userv1.User{ 42: {UserId: 42, DisplayUserId: "163000", Username: "viewer", RegionId: 686}, 88: {UserId: 88, DisplayUserId: "163088", Username: "weekly_ranker", Avatar: "https://cdn.example/ranker.png", RegionId: 686}, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, userClient) handler.SetWeeklyStarClient(weeklyStarClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/weekly-star/current", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("weekly star current status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { Cycle struct { Gifts []struct { GiftID string `json:"gift_id"` Name string `json:"name"` ImageURL string `json:"image_url"` PreviewURL string `json:"preview_url"` Resource struct { ResourceID int64 `json:"resource_id"` PreviewURL string `json:"preview_url"` } `json:"resource"` } `json:"gifts"` Rewards []struct { RankNo int32 `json:"rank_no"` ResourceGroupID int64 `json:"resource_group_id"` ResourceGroup struct { GroupID int64 `json:"group_id"` Name string `json:"name"` Items []struct { ResourceID int64 `json:"resource_id"` Quantity int64 `json:"quantity"` DurationMS int64 `json:"duration_ms"` Resource struct { ResourceID int64 `json:"resource_id"` ResourceType string `json:"resource_type"` Name string `json:"name"` AssetURL string `json:"asset_url"` } `json:"resource"` } `json:"items"` } `json:"resource_group"` } `json:"rewards"` } `json:"cycle"` TopEntries []struct { User struct { Username string `json:"username"` } `json:"user"` } `json:"top_entries"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode weekly star current response failed: %v", err) } if weeklyStarClient.lastCurrent == nil || weeklyStarClient.lastCurrent.GetUserId() != 42 || weeklyStarClient.lastCurrent.GetRegionId() != 686 { t.Fatalf("weekly star current request mismatch: %+v", weeklyStarClient.lastCurrent) } if envelope.Code != httpkit.CodeOK || len(envelope.Data.Cycle.Rewards) != 1 { t.Fatalf("weekly star response missing rewards: %+v", envelope) } if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetAppCode() != "lalu" || !walletClient.lastListGiftConfigs.GetActiveOnly() || walletClient.lastListGiftConfigs.GetPageSize() != 500 { t.Fatalf("weekly star gift preload request mismatch: %+v", walletClient.lastListGiftConfigs) } if len(envelope.Data.Cycle.Gifts) != 1 || envelope.Data.Cycle.Gifts[0].GiftID != "308" || envelope.Data.Cycle.Gifts[0].Name != "Star Ball" || envelope.Data.Cycle.Gifts[0].ImageURL != "https://cdn.example/gift-308.png" || envelope.Data.Cycle.Gifts[0].PreviewURL != "https://cdn.example/gift-308.png" || envelope.Data.Cycle.Gifts[0].Resource.ResourceID != 7308 { t.Fatalf("weekly star gift must embed cover material details: %+v", envelope.Data.Cycle.Gifts) } firstReward := envelope.Data.Cycle.Rewards[0] if firstReward.ResourceGroupID != 23 || firstReward.ResourceGroup.GroupID != 23 || firstReward.ResourceGroup.Name != "Top 1 Resource Group" { t.Fatalf("weekly star reward group mismatch: %+v", firstReward) } if len(firstReward.ResourceGroup.Items) != 1 { t.Fatalf("weekly star reward group items mismatch: %+v", firstReward.ResourceGroup) } firstItem := firstReward.ResourceGroup.Items[0] if firstItem.ResourceID != 8101 || firstItem.Quantity != 1 || firstItem.DurationMS == 0 || firstItem.Resource.ResourceID != 8101 || firstItem.Resource.ResourceType != "avatar_frame" || firstItem.Resource.Name != "Weekly Star Frame" || firstItem.Resource.AssetURL == "" { t.Fatalf("weekly star reward group item must embed resource material: %+v", firstItem) } if len(envelope.Data.TopEntries) != 1 || envelope.Data.TopEntries[0].User.Username != "weekly_ranker" { t.Fatalf("weekly star top entry user mismatch: %+v", envelope.Data.TopEntries) } } func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) { frameResource := &walletv1.Resource{ ResourceId: 8101, ResourceCode: "frame_gold", ResourceType: "avatar_frame", Name: "Gold Frame", AssetUrl: "https://cdn.example/frame.png", AnimationUrl: "https://cdn.example/frame.svga", MetadataJson: `{"level":1}`, GrantStrategy: "set_active_flag", Status: "active", } vehicleResource := &walletv1.Resource{ ResourceId: 8102, ResourceCode: "vehicle_gold", ResourceType: "vehicle", Name: "Gold Vehicle", AssetUrl: "https://cdn.example/vehicle.png", AnimationUrl: "https://cdn.example/vehicle.svga", GrantStrategy: "set_active_flag", Status: "active", } profileCardResource := &walletv1.Resource{ ResourceId: 8104, ResourceCode: "profile_card_gold", ResourceType: "profile_card", Name: "Gold Profile Card", AssetUrl: "https://cdn.example/profile-card.png", AnimationUrl: "https://cdn.example/profile-card.mp4", MetadataJson: `{"profile_card_layout":{"source_width":1136,"source_height":1680,"content_top":117,"content_bottom":1666,"content_height":1550}}`, GrantStrategy: "extend_expiry", Status: "active", } badgeResource := &walletv1.Resource{ ResourceId: 8103, ResourceCode: "badge_host", ResourceType: "badge", Name: "Host Badge", AssetUrl: "https://cdn.example/badge.png", GrantStrategy: "set_active_flag", Status: "active", } micSeatResource := &walletv1.Resource{ ResourceId: 8106, ResourceCode: "mic_seat_wave_gold", ResourceType: "mic_seat_animation", Name: "Gold Mic Wave", AssetUrl: "https://cdn.example/mic-seat.png", AnimationUrl: "https://cdn.example/mic-seat.svga", MetadataJson: `{"usage":"mic_seat"}`, GrantStrategy: "set_active_flag", Status: "active", } walletClient := &fakeWalletClient{ batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{ Users: []*walletv1.UserEquippedResources{{ UserId: 42, Resources: []*walletv1.UserResourceEntitlement{ {UserId: 42, EntitlementId: "ent-frame", ResourceId: 8101, Resource: frameResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-mic-seat-wave", ResourceId: 8106, Resource: micSeatResource, Equipped: true, ExpiresAtMs: 1999999999999}, }, }}, }, resourcesByID: map[int64]*walletv1.Resource{8103: badgeResource}, } achievementClient := &fakeAchievementClient{ badgesResp: &activityv1.ListMyBadgesResponse{ StripBadges: []*activityv1.BadgeDisplayItem{{ Slot: "strip", Position: 1, BadgeForm: "icon", ResourceId: 8103, EntitlementId: "ent-badge", SourceType: "achievement", SourceId: "ach-host", PinMode: "auto", UpdatedAtMs: 1778256000000, }}, ServerTimeMs: 1778256000000, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) handler.SetAchievementClient(achievementClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/appearance", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("appearance status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { UserID string `json:"user_id"` AvatarFrame struct { ResourceID int64 `json:"resource_id"` AnimationURL string `json:"animation_url"` } `json:"avatar_frame"` Vehicle struct { ResourceID int64 `json:"resource_id"` AnimationURL string `json:"animation_url"` } `json:"vehicle"` ProfileCard struct { ResourceID int64 `json:"resource_id"` MetadataJSON string `json:"metadata_json"` } `json:"profile_card"` MicSeatAnimation struct { ResourceID int64 `json:"resource_id"` AnimationURL string `json:"animation_url"` EntitlementID string `json:"entitlement_id"` } `json:"mic_seat_animation"` EquippedBadges []struct { ResourceID int64 `json:"resource_id"` } `json:"equipped_badges"` Badges []struct { ResourceID int64 `json:"resource_id"` Resource struct { AssetURL string `json:"asset_url"` } `json:"resource"` } `json:"badges"` Resources []struct { ResourceID int64 `json:"resource_id"` ResourceType string `json:"resource_type"` } `json:"resources"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode appearance response failed: %v", err) } if walletClient.lastBatchEquipped == nil || walletClient.lastBatchEquipped.GetUserIds()[0] != 42 { t.Fatalf("appearance should batch query equipped resources: %+v", walletClient.lastBatchEquipped) } if strings.Join(walletClient.lastBatchEquipped.GetResourceTypes(), ",") != "avatar_frame,profile_card,vehicle,badge,mic_seat_animation" { t.Fatalf("appearance should query mic_seat_animation with wearable resources: %+v", walletClient.lastBatchEquipped.GetResourceTypes()) } if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 { t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges) } if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || envelope.Data.MicSeatAnimation.ResourceID != 8106 || envelope.Data.MicSeatAnimation.AnimationURL != "https://cdn.example/mic-seat.svga" || envelope.Data.MicSeatAnimation.EntitlementID != "ent-mic-seat-wave" || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" { t.Fatalf("appearance response mismatch: %+v", envelope) } hasMicSeatResource := false for _, resource := range envelope.Data.Resources { if resource.ResourceID == 8106 && resource.ResourceType == "mic_seat_animation" { hasMicSeatResource = true break } } if !hasMicSeatResource { t.Fatalf("appearance resources must include equipped mic_seat_animation: %+v", envelope.Data.Resources) } } func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) { walletClient := &fakeWalletClient{ listResourcesResp: &walletv1.ListResourcesResponse{ Resources: []*walletv1.Resource{ { ResourceId: 7001, ResourceCode: "emoji_global", ResourceType: "emoji_pack", Name: "Global Emoji", Status: "active", PreviewUrl: "https://cdn.example/emoji/global.png", AnimationUrl: "https://cdn.example/emoji/global.svga", MetadataJson: `{"category":"默认","pricing_type":"free","region_scope":"all"}`, SortOrder: 10, }, { ResourceId: 7002, ResourceCode: "emoji_saudi", ResourceType: "emoji_pack", Name: "Saudi Emoji", Status: "active", AssetUrl: "https://cdn.example/emoji/saudi.png", AnimationUrl: "https://cdn.example/emoji/saudi.pag", MetadataJson: `{"category":"热门","pricing_type":"paid","region_ids":[1001]}`, SortOrder: 20, }, { ResourceId: 7003, ResourceCode: "emoji_egypt", ResourceType: "emoji_pack", Name: "Egypt Emoji", Status: "active", AssetUrl: "https://cdn.example/emoji/egypt.png", AnimationUrl: "https://cdn.example/emoji/egypt.pag", MetadataJson: `{"region_ids":[1002]}`, SortOrder: 30, }, }, Total: 3, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/emoji-packs?region_id=1001&page=1&page_size=10", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("emoji packs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { Items []struct { ResourceID int64 `json:"resource_id"` Category string `json:"category"` PricingType string `json:"pricing_type"` CoverURL string `json:"cover_url"` AnimationURL string `json:"animation_url"` RegionIDs []int64 `json:"region_ids"` IsGlobal bool `json:"is_global"` } `json:"items"` Total int64 `json:"total"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode emoji packs failed: %v", err) } if walletClient.lastListResources == nil || walletClient.lastListResources.GetResourceType() != "emoji_pack" || !walletClient.lastListResources.GetActiveOnly() { t.Fatalf("emoji pack request mismatch: %+v", walletClient.lastListResources) } if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 { t.Fatalf("emoji pack envelope mismatch: %+v", envelope) } if !envelope.Data.Items[0].IsGlobal || envelope.Data.Items[0].CoverURL != "https://cdn.example/emoji/global.png" || envelope.Data.Items[0].Category != "默认" || envelope.Data.Items[0].PricingType != "free" { t.Fatalf("global emoji pack mismatch: %+v", envelope.Data.Items[0]) } if envelope.Data.Items[1].ResourceID != 7002 || envelope.Data.Items[1].AnimationURL != "https://cdn.example/emoji/saudi.pag" || envelope.Data.Items[1].Category != "热门" || envelope.Data.Items[1].PricingType != "paid" || len(envelope.Data.Items[1].RegionIDs) != 1 || envelope.Data.Items[1].RegionIDs[0] != 1001 { t.Fatalf("regional emoji pack mismatch: %+v", envelope.Data.Items[1]) } } func TestListResourceShopItemsReturnsActiveSaleItems(t *testing.T) { walletClient := &fakeWalletClient{ resourceShopResp: &walletv1.ListResourceShopItemsResponse{ Items: []*walletv1.ResourceShopItem{ { ShopItemId: 9001, ResourceId: 7001, Status: "active", DurationDays: 3, PriceType: "coin", CoinPrice: 300, EffectiveFromMs: 1_700_000_000_000, EffectiveToMs: 1_700_086_400_000, SortOrder: 10, Resource: &walletv1.Resource{ ResourceId: 7001, ResourceCode: "avatar_frame_gold", ResourceType: "avatar_frame", Name: "Gold Frame", Status: "active", AssetUrl: "https://cdn.example/frame.png", PreviewUrl: "https://cdn.example/frame-preview.png", PriceType: "coin", CoinPrice: 100, }, }, }, Total: 1, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/resource-shop/items?resource_type=avatar_frame&page=1&page_size=10", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("resource shop status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { Items []struct { ShopItemID int64 `json:"shop_item_id"` ResourceID int64 `json:"resource_id"` DurationDays int32 `json:"duration_days"` CoinPrice int64 `json:"coin_price"` EffectiveFromMS int64 `json:"effective_from_ms"` Resource struct { ResourceCode string `json:"resource_code"` ResourceType string `json:"resource_type"` CoinPrice int64 `json:"coin_price"` PreviewURL string `json:"preview_url"` } `json:"resource"` } `json:"items"` Total int64 `json:"total"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode resource shop failed: %v", err) } if walletClient.lastResourceShop == nil || walletClient.lastResourceShop.GetResourceType() != "avatar_frame" || !walletClient.lastResourceShop.GetActiveOnly() || walletClient.lastResourceShop.GetPageSize() != 10 { t.Fatalf("resource shop request mismatch: %+v", walletClient.lastResourceShop) } if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 { t.Fatalf("resource shop envelope mismatch: %+v", envelope) } first := envelope.Data.Items[0] if first.ShopItemID != 9001 || first.DurationDays != 3 || first.CoinPrice != 300 || first.Resource.ResourceCode != "avatar_frame_gold" || first.Resource.CoinPrice != 100 || first.Resource.PreviewURL == "" { t.Fatalf("resource shop item mismatch: %+v", first) } } func TestPurchaseResourceShopItemUsesAuthenticatedUserAndCommandID(t *testing.T) { walletClient := &fakeWalletClient{ purchaseShopResp: &walletv1.PurchaseResourceShopItemResponse{ OrderId: "rshop_order_1", TransactionId: "wtx_shop_1", ResourceGrantId: "rgr_shop_1", CoinSpent: 300, Balance: &walletv1.AssetBalance{ AssetType: "COIN", AvailableAmount: 700, Version: 2, }, ShopItem: &walletv1.ResourceShopItem{ ShopItemId: 9001, ResourceId: 7001, DurationDays: 3, PriceType: "coin", CoinPrice: 300, Resource: &walletv1.Resource{ ResourceId: 7001, ResourceCode: "avatar_frame_gold", ResourceType: "avatar_frame", Name: "Gold Frame", }, }, Resource: &walletv1.UserResourceEntitlement{ EntitlementId: "ent_shop_1", UserId: 42, ResourceId: 7001, Status: "active", Quantity: 1, Resource: &walletv1.Resource{ ResourceId: 7001, ResourceCode: "avatar_frame_gold", ResourceType: "avatar_frame", }, }, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/resource-shop/items/9001/purchase", bytes.NewReader([]byte(`{"command_id":"cmd-shop-1"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("purchase shop status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastPurchaseShop == nil || walletClient.lastPurchaseShop.GetCommandId() != "cmd-shop-1" || walletClient.lastPurchaseShop.GetUserId() != 42 || walletClient.lastPurchaseShop.GetShopItemId() != 9001 { t.Fatalf("purchase shop request mismatch: %+v", walletClient.lastPurchaseShop) } var envelope struct { Code string `json:"code"` Data struct { OrderID string `json:"order_id"` TransactionID string `json:"transaction_id"` CoinSpent int64 `json:"coin_spent"` Balance struct { AvailableAmount int64 `json:"available_amount"` } `json:"balance"` Resource struct { EntitlementID string `json:"entitlement_id"` ResourceID int64 `json:"resource_id"` } `json:"resource"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode purchase shop failed: %v", err) } if envelope.Code != httpkit.CodeOK || envelope.Data.OrderID != "rshop_order_1" || envelope.Data.CoinSpent != 300 || envelope.Data.Balance.AvailableAmount != 700 || envelope.Data.Resource.EntitlementID != "ent_shop_1" { t.Fatalf("purchase shop envelope mismatch: %+v", envelope) } } func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) { walletClient := &fakeWalletClient{ listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{ Gifts: []*walletv1.GiftConfig{ { GiftId: "rose", ResourceId: 11, Status: "active", Name: "Rose", GiftTypeCode: "normal", CoinPrice: 100, Resource: &walletv1.Resource{ ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", Status: "active", }, }, }, Total: 1, }, listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{ GiftTypes: []*walletv1.GiftTypeConfig{ { TypeCode: "normal", Name: "普通礼物", TabKey: "Gift", Status: "active", SortOrder: 10, CreatedAtMs: 1_700_000_000_000, UpdatedAtMs: 1_700_000_000_100, }, { TypeCode: "lucky", Name: "幸运礼物", TabKey: "Lucky", Status: "active", SortOrder: 30, }, }, }, } handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/gift-tabs", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("gift tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var envelope struct { Code string `json:"code"` Data struct { Items []struct { Key string `json:"key"` GiftTypeCode string `json:"gift_type_code"` Name string `json:"name"` Label string `json:"label"` TabKey string `json:"tab_key"` Status string `json:"status"` Order int32 `json:"order"` SortOrder int32 `json:"sort_order"` UpdatedAtMS int64 `json:"updated_at_ms"` Gifts []struct { GiftID string `json:"gift_id"` GiftTypeCode string `json:"gift_type_code"` CoinPrice int64 `json:"coin_price"` } `json:"gifts"` } `json:"items"` Total int `json:"total"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode gift tabs failed: %v", err) } if walletClient.lastListGiftTypes == nil || walletClient.lastListGiftTypes.GetAppCode() != "lalu" || !walletClient.lastListGiftTypes.GetActiveOnly() { t.Fatalf("gift tabs request mismatch: %+v", walletClient.lastListGiftTypes) } if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetAppCode() != "lalu" || !walletClient.lastListGiftConfigs.GetActiveOnly() || walletClient.lastListGiftConfigs.GetPageSize() != 100 || walletClient.lastListGiftConfigs.GetFilterRegion() { t.Fatalf("gift tabs gift preload request mismatch: %+v", walletClient.lastListGiftConfigs) } if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 { t.Fatalf("gift tabs envelope mismatch: %+v", envelope) } if envelope.Data.Items[0].Key != "normal" || envelope.Data.Items[0].GiftTypeCode != "normal" || envelope.Data.Items[0].Name != "普通礼物" || envelope.Data.Items[0].Label != "Gift" || envelope.Data.Items[0].TabKey != "Gift" || envelope.Data.Items[0].Order != 10 || envelope.Data.Items[0].SortOrder != 10 || envelope.Data.Items[0].UpdatedAtMS != 1_700_000_000_100 { t.Fatalf("first gift tab mismatch: %+v", envelope.Data.Items[0]) } if len(envelope.Data.Items[0].Gifts) != 1 || envelope.Data.Items[0].Gifts[0].GiftID != "rose" || envelope.Data.Items[0].Gifts[0].GiftTypeCode != "normal" || envelope.Data.Items[0].Gifts[0].CoinPrice != 100 { t.Fatalf("first gift tab gifts mismatch: %+v", envelope.Data.Items[0].Gifts) } if len(envelope.Data.Items[1].Gifts) != 0 { t.Fatalf("empty gift tab must still be returned with empty gifts: %+v", envelope.Data.Items[1]) } } func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) { giftResource := &walletv1.Resource{ ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", Status: "active", UsageScopes: []string{"gift"}, AssetUrl: "https://cdn.example/rose.svga", SortOrder: 1, } walletClient := &fakeWalletClient{ resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}}, listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{ TypeCode: "normal", Name: "普通礼物", TabKey: "Gift", Status: "active", SortOrder: 10, }}}, listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{ GiftId: "rose", ResourceId: giftResource.GetResourceId(), Resource: giftResource, Status: "active", Name: "Rose", GiftTypeCode: "normal", PriceVersion: "v1", ChargeAssetType: "COIN", CoinPrice: 100, HeatValue: 100, RegionIds: []int64{0, 1001}, }}, Total: 1}, listUserResourcesResp: &walletv1.ListUserResourcesResponse{Resources: []*walletv1.UserResourceEntitlement{{ EntitlementId: "ent-rose", UserId: 42, ResourceId: giftResource.GetResourceId(), Resource: giftResource, Status: "active", Quantity: 2, RemainingQuantity: 2, }}}, } queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{ RoomId: "room-gift-cache", OwnerUserId: 88, Status: "active", VisibleRegionId: 1001, }}} handler := NewHandler(&fakeRoomClient{}) handler.SetRoomQueryClient(queryClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) for attempt := 0; attempt < 2; attempt++ { request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-cache/gift-panel", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("gift panel status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String()) } } if walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 { t.Fatalf("gift panel config should be cached, typeCalls=%d giftCalls=%d", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls) } if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 { t.Fatalf("gift panel realtime data must not be cached, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls) } if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetRegionId() != 1001 || walletClient.lastListGiftConfigs.GetPageSize() != 500 || !walletClient.lastListGiftConfigs.GetFilterRegion() { t.Fatalf("gift panel list request mismatch: %+v", walletClient.lastListGiftConfigs) } if walletClient.lastListUserResources == nil || walletClient.lastListUserResources.GetUserId() != 42 || walletClient.lastListUserResources.GetResourceType() != "gift" || !walletClient.lastListUserResources.GetActiveOnly() { t.Fatalf("gift panel bag request mismatch: %+v", walletClient.lastListUserResources) } } func TestRoomGiftPanelCacheSeparatesVisibleRegion(t *testing.T) { walletClient := &fakeWalletClient{ resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}}, listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{TypeCode: "normal", TabKey: "Gift", Status: "active"}}}, listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{ GiftId: "rose", ResourceId: 11, Resource: &walletv1.Resource{ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", Status: "active"}, Status: "active", Name: "Rose", GiftTypeCode: "normal", CoinPrice: 100, }}, Total: 1}, } snapshot := &roomv1.RoomSnapshot{ RoomId: "room-gift-region-cache", OwnerUserId: 88, Status: "active", VisibleRegionId: 1001, } queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: snapshot}} handler := NewHandler(&fakeRoomClient{}) handler.SetRoomQueryClient(queryClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("first gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } snapshot.VisibleRegionId = 1002 request = httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("second gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 || walletClient.lastListGiftConfigs.GetRegionId() != 1002 { t.Fatalf("gift panel config cache must be separated by visible region, typeCalls=%d giftCalls=%d lastGift=%+v", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls, walletClient.lastListGiftConfigs) } } func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) { messageClient := &fakeMessageInboxClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetMessageInboxClient(messageClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/read-all", bytes.NewReader([]byte(`{"section":"activity"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-message-read-all") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || messageClient.lastReadAll == nil || messageClient.lastReadAll.GetUserId() != 42 || messageClient.lastReadAll.GetSection() != "activity" { t.Fatalf("read-all mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastReadAll) } request = httptest.NewRequest(http.MethodPost, "/api/v1/messages/msg-1/read", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-message-read") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || messageClient.lastRead == nil || messageClient.lastRead.GetUserId() != 42 || messageClient.lastRead.GetMessageId() != "msg-1" { t.Fatalf("read mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastRead) } request = httptest.NewRequest(http.MethodDelete, "/api/v1/messages/msg-1", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-message-delete") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || messageClient.lastDelete == nil || messageClient.lastDelete.GetUserId() != 42 || messageClient.lastDelete.GetMessageId() != "msg-1" { t.Fatalf("delete mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastDelete) } } func TestBatchUserProfilesReturnsOnlySafeDisplayFields(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/profiles:batch?user_ids=42,43", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 7)) request.Header.Set("X-Request-ID", "req-profile-batch") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastBatch == nil || profileClient.lastBatch.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-batch") || len(profileClient.lastBatch.GetUserIds()) != 2 { t.Fatalf("batch user request mismatch: %+v", profileClient.lastBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode profile batch failed: %v", err) } data, ok := response.Data.(map[string]any) profiles, profilesOK := data["profiles"].([]any) if response.Code != httpkit.CodeOK || !ok || !profilesOK || len(profiles) != 2 { t.Fatalf("profile batch response mismatch: %+v", response) } first, ok := profiles[0].(map[string]any) if !ok || first["user_id"] != "42" || first["display_user_id"] == "" || first["username"] == "" || first["app_code"] != "lalu" { t.Fatalf("profile batch item mismatch: %+v", first) } if _, exists := first["birth"]; exists { t.Fatalf("profile batch must not expose birth or other non-conversation fields: %+v", first) } } func TestVoiceRoomRedPacketClaimRecordsIncludesUserProfiles(t *testing.T) { walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{ ServerTimeMs: 3000, Packet: &walletv1.RedPacket{ PacketId: "packet-1", Claims: []*walletv1.RedPacketClaim{ {ClaimId: "claim-1", PacketId: "packet-1", UserId: 10002, Amount: 300, Status: "claimed", CreatedAtMs: 1000}, {ClaimId: "claim-2", PacketId: "packet-1", UserId: 10003, Amount: 200, Status: "claimed", CreatedAtMs: 2000}, }, }, }} profileClient := &fakeUserProfileClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/claim-records?packetNo=packet-1&page=1&pageSize=1", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-red-packet-claims") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastGetRedPacket == nil || !walletClient.lastGetRedPacket.GetIncludeClaims() || walletClient.lastGetRedPacket.GetPacketId() != "packet-1" { t.Fatalf("red packet detail request mismatch: %+v", walletClient.lastGetRedPacket) } if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 { t.Fatalf("claim records must batch only visible claim users: %+v", profileClient.lastBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode claim records failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 { t.Fatalf("claim records response mismatch: %+v", response) } first, ok := items[0].(map[string]any) if !ok || first["account"] != "110002" || first["nickname"] != "user-10002" || first["avatar"] != "https://cdn.example/avatar.png" { t.Fatalf("claim record should include display profile fields: %+v", first) } } func TestVoiceRoomRedPacketDetailIncludesSenderProfile(t *testing.T) { walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{ ServerTimeMs: 3000, Packet: &walletv1.RedPacket{ PacketId: "packet-1", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", TotalAmount: 1000, PacketCount: 5, RemainingCount: 3, Status: "active", }, }} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/detail?packetNo=packet-1", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-red-packet-detail") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 10002 { t.Fatalf("detail must fetch sender profile: %+v", profileClient.lastGet) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode red-packet detail failed: %v", err) } data, ok := response.Data.(map[string]any) packet, packetOK := data["packet"].(map[string]any) if response.Code != httpkit.CodeOK || !ok || !packetOK { t.Fatalf("detail response mismatch: %+v", response) } if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" { t.Fatalf("detail packet should include sender display fields: %+v", packet) } } func TestVoiceRoomRedPacketClaimIncludesSenderProfile(t *testing.T) { packetBeforeClaim := &walletv1.RedPacket{ PacketId: "packet-1", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", TotalAmount: 1000, PacketCount: 5, RemainingCount: 3, Status: "active", } walletClient := &fakeWalletClient{ getRedPacketResp: &walletv1.GetRedPacketResponse{Packet: packetBeforeClaim}, claimRedPacketResp: &walletv1.ClaimRedPacketResponse{ ServerTimeMs: 4000, BalanceAfter: 700, Packet: &walletv1.RedPacket{ PacketId: "packet-1", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", TotalAmount: 1000, PacketCount: 5, RemainingCount: 2, Status: "active", }, Claim: &walletv1.RedPacketClaim{ClaimId: "claim-1", PacketId: "packet-1", UserId: 42, Amount: 200, Status: "claimed", CreatedAtMs: 3500}, }, } profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: &userv1.User{UserId: 42, DisplayUserId: "880042", Username: "claimer-name", Avatar: "https://cdn.example/claimer.png", RegionId: 1001}, 10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001}, }} guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}} handler := NewHandlerWithConfig(&fakeRoomClient{}, guardClient, nil, nil, TencentIMConfig{}, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/app/voice-room/red-packet/claim", bytes.NewReader([]byte(`{"packetNo":"packet-1"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-red-packet-claim") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if guardClient.lastPresence == nil || guardClient.lastPresence.GetRoomId() != "room-1" || guardClient.lastPresence.GetUserId() != 42 { t.Fatalf("claim must verify room presence before wallet mutation: %+v", guardClient.lastPresence) } if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 10002 { t.Fatalf("claim must fetch claimer then sender profiles before response: %+v", profileClient.getRequests) } if walletClient.lastClaimRedPacket == nil || walletClient.lastClaimRedPacket.GetPacketId() != "packet-1" || walletClient.lastClaimRedPacket.GetUserId() != 42 { t.Fatalf("claim request mismatch: %+v", walletClient.lastClaimRedPacket) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode red-packet claim failed: %v", err) } data, ok := response.Data.(map[string]any) packet, packetOK := data["packet"].(map[string]any) claim, claimOK := data["claim"].(map[string]any) if response.Code != httpkit.CodeOK || !ok || !packetOK || !claimOK { t.Fatalf("claim response mismatch: %+v", response) } if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" { t.Fatalf("claim packet should include sender display fields: %+v", packet) } if claim["account"] != "880042" || claim["nickname"] != "claimer-name" || claim["avatar"] != "https://cdn.example/claimer.png" { t.Fatalf("claim should keep claimant display fields: %+v", claim) } } func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) { walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{ ServerTimeMs: 4000, Packets: []*walletv1.RedPacket{ {PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active"}, {PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"}, }, }} profileClient := &fakeUserProfileClient{regionID: 1001} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/active?roomId=room-1", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-red-packet-active") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastListRedPackets == nil || walletClient.lastListRedPackets.GetRoomId() != "room-1" || walletClient.lastListRedPackets.GetViewerUserId() != 42 { t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets) } if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 { t.Fatalf("active red-packet should batch only visible sender profiles: %+v", profileClient.lastBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode active red-packet response failed: %v", err) } data, ok := response.Data.(map[string]any) packets, packetsOK := data["packets"].([]any) if response.Code != httpkit.CodeOK || !ok || !packetsOK || len(packets) != 1 { t.Fatalf("active red-packet response mismatch: %+v", response) } first, ok := packets[0].(map[string]any) if !ok || first["packetNo"] != "packet-active" || first["status"] != "ACTIVE" || first["senderNickname"] != "user-10002" || first["senderAvatar"] != "https://cdn.example/avatar.png" { t.Fatalf("active packet item mismatch: %+v", first) } } func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{ Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 120, FrozenAmount: 3, Version: 2}}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/me/balances", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-balances") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.last == nil || walletClient.last.GetUserId() != 42 || walletClient.last.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-balances") { t.Fatalf("wallet request mismatch: %+v", walletClient.last) } if len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "COIN" { t.Fatalf("wallet default asset types mismatch: %+v", walletClient.last) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } balances, ok := data["balances"].([]any) if !ok || len(balances) != 1 { t.Fatalf("balances response shape mismatch: %+v", data) } balance, ok := balances[0].(map[string]any) if !ok || balance["asset_type"] != "COIN" || balance["available_amount"] != float64(120) { t.Fatalf("balance response mismatch: %+v", balance) } } func TestGetMyGiftWallUsesAuthenticatedUserID(t *testing.T) { walletClient := &fakeWalletClient{giftWallResp: &walletv1.GetUserGiftWallResponse{ GiftKindCount: 1, GiftTotalCount: 5, TotalValue: 50, TotalCoinValue: 50, Items: []*walletv1.GiftWallItem{{ GiftId: "rose", GiftName: "Rose", ResourceId: 11, GiftTypeCode: "normal", PresentationJson: "{}", GiftCount: 5, TotalValue: 50, TotalCoinValue: 50, ChargeAssetType: "COIN", LastPriceVersion: "v1", Resource: &walletv1.Resource{ ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", AssetUrl: "https://cdn.example/rose.png", }, ResourceSnapshotJson: `{"ResourceID":11}`, LastReceivedAtMs: 1_700_000_000_000, }}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/gift-wall", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-gift-wall") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastGiftWall == nil || walletClient.lastGiftWall.GetUserId() != 42 || walletClient.lastGiftWall.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-gift-wall") { t.Fatalf("gift wall request mismatch: %+v", walletClient.lastGiftWall) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 || data["gift_total_count"] != float64(5) || data["total_value"] != float64(50) { t.Fatalf("gift wall response mismatch: %+v", response) } item, ok := items[0].(map[string]any) resource, resourceOK := item["resource"].(map[string]any) if !ok || item["gift_id"] != "rose" || item["gift_count"] != float64(5) || !resourceOK || resource["asset_url"] == "" { t.Fatalf("gift wall item mismatch: %+v", item) } } func TestGetUserGiftWallUsesPathUserID(t *testing.T) { walletClient := &fakeWalletClient{giftWallResp: &walletv1.GetUserGiftWallResponse{ GiftKindCount: 1, GiftTotalCount: 3, TotalValue: 300, TotalCoinValue: 300, Items: []*walletv1.GiftWallItem{{ GiftId: "castle", GiftName: "Castle", GiftCount: 3, TotalValue: 300, TotalCoinValue: 300, }}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/10002/gift-wall", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-user-gift-wall") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastGiftWall == nil || walletClient.lastGiftWall.GetUserId() != 10002 || walletClient.lastGiftWall.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-user-gift-wall") { t.Fatalf("profile gift wall request mismatch: %+v", walletClient.lastGiftWall) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["gift_total_count"] != float64(3) || data["total_value"] != float64(300) { t.Fatalf("profile gift wall response mismatch: %+v", response) } } func TestGetUserGiftWallRejectsInvalidPathUserID(t *testing.T) { walletClient := &fakeWalletClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/not-a-number/gift-wall", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastGiftWall != nil { t.Fatalf("invalid user_id must not call wallet-service: %+v", walletClient.lastGiftWall) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != httpkit.CodeInvalidArgument { t.Fatalf("error response mismatch: %+v", response) } } func TestListCoinTransactionsForcesCoinAndDefaultsPageSize30(t *testing.T) { walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{ Total: 1, Transactions: []*walletv1.WalletTransaction{{ EntryId: 7, TransactionId: "wtx-coin-1", BizType: "coin_seller_transfer", AssetType: "COIN", AvailableDelta: 300, AvailableAfter: 1300, CreatedAtMs: 1_700_000_000_000, }}, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-transactions?page=2", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-coin-ledger") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastTransactions == nil || walletClient.lastTransactions.GetUserId() != 42 || walletClient.lastTransactions.GetAssetType() != "COIN" { t.Fatalf("wallet transaction request mismatch: %+v", walletClient.lastTransactions) } if walletClient.lastTransactions.GetPage() != 2 || walletClient.lastTransactions.GetPageSize() != 30 { t.Fatalf("coin transaction pagination mismatch: %+v", walletClient.lastTransactions) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 || data["page_size"] != float64(30) { t.Fatalf("coin transaction response mismatch: %+v", response) } } func TestListWalletTransactionsIncludesCounterpartyProfile(t *testing.T) { walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{ Total: 1, Transactions: []*walletv1.WalletTransaction{{ EntryId: 9, TransactionId: "wtx-seller-1", BizType: "coin_seller_transfer", AssetType: "COIN_SELLER_COIN", AvailableDelta: -10_000, AvailableAfter: 1_011, CounterpartyUserId: 9001, CreatedAtMs: 1_780_000_000_000, TransferUsdMinor: 1250, TransferCurrencyCode: "USD", }}, }} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 9001: { UserId: 9001, DisplayUserId: "163001", Username: "Alice", Avatar: "https://cdn.example/alice.png", AppCode: "lalu", }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/transactions?asset_type=COIN_SELLER_COIN&page_size=20", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-wallet-ledger") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastTransactions == nil || walletClient.lastTransactions.GetAssetType() != "COIN_SELLER_COIN" || walletClient.lastTransactions.GetPageSize() != 20 { t.Fatalf("wallet transaction request mismatch: %+v", walletClient.lastTransactions) } if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 { t.Fatalf("counterparty profile batch mismatch: %+v", profileClient.lastBatch) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) items, itemsOK := data["items"].([]any) if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 { t.Fatalf("wallet transaction response mismatch: %+v", response) } first, ok := items[0].(map[string]any) if !ok || first["counterparty_display_user_id"] != "163001" || first["counterparty_username"] != "Alice" || first["counterparty_avatar"] != "https://cdn.example/alice.png" || first["transfer_usd_minor"] != float64(1250) || first["transfer_currency_code"] != "USD" { t.Fatalf("counterparty profile fields mismatch: %+v", first) } } func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) { walletClient := &fakeWalletClient{rechargeProductsResp: &walletv1.ListRechargeProductsResponse{ Channels: []string{"google"}, Products: []*walletv1.RechargeProduct{{ ProductId: 11, ProductCode: "iap_android_11", ProductName: "1500 Coins", Description: "coin pack", Platform: "android", Channel: "google", CurrencyCode: "USDT", CoinAmount: 1500, AmountMicro: 1500000, AmountMinor: 1500000, RegionIds: []int64{2002}, ResourceAssetType: "COIN", Status: "active", Enabled: true, }}, }} profileClient := &fakeUserProfileClient{regionID: 2002} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/recharge/products", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-recharge-products") request.Header.Set("X-App-Platform", "android") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet) } if walletClient.lastRechargeProducts == nil || walletClient.lastRechargeProducts.GetUserId() != 42 || walletClient.lastRechargeProducts.GetRegionId() != 2002 || walletClient.lastRechargeProducts.GetPlatform() != "android" || walletClient.lastRechargeProducts.GetAppCode() != "lalu" || walletClient.lastRechargeProducts.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-recharge-products") { t.Fatalf("recharge products request mismatch: %+v", walletClient.lastRechargeProducts) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) products, productsOK := data["products"].([]any) channels, channelsOK := data["channels"].([]any) if response.Code != httpkit.CodeOK || !ok || !productsOK || len(products) != 1 || !channelsOK || len(channels) != 1 { t.Fatalf("recharge response shape mismatch: %+v", response) } product, ok := products[0].(map[string]any) if !ok || product["product_name"] != "1500 Coins" || product["platform"] != "android" || product["channel"] != "google" || product["currency_code"] != "USDT" || product["coin_amount"] != float64(1500) || product["amount_micro"] != float64(1500000) || product["resource_asset_type"] != "COIN" || product["enabled"] != true { t.Fatalf("recharge product dto mismatch: %+v", product) } } func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T) { walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{ PaymentOrderId: "gpay_abc", TransactionId: "wtx_abc", Status: "credited", ProductId: 11, ProductCode: "iap_android_11", CoinAmount: 1500, Balance: &walletv1.AssetBalance{ AssetType: "COIN", AvailableAmount: 2500, }, ConsumeState: "consumed", }} profileClient := &fakeUserProfileClient{regionID: 2002} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) body := `{"command_id":"pay-cmd-1","product_id":11,"product_code":"iap_android_11","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}` request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Request-ID", "req-google-confirm") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet) } if walletClient.lastGoogleConfirm == nil || walletClient.lastGoogleConfirm.GetUserId() != 42 || walletClient.lastGoogleConfirm.GetRegionId() != 2002 || walletClient.lastGoogleConfirm.GetCountryId() != 840 || walletClient.lastGoogleConfirm.GetProductId() != 11 || walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" || walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" || walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" || walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-1" || walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm") { t.Fatalf("google confirm request mismatch: %+v", walletClient.lastGoogleConfirm) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["payment_order_id"] != "gpay_abc" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(1500) { t.Fatalf("google confirm response mismatch: %+v", response) } } func TestConfirmGooglePaymentAllowsGoogleProductCodeWithoutProductID(t *testing.T) { walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{ PaymentOrderId: "gpay_first_recharge", TransactionId: "wtx_first_recharge", Status: "credited", ProductId: 11, ProductCode: "first_recharge_google_999", CoinAmount: 90000, Balance: &walletv1.AssetBalance{ AssetType: "COIN", AvailableAmount: 90000, }, ConsumeState: "consumed", }} profileClient := &fakeUserProfileClient{regionID: 2002} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetWalletClient(walletClient) router := handler.Routes(auth.NewVerifier("secret")) body := `{"command_id":"pay-cmd-first-recharge","product_code":"first_recharge_google_999","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}` request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Request-ID", "req-google-confirm-first-recharge") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastGoogleConfirm == nil || walletClient.lastGoogleConfirm.GetUserId() != 42 || walletClient.lastGoogleConfirm.GetRegionId() != 2002 || walletClient.lastGoogleConfirm.GetCountryId() != 840 || walletClient.lastGoogleConfirm.GetProductId() != 0 || walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" || walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" || walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" || walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-first-recharge" || walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm-first-recharge") { t.Fatalf("google first recharge confirm request mismatch: %+v", walletClient.lastGoogleConfirm) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["payment_order_id"] != "gpay_first_recharge" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(90000) { t.Fatalf("google first recharge confirm response mismatch: %+v", response) } } func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) { walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{ Products: []*walletv1.RechargeProduct{{ ProductId: 91001, ProductName: "Seller 10 USD", AmountMicro: 10_000_000, CoinAmount: 880000, Platform: "web", AudienceType: "coin_seller", Enabled: true, }}, PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: 810, ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Status: "active", UsdToCurrencyRate: "1.00000000", SortOrder: 810, }}, UsdtTrc20Enabled: true, UsdtTrc20Address: "TPlatformReceiveAddress", }} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: { UserId: 42, DisplayUserId: "163042", Username: "seller42", Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "SA", CountryDisplayName: "Saudi Arabia", RegionId: 7100, RegionCode: "mena-sa", RegionName: "Saudi Arabia", }}} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"should-ignore": 900001}} hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsCoinSeller: true, CoinSellerStatus: "active"}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) // H5 带 token 时 display_user_id 只能作为无效噪声;gateway 必须用 token 用户重新查国家和币商角色。 request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=should-ignore", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-h5-seller-options") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if identityClient.lastResolve != nil { t.Fatalf("token h5 options must not resolve display id: %+v", identityClient.lastResolve) } if walletClient.lastH5Options == nil || walletClient.lastH5Options.GetTargetUserId() != 42 || walletClient.lastH5Options.GetTargetRegionId() != 7100 || walletClient.lastH5Options.GetTargetCountryCode() != "SA" || walletClient.lastH5Options.GetAudienceType() != "coin_seller" { t.Fatalf("h5 options wallet request mismatch: %+v", walletClient.lastH5Options) } if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 { t.Fatalf("h5 options role summary request mismatch: %+v", hostClient.lastRoleSummary) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("h5 options response mismatch: %+v", response) } account := data["account"].(map[string]any) methods := data["payment_methods"].([]any) if account["audience_type"] != "coin_seller" || account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["pay_type"] != "MADA" { t.Fatalf("h5 options response data mismatch: %+v", data) } } func TestH5RechargeContextRejectsMissingIdentityWithoutToken(t *testing.T) { walletClient := &fakeWalletClient{} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: { UserId: 66, DisplayUserId: "163066", Username: "normal66", Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "PH", RegionId: 7200, }}} hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/context", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusBadRequest { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if identityClient.lastResolve != nil { t.Fatalf("missing h5 identity must be rejected before user-service lookup: %+v", identityClient.lastResolve) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } if response.Code != httpkit.CodeInvalidArgument || response.Message != "invalid argument" { t.Fatalf("missing h5 identity response mismatch: %+v", response) } } func TestH5RechargeOptionsNormalizesSaudiCountryNameToPaymentCountryCode(t *testing.T) { walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{ PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: 810, ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Status: "active", UsdToCurrencyRate: "3.75000000", }}, }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"sa-name": 77}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{77: { UserId: 77, DisplayUserId: "sa-name", Username: "sa_name_user", Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "Saudi Arabia", CountryDisplayName: "沙特阿拉伯", RegionId: 7100, }}} hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 77}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) // 历史用户资料可能把国家写成英文名或中文展示名;H5 支付链路必须先归一为 SA,再让 wallet-service 按国家返回 MiFaPay 方式。 request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=sa-name", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastH5Options == nil || walletClient.lastH5Options.GetTargetCountryCode() != "SA" { t.Fatalf("h5 options should normalize country name to SA: %+v", walletClient.lastH5Options) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("h5 options response mismatch: %+v", response) } account := data["account"].(map[string]any) methods := data["payment_methods"].([]any) if account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["country_code"] != "SA" { t.Fatalf("normalized h5 options response data mismatch: %+v", data) } } func TestH5RechargeOptionsResolvesDisplayUserCountryWithoutToken(t *testing.T) { walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{ PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: 1310, ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "Gcash", MethodName: "GCash", Status: "active", UsdToCurrencyRate: "1.00000000", }}, }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: { UserId: 66, DisplayUserId: "163066", Username: "normal66", Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "PH", CountryDisplayName: "Philippines", RegionId: 7200, }}} hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=163066", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163066" { t.Fatalf("display user id was not resolved: %+v", identityClient.lastResolve) } if walletClient.lastH5Options == nil || walletClient.lastH5Options.GetTargetUserId() != 66 || walletClient.lastH5Options.GetTargetRegionId() != 7200 || walletClient.lastH5Options.GetTargetCountryCode() != "PH" || walletClient.lastH5Options.GetAudienceType() != "normal" { t.Fatalf("display h5 options wallet request mismatch: %+v", walletClient.lastH5Options) } } func TestH5RechargeOptionsFallsBackToNumericUserIDWhenDisplayIDIsHeld(t *testing.T) { walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{ PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{ MethodId: 1810, ProviderCode: "mifapay", ProviderName: "MiFaPay", CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Status: "active", UsdToCurrencyRate: "1.00000000", }}, }} identityClient := &fakeUserIdentityClient{resolveErr: xerr.New(xerr.DisplayUserIDNotFound, "not found")} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{123456: { UserId: 123456, DisplayUserId: "1111cc1111", Username: "test_123456", Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "SA", CountryDisplayName: "Saudi Arabia", RegionId: 686, }}} hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 123456}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) // 123456 在用户换靓号后可能不再是 active display_user_id,但 H5 的无 token 入口文案是输入用户 ID; // display 解析 NOT_FOUND 时,纯数字输入必须回退到真实 user_id,才能按该用户国家返回沙特支付方式。 request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=123456", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "123456" { t.Fatalf("display user id was not tried first: %+v", identityClient.lastResolve) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 123456 { t.Fatalf("numeric user id fallback did not load profile: %+v", profileClient.lastGet) } if walletClient.lastH5Options == nil || walletClient.lastH5Options.GetTargetUserId() != 123456 || walletClient.lastH5Options.GetTargetRegionId() != 686 || walletClient.lastH5Options.GetTargetCountryCode() != "SA" || walletClient.lastH5Options.GetAudienceType() != "normal" { t.Fatalf("numeric h5 options wallet request mismatch: %+v", walletClient.lastH5Options) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("h5 options response mismatch: %+v", response) } account := data["account"].(map[string]any) methods := data["payment_methods"].([]any) if account["display_user_id"] != "1111cc1111" || account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["pay_type"] != "MADA" { t.Fatalf("numeric h5 options response data mismatch: %+v", data) } } func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) { walletClient := &fakeWalletClient{h5CreateOrderResp: &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{ OrderId: "h5-order-eg", TargetUserId: 55, TargetRegionId: 7300, TargetCountryCode: "EG", AudienceType: "normal", ProductId: 93001, ProductName: "Normal 1.5 USD", CoinAmount: 120000, UsdMinorAmount: 150, ProviderCode: "mifapay", PaymentMethodId: 1210, CountryCode: "EG", CurrencyCode: "EGP", ProviderAmountMinor: 150, PayWay: "Ewallet", PayType: "FawryPay", PayUrl: "https://pay.example/h5-order-eg", Status: "pending", }}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{55: { UserId: 55, DisplayUserId: "163055", Username: "normal55", Status: userv1.UserStatus_USER_STATUS_ACTIVE, CountryId: 818, Country: "EG", CountryDisplayName: "Egypt", RegionId: 7300, }}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(&fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 55}}) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"commandId":"cmd-h5-create-eg","productId":93001,"paymentMethodId":1210,"returnUrl":"https://h5.example/return"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/recharge/h5/orders", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 55)) request.Header.Set("X-Forwarded-For", "203.0.113.9") request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastH5CreateOrder == nil || walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" || walletClient.lastH5CreateOrder.GetTargetUserId() != 55 || walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 || walletClient.lastH5CreateOrder.GetTargetCountryId() != 818 || walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" || walletClient.lastH5CreateOrder.GetAudienceType() != "normal" || walletClient.lastH5CreateOrder.GetProductId() != 93001 || walletClient.lastH5CreateOrder.GetProviderCode() != "mifapay" || walletClient.lastH5CreateOrder.GetPaymentMethodId() != 1210 || walletClient.lastH5CreateOrder.GetReturnUrl() != "https://h5.example/return" || walletClient.lastH5CreateOrder.GetLanguage() != "en" || walletClient.lastH5CreateOrder.GetPayerName() != "normal55" || walletClient.lastH5CreateOrder.GetPayerAccount() != "163055" || walletClient.lastH5CreateOrder.GetPayerEmail() != "163055@lalu.com" { t.Fatalf("h5 create order wallet request mismatch: %+v", walletClient.lastH5CreateOrder) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("h5 create order response mismatch: %+v", response) } order := data["order"].(map[string]any) if order["order_id"] != "h5-order-eg" || order["country_code"] != "EG" || order["pay_type"] != "FawryPay" || order["pay_url"] != "https://pay.example/h5-order-eg" { t.Fatalf("h5 create order response data mismatch: %+v", data) } } func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) { walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{ TransactionId: "wtx-coin-seller", SellerBalanceAfter: 80000, TargetBalanceAfter: 80000, Amount: 80000, RechargeUsdMinor: 0, RechargeCurrencyCode: "", RechargePolicyId: 0, RechargePolicyVersion: "", RechargePolicyCoinAmount: 0, RechargePolicyUsdMinorAmount: 0, }} hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{ UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163002": 900002}} profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 1001}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"commandId":"cmd-seller-transfer","targetUserId":163002,"amount":80000,"reason":"player recharge"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-coin-seller") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if hostClient.last == nil || hostClient.last.GetUserId() != 42 { t.Fatalf("coin seller identity request mismatch: %+v", hostClient.last) } if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163002" { t.Fatalf("target display user id was not resolved: %+v", identityClient.lastResolve) } if walletClient.lastTransfer == nil || walletClient.lastTransfer.GetSellerUserId() != 42 || walletClient.lastTransfer.GetTargetUserId() != 900002 || walletClient.lastTransfer.GetSellerRegionId() != 1001 || walletClient.lastTransfer.GetTargetRegionId() != 1001 || walletClient.lastTransfer.GetAmount() != 80000 || walletClient.lastTransfer.GetCommandId() != "cmd-seller-transfer" { t.Fatalf("wallet transfer request mismatch: %+v", walletClient.lastTransfer) } if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 { t.Fatalf("coin seller region lookup mismatch: %+v", profileClient.getRequests) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["transaction_id"] != "wtx-coin-seller" || data["seller_balance_after"] != float64(80000) || data["recharge_usd_minor"] != float64(0) || data["recharge_policy_id"] != float64(0) { t.Fatalf("coin seller transfer response mismatch: %+v", response) } } func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) { imPublisher := &fakeTencentIMUserMessagePublisher{} walletClient := &fakeWalletClient{directGiftResp: &walletv1.DebitGiftResponse{ BillingReceiptId: "receipt-direct-gift", TransactionId: "wtx-direct-gift", CoinSpent: 200, ChargeAssetType: "COIN", ChargeAmount: 200, GiftTypeCode: "normal", GiftName: "Rose", GiftIconUrl: "https://cdn.example/gifts/rose.png", GiftAnimationUrl: "https://cdn.example/gifts/rose.mp4", GiftEffectTypes: []string{"mp4"}, PriceVersion: "v1", BalanceAfter: 800, GiftIncomeCoinAmount: 60, GiftIncomeBalanceAfter: 460, HostPeriodDiamondAdded: 200, HostPeriodCycleKey: "2026-06", }} profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 2002}} hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{ UserId: 900002, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) handler.SetTencentIMUserMessagePublisher(imPublisher) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"commandId":"cmd-direct-gift","targetUserId":900002,"giftId":"rose","giftCount":2,"region_id":9999}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/direct-gifts/send", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-direct-gift") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastDirectGift == nil || walletClient.lastDirectGift.GetCommandId() != "cmd-direct-gift" || walletClient.lastDirectGift.GetSenderUserId() != 42 || walletClient.lastDirectGift.GetTargetUserId() != 900002 || walletClient.lastDirectGift.GetGiftId() != "rose" || walletClient.lastDirectGift.GetGiftCount() != 2 || walletClient.lastDirectGift.GetRegionId() != 1001 || walletClient.lastDirectGift.GetSenderRegionId() != 1001 || !walletClient.lastDirectGift.GetTargetIsHost() || walletClient.lastDirectGift.GetTargetHostRegionId() != 8801 || walletClient.lastDirectGift.GetTargetAgencyOwnerUserId() != 30001 { t.Fatalf("direct gift wallet request mismatch: %+v", walletClient.lastDirectGift) } if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 { t.Fatalf("direct gift must resolve sender and target through user-service: %+v", profileClient.getRequests) } if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 900002 { t.Fatalf("direct gift target host scope was not resolved: %+v", hostClient.lastHost) } if len(imPublisher.messages) != 1 { t.Fatalf("direct gift must publish exactly one C2C message: %+v", imPublisher.messages) } imMessage := imPublisher.messages[0] if imMessage.ToAccount != "900002" || imMessage.FromAccount != "42" || imMessage.SyncOtherMachine != 1 || imMessage.EventID != "direct_gift:lalu:cmd-direct-gift" || imMessage.Desc != "gift" || imMessage.Ext != "direct_message_gift" { t.Fatalf("direct gift C2C message mismatch: %+v", imMessage) } var imPayload map[string]any if err := json.Unmarshal(imMessage.PayloadJSON, &imPayload); err != nil { t.Fatalf("decode direct gift im payload failed: %v", err) } if imPayload["type"] != "gift" || imPayload["event_id"] != "direct_gift:lalu:cmd-direct-gift" || imPayload["billing_receipt_id"] != "receipt-direct-gift" || imPayload["gift_income_coin_amount"] != float64(60) || imPayload["gift_name"] != "Rose" || imPayload["gift_count"] != float64(2) { t.Fatalf("direct gift im payload mismatch: %+v", imPayload) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) message, messageOK := data["message"].(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["billing_receipt_id"] != "receipt-direct-gift" || data["event_id"] != "direct_gift:lalu:cmd-direct-gift" || data["coin_balance_after"] != float64(800) || data["gift_income_coin_amount"] != float64(60) || data["gift_income_balance_after"] != float64(460) || data["gift_name"] != "Rose" || !messageOK || message["custom_description"] != "gift" || message["custom_extension"] != "direct_message_gift" || message["message_id"] != "direct_gift:lalu:cmd-direct-gift" { t.Fatalf("direct gift response mismatch: %+v", response) } } func TestCoinSellerTransferAllowsSelfTargetAndSkipsDuplicateRegionLookup(t *testing.T) { walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{ TransactionId: "wtx-coin-seller-self", SellerBalanceAfter: 1010, TargetBalanceAfter: 1, Amount: 1, }} hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{ UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163000": 42}} profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 688}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"command_id":"cmd-seller-self","target_display_user_id":"163000","amount":1,"reason":"self recharge"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastTransfer == nil || walletClient.lastTransfer.GetSellerUserId() != 42 || walletClient.lastTransfer.GetTargetUserId() != 42 || walletClient.lastTransfer.GetSellerRegionId() != 688 || walletClient.lastTransfer.GetTargetRegionId() != 688 || walletClient.lastTransfer.GetAmount() != 1 { t.Fatalf("self wallet transfer request mismatch: %+v", walletClient.lastTransfer) } if len(profileClient.getRequests) != 1 || profileClient.getRequests[0].GetUserId() != 42 { t.Fatalf("self transfer should only lookup seller profile once: %+v", profileClient.getRequests) } } func TestResolveDisplayUserIDIncludesProfileForH5Lookup(t *testing.T) { identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163000": 42}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: { UserId: 42, DisplayUserId: "163000", Username: "xcxcxcxc", Avatar: "https://cdn.example/avatar.png", RegionId: 688, Status: userv1.UserStatus_USER_STATUS_ACTIVE, }, }} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) if handler.userProfileClient == nil { t.Fatal("profile client was not attached to gateway handler") } router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-display-user-id/163000", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163000" { t.Fatalf("resolve request mismatch: %+v", identityClient.lastResolve) } if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 { t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data := response.Data.(map[string]any) profile := data["profile"].(map[string]any) identity := data["identity"].(map[string]any) if data["display_user_id"] != "163000" || identity["display_user_id"] != "163000" || profile["username"] != "xcxcxcxc" || profile["avatar"] != "https://cdn.example/avatar.png" { t.Fatalf("resolve display user response mismatch: %+v", data) } } func TestCoinSellerTransferRejectsCrossRegionBeforeWallet(t *testing.T) { walletClient := &fakeWalletClient{} hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{ UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN", }} identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163002": 900002}} profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 1002}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) handler.SetWalletClient(walletClient) handler.SetUserHostClient(hostClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"command_id":"cmd-seller-cross-region","target_user_id":163002,"amount":80000,"reason":"player recharge"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-coin-seller-cross-region") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if walletClient.lastTransfer != nil { t.Fatalf("cross-region transfer must not reach wallet-service: %+v", walletClient.lastTransfer) } } func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"country":"US"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-country") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastCountry == nil || profileClient.lastCountry.GetUserId() != 42 || profileClient.lastCountry.GetCountry() != "US" { t.Fatalf("country change request mismatch: %+v", profileClient.lastCountry) } profileClient = &fakeUserProfileClient{countryErr: xerr.ToGRPCError(xerr.New(xerr.CountryChangeCooldown, "country change is cooling down"))} router = NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request = httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-country-cooldown") recorder = httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "country change is cooling down", "req-country-cooldown") } func TestChangeCountryRemovesOldRegionBroadcastMember(t *testing.T) { profileClient := &fakeUserProfileClient{} broadcastClient := &fakeBroadcastClient{} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) handler.SetBroadcastClient(broadcastClient) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"country":"US"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-country-region") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if broadcastClient.lastRemove == nil { t.Fatalf("expected old region broadcast member removal") } if broadcastClient.lastRemove.GetUserId() != 42 || broadcastClient.lastRemove.GetRegionId() != 1001 || broadcastClient.lastRemove.GetMeta().GetAppCode() != "lalu" { t.Fatalf("remove request mismatch: %+v", broadcastClient.lastRemove) } } func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG","invite_code":"A1B2C3"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-onboarding") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete) } if profileClient.lastComplete.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-onboarding") || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" || profileClient.lastComplete.GetInviteCode() != "A1B2C3" { t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete) } if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" { t.Fatalf("complete onboarding must propagate current session_id: %+v", profileClient.lastComplete.GetMeta()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" { t.Fatalf("unexpected onboarding response: %+v", response) } token, ok := data["token"].(map[string]any) if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true { t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"]) } invite, ok := data["invite"].(map[string]any) if !ok || invite["bound"] != true || invite["invite_code"] != "A1B2C3" || invite["inviter_user_id"] != "10001" { t.Fatalf("complete onboarding invite response mismatch: %+v", data["invite"]) } } func TestCompleteOnboardingReturnsInvalidInviteCodeMessage(t *testing.T) { profileClient := &fakeUserProfileClient{ completeErr: xerr.ToGRPCError(xerr.New(xerr.InvalidInviteCode, "invalid invite code")), } router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG","invite_code":"BADCODE"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-onboarding-invalid-invite") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusBadRequest, string(xerr.InvalidInviteCode), "invalid invite code", "req-onboarding-invalid-invite") } func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) { tests := []struct { name string path string body string }{ {name: "profile_update", path: "/api/v1/users/me/profile/update", body: `{"username":"hy"}`}, {name: "contact_info", path: "/api/v1/users/me/contact-info", body: `{"contact_info":"+65"}`}, {name: "country_change", path: "/api/v1/users/me/country/change", body: `{"country":"US"}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-profile-mutation") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-profile-mutation") if profileClient.lastUpdate != nil || profileClient.lastCountry != nil { t.Fatalf("profile gate must stop mutation before user-service: update=%+v country=%+v", profileClient.lastUpdate, profileClient.lastCountry) } }) } } func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testing.T) { tests := []struct { name string method string path string body string }{ {name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"}, {name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`}, {name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1"}`}, {name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`}, {name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`}, {name: "mic_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/mic/heartbeat", body: `{"room_id":"room-1","mic_session_id":"mic-1"}`}, {name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"}, {name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`}, {name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`}, {name: "message_tabs", method: http.MethodGet, path: "/api/v1/messages/tabs"}, {name: "message_list", method: http.MethodGet, path: "/api/v1/messages?section=system"}, {name: "message_read", method: http.MethodPost, path: "/api/v1/messages/msg-1/read"}, {name: "message_read_all", method: http.MethodPost, path: "/api/v1/messages/read-all", body: `{"section":"system"}`}, {name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"}, {name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`}, {name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { roomClient := &fakeRoomClient{} handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, }) handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour}) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-profile-required") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-profile-required") if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || roomClient.lastGift != nil { t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient) } }) } } func TestVoiceRoomRoutesRejectWrongHTTPMethod(t *testing.T) { roomClient := &fakeRoomClient{} queryClient := &fakeRoomQueryClient{} guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}} handler := NewHandlerWithConfig(roomClient, guardClient, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, }) handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour}) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) tests := []struct { name string method string path string body string }{ {name: "rooms_list", method: http.MethodPost, path: "/api/v1/rooms"}, {name: "current_room", method: http.MethodPost, path: "/api/v1/rooms/current"}, {name: "join_room", method: http.MethodGet, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`}, {name: "heartbeat", method: http.MethodGet, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`}, {name: "im_usersig", method: http.MethodPost, path: "/api/v1/im/usersig"}, {name: "rtc_token", method: http.MethodGet, path: "/api/v1/rtc/token"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-method-guard") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "req-method-guard") }) } if roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || queryClient.lastList != nil || queryClient.lastCurrent != nil || guardClient.lastPresence != nil { t.Fatalf("method guard must stop voice-room calls before downstream clients: room=%+v query=%+v guard=%+v", roomClient, queryClient, guardClient) } } func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) { uploader := &fakeObjectUploader{} handler := NewHandler(&fakeRoomClient{}) handler.SetObjectUploader(uploader) router := handler.Routes(auth.NewVerifier("secret")) payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...) body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", payload) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-upload-avatar") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode upload response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected upload envelope: %+v", response) } if !strings.HasPrefix(uploader.lastKey, "app/avatars/42/") || !strings.HasSuffix(uploader.lastKey, ".png") { t.Fatalf("unexpected avatar object key: %q", uploader.lastKey) } if string(uploader.lastContent) != string(payload) || uploader.lastSizeBytes != int64(len(payload)) || uploader.lastContentType != "image/png" { t.Fatalf("uploaded object mismatch: key=%s size=%d type=%s body=%x", uploader.lastKey, uploader.lastSizeBytes, uploader.lastContentType, uploader.lastContent) } if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/png" { t.Fatalf("unexpected upload data: %+v", data) } } func TestUploadRegistrationAvatarAllowsPublicPendingAuthUpload(t *testing.T) { uploader := &fakeObjectUploader{} handler := NewHandler(&fakeRoomClient{}) handler.SetObjectUploader(uploader) router := handler.Routes(auth.NewVerifier("secret")) payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...) body, contentType := multipartUploadBody(t, "avatar", "avatar.png", "image/png", payload) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/registration/avatar/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("X-Request-ID", "req-upload-register-avatar") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode upload response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected upload envelope: %+v", response) } if !strings.HasPrefix(uploader.lastKey, "app/avatars/registration/") || !strings.HasSuffix(uploader.lastKey, ".png") { t.Fatalf("unexpected registration avatar object key: %q", uploader.lastKey) } if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/png" { t.Fatalf("unexpected upload data: %+v", data) } } func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) { uploader := &fakeObjectUploader{} handler := NewHandler(&fakeRoomClient{}) handler.SetObjectUploader(uploader) router := handler.Routes(auth.NewVerifier("secret")) body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", []byte("not an image")) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-upload-avatar-invalid") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-upload-avatar-invalid") if uploader.lastKey != "" { t.Fatalf("invalid avatar must not be uploaded: %+v", uploader) } } func TestUploadFileRequiresObjectUploader(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) body, contentType := multipartUploadBody(t, "file", "document.txt", "text/plain", []byte("hello")) request := httptest.NewRequest(http.MethodPost, "/api/v1/files/upload", body) request.Header.Set("Content-Type", contentType) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-upload-disabled") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-upload-disabled") } func TestAuthLoginMapsGRPCReason(t *testing.T) { userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"account":"100001","password":"bad","device_id":"ios"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-auth-failed") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.AuthFailed), "req-auth-failed") } func TestThirdPartyLoginMapsDeviceAlreadyRegisteredMessage(t *testing.T) { userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.DeviceAlreadyRegistered, "you device already register 2 account 6400,168430, login failed"))} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"android_91d831f9fa382518","platform":"android"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-device-registered") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.DeviceAlreadyRegistered), "you device already register 2 account 6400,168430, login failed", "req-device-registered") } func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) { // room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。 statusCode, code, message := httpkit.MapReasonToHTTP(xerr.Conflict) if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "request cannot be completed in current state" { t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message) } } func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`))) request.Header.Set("X-Request-ID", "req-auth") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusUnauthorized, httpkit.CodeUnauthorized, "req-auth") } func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidJSON, "req-json") } func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-scoped","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-generated-room-id") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if client.lastCreate == nil { t.Fatal("CreateRoom must be forwarded after gateway generates room_id") } if roomID := client.lastCreate.GetMeta().GetRoomId(); !strings.HasPrefix(roomID, "lalu_") || roomID == "room:1" { t.Fatalf("client room_id must not be forwarded: %q", roomID) } } func TestCreateRoomAllowsMissingSeatCount(t *testing.T) { client := &fakeRoomClient{} router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-default-seat","mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-create-default-seat") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if client.lastCreate == nil || client.lastCreate.GetSeatCount() != 0 { t.Fatalf("missing seat_count should forward zero to room-service default config: %+v", client.lastCreate) } } func TestUpdateRoomProfileForwardsOptionalFields(t *testing.T) { client := &fakeRoomClient{} router := NewHandler(client).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-1","room_name":" New Room ","room_avatar":" https://cdn.example.com/new.png ","room_description":" new desc ","seat_count":20}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile-update") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } req := client.lastUpdateProfile if req == nil { t.Fatal("UpdateRoomProfile must be forwarded") } if req.GetMeta().GetRoomId() != "room-1" || req.GetMeta().GetCommandId() != "cmd-profile-1" || req.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-update") || req.GetMeta().GetActorUserId() != 42 { t.Fatalf("UpdateRoomProfile meta mismatch: %+v", req.GetMeta()) } if req.RoomName == nil || req.GetRoomName() != "New Room" || req.RoomAvatar == nil || req.GetRoomAvatar() != "https://cdn.example.com/new.png" || req.RoomDescription == nil || req.GetRoomDescription() != "new desc" || req.SeatCount == nil || req.GetSeatCount() != 20 { t.Fatalf("UpdateRoomProfile optional fields mismatch: %+v", req) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if !ok || data["server_time_ms"] == nil || data["room"] == nil || data["seats"] == nil { t.Fatalf("profile update response should include result room seats and server_time_ms: %+v", response.Data) } } func TestUpdateRoomProfileRPCErrorWritesEnvelope(t *testing.T) { client := &fakeRoomClient{updateProfileErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "removed seats must be empty and unlocked"))} router := NewHandler(client).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-conflict","seat_count":10}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-profile-conflict") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-profile-conflict") } func TestRoomBackgroundRoutesForwardOwnerRequests(t *testing.T) { client := &fakeRoomClient{} queryClient := &fakeRoomQueryClient{backgroundsResp: &roomv1.ListRoomBackgroundsResponse{ Backgrounds: []*roomv1.RoomBackgroundImage{{ BackgroundId: 101, RoomId: "room-1", ImageUrl: "https://cdn.example.com/bg.png", CreatedByUserId: 42, CreatedAtMs: 1_700_000_000_003, UpdatedAtMs: 1_700_000_000_003, }}, SelectedBackgroundUrl: "https://cdn.example.com/bg.png", ServerTimeMs: 1_700_000_000_004, }} handler := NewHandler(client) handler.SetRoomQueryClient(queryClient) router := handler.Routes(auth.NewVerifier("secret")) saveReq := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/backgrounds/save", bytes.NewReader([]byte(`{"room_id":"room-1","image_url":" https://cdn.example.com/bg.png "}`))) saveReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) saveReq.Header.Set("X-Request-ID", "req-bg-save") saveRecorder := httptest.NewRecorder() router.ServeHTTP(saveRecorder, saveReq) if saveRecorder.Code != http.StatusOK { t.Fatalf("save status mismatch: got %d body=%s", saveRecorder.Code, saveRecorder.Body.String()) } if client.lastSaveBackground == nil || client.lastSaveBackground.GetRoomId() != "room-1" || client.lastSaveBackground.GetImageUrl() != "https://cdn.example.com/bg.png" || client.lastSaveBackground.GetMeta().GetActorUserId() != 42 { t.Fatalf("SaveRoomBackground forwarding mismatch: %+v", client.lastSaveBackground) } listReq := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-1/backgrounds", nil) listReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) listReq.Header.Set("X-Request-ID", "req-bg-list") listRecorder := httptest.NewRecorder() router.ServeHTTP(listRecorder, listReq) if listRecorder.Code != http.StatusOK { t.Fatalf("list status mismatch: got %d body=%s", listRecorder.Code, listRecorder.Body.String()) } if queryClient.lastBackgrounds == nil || queryClient.lastBackgrounds.GetRoomId() != "room-1" || queryClient.lastBackgrounds.GetViewerUserId() != 42 { t.Fatalf("ListRoomBackgrounds forwarding mismatch: %+v", queryClient.lastBackgrounds) } setReq := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/backgrounds/set", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-bg-set","background_id":"101"}`))) setReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) setReq.Header.Set("X-Request-ID", "req-bg-set") setRecorder := httptest.NewRecorder() router.ServeHTTP(setRecorder, setReq) if setRecorder.Code != http.StatusOK { t.Fatalf("set status mismatch: got %d body=%s", setRecorder.Code, setRecorder.Body.String()) } if client.lastSetBackground == nil || client.lastSetBackground.GetMeta().GetRoomId() != "room-1" || client.lastSetBackground.GetMeta().GetCommandId() != "cmd-bg-set" || client.lastSetBackground.GetBackgroundId() != 101 { t.Fatalf("SetRoomBackground forwarding mismatch: %+v", client.lastSetBackground) } var envelope httpkit.ResponseEnvelope if err := json.NewDecoder(setRecorder.Body).Decode(&envelope); err != nil { t.Fatalf("decode set response failed: %v", err) } data, ok := envelope.Data.(map[string]any) if !ok { t.Fatalf("set response data mismatch: %+v", envelope.Data) } room, ok := data["room"].(map[string]any) if !ok || room["background_url"] != "https://cdn.example.com/bg.png" { t.Fatalf("set response must expose selected background_url: %+v", data["room"]) } } func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) { tests := []struct { name string body string }{ {name: "command_id", body: `{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`}, {name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`}, {name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice","room_avatar":"https://cdn.example.com/room.png"}`}, {name: "room_avatar", body: `{"room_id":"room-1","command_id":"cmd-create-missing-avatar","seat_count":10,"mode":"voice","room_name":"Lobby"}`}, {name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client := &fakeRoomClient{} router := NewHandler(client).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(test.body))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-create-required-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-create-required-"+test.name) if client.lastCreate != nil { t.Fatalf("missing required create field must not be forwarded: %+v", client.lastCreate) } }) } } func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) { router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-1","command_id":"cmd-create-upstream","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-upstream") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-upstream") } func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) { roomClient := &fakeRoomClient{ createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")), } router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-owner-2","command_id":"cmd-create-owner-conflict","seat_count":10,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-owner-conflict") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-owner-conflict") } func TestJoinRoomClosedWritesRoomClosedEnvelope(t *testing.T) { roomClient := &fakeRoomClient{ joinErr: xerr.ToGRPCError(xerr.New(xerr.RoomClosed, "room closed")), } router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret")) body := []byte(`{"room_id":"room-closed","command_id":"cmd-join-closed"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-room-closed") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.RoomClosed), "room closed", "req-room-closed") } func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, }, profileClient).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-usersig") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" { t.Fatalf("unexpected usersig response: %+v", response) } groups, ok := data["join_groups"].([]any) if !ok || len(groups) != 2 { t.Fatalf("join_groups mismatch: %+v", data["join_groups"]) } regionGroup, ok := groups[1].(map[string]any) if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_v2_1001" || regionGroup["type"] != "region_broadcast" { t.Fatalf("region join group mismatch: %+v", groups[1]) } prefixedRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, GroupIDPrefix: "test_", }, profileClient).Routes(auth.NewVerifier("secret")) prefixedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) prefixedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) prefixedRecorder := httptest.NewRecorder() prefixedRouter.ServeHTTP(prefixedRecorder, prefixedRequest) if prefixedRecorder.Code != http.StatusOK { t.Fatalf("prefixed status mismatch: got %d body=%s", prefixedRecorder.Code, prefixedRecorder.Body.String()) } var prefixedResponse httpkit.ResponseEnvelope if err := json.NewDecoder(prefixedRecorder.Body).Decode(&prefixedResponse); err != nil { t.Fatalf("decode prefixed response failed: %v", err) } prefixedData, ok := prefixedResponse.Data.(map[string]any) if !ok { t.Fatalf("prefixed response data mismatch: %+v", prefixedResponse) } prefixedGroups, ok := prefixedData["join_groups"].([]any) if !ok || len(prefixedGroups) != 2 { t.Fatalf("prefixed join_groups mismatch: %+v", prefixedData["join_groups"]) } prefixedRegionGroup, ok := prefixedGroups[1].(map[string]any) if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_v2_1001" { t.Fatalf("prefixed region join group mismatch: %+v", prefixedGroups[1]) } missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, UserSigTTL: time.Hour, }, profileClient).Routes(auth.NewVerifier("secret")) missingConfigRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) missingConfigRequest.Header.Set("X-Request-ID", "req-usersig-missing") missingConfigRecorder := httptest.NewRecorder() missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest) assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing") } func TestTencentIMUserSigEnsuresImportedAccount(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} importer := &fakeTencentIMAccountImporter{} handler := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, }, profileClient) handler.SetTencentIMAccountImporter(importer) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-usersig-import") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } if importer.lastUserID != 42 || importer.lastNickname != "hy" || importer.lastFaceURL != "https://cdn.example/avatar.png" { t.Fatalf("unexpected imported account: %+v", importer) } importer.err = errors.New("tencent unavailable") failedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil) failedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) failedRequest.Header.Set("X-Request-ID", "req-usersig-import-failed") failedRecorder := httptest.NewRecorder() router.ServeHTTP(failedRecorder, failedRequest) assertEnvelope(t, failedRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-import-failed") } func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) { previousNow := roomapi.TimeNow roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) } defer func() { roomapi.TimeNow = previousNow }() guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}} handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: 2 * time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-rtc-token") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } data, ok := response.Data.(map[string]any) if response.Code != httpkit.CodeOK || !ok { t.Fatalf("unexpected envelope: %+v", response) } if data["user_id"] != "42" || data["rtc_user_id"] != "42" || data["room_id"] != "room_1001" || data["str_room_id"] != "room_1001" { t.Fatalf("unexpected rtc identity mapping: %+v", data) } if data["rtc_room_id_type"] != "string" || data["app_scene"] != "voice_chat_room" || data["role"] != "audience" { t.Fatalf("unexpected rtc policy fields: %+v", data) } if data["user_sig"] == "" || int64(data["expire_at_ms"].(float64)) != time.Unix(1_700_000_000, 0).Add(2*time.Hour).UnixMilli() { t.Fatalf("unexpected rtc usersig fields: %+v", data) } if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room_1001" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-token") { t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence) } } func TestTencentRTCTokenRejectsInvalidRoomIDBeforeGuard(t *testing.T) { guard := &fakeRoomGuardClient{} handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room:1001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-rtc-invalid") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-rtc-invalid") if guard.lastPresence != nil { t.Fatalf("invalid room_id must not call presence guard: %+v", guard.lastPresence) } } func TestTencentRTCTokenMapsPresenceDenialReasons(t *testing.T) { tests := []struct { name string reason string statusCode int code string }{ {name: "not_in_room", reason: "not_in_room", statusCode: http.StatusForbidden, code: httpkit.CodePermissionDenied}, {name: "user_banned", reason: "user_banned", statusCode: http.StatusForbidden, code: httpkit.CodePermissionDenied}, {name: "room_not_found", reason: "room_not_found", statusCode: http.StatusNotFound, code: httpkit.CodeNotFound}, {name: "room_closed", reason: "room_closed", statusCode: http.StatusConflict, code: string(xerr.RoomClosed)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: test.reason, RoomVersion: 3}} handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("X-Request-ID", "req-rtc-"+test.name) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertEnvelope(t, recorder, test.statusCode, test.code, "req-rtc-"+test.name) }) } } func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) { missingConfigGuard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}} missingConfigHandler := NewHandlerWithConfig(&fakeRoomClient{}, missingConfigGuard, nil, nil, TencentIMConfig{}) missingConfigHandler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) missingConfigRouter := missingConfigHandler.Routes(auth.NewVerifier("secret")) missingConfigRequest := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) missingConfigRequest.Header.Set("X-Request-ID", "req-rtc-config") missingConfigRecorder := httptest.NewRecorder() missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest) assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-rtc-config") if missingConfigGuard.lastPresence != nil { t.Fatalf("missing rtc config must fail before room-service guard: %+v", missingConfigGuard.lastPresence) } upstreamGuard := &fakeRoomGuardClient{err: errors.New("room-service unavailable")} upstreamHandler := NewHandlerWithConfig(&fakeRoomClient{}, upstreamGuard, nil, nil, TencentIMConfig{}) upstreamHandler.SetTencentRTC(TencentRTCConfig{ Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour, RoomIDType: "string", AppScene: "voice_chat_room", }) upstreamRouter := upstreamHandler.Routes(auth.NewVerifier("secret")) upstreamRequest := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`))) upstreamRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) upstreamRequest.Header.Set("X-Request-ID", "req-rtc-upstream") upstreamRecorder := httptest.NewRecorder() upstreamRouter.ServeHTTP(upstreamRecorder, upstreamRequest) assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-rtc-upstream") } func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) { roomClient := &fakeRoomClient{} handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ SDKAppID: 1400000000, CallbackSignKey: "callback-key", }) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"EventGroupId":2,"EventType":203,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback?app_code=lalu", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-rtc-callback") request.Header.Set("SdkAppId", "1400000000") request.Header.Set("Sign", signTencentRTCCallback("callback-key", body)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentRTCCallback(t, recorder, http.StatusOK, 0) if roomClient.lastRTCEvent == nil { t.Fatal("RTC callback must forward known events to room-service") } if roomClient.lastRTCEvent.GetEventType() != "audio_started" || roomClient.lastRTCEvent.GetTargetUserId() != 42 || roomClient.lastRTCEvent.GetEventTimeMs() != 1700000001000 { t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent) } meta := roomClient.lastRTCEvent.GetMeta() if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-callback") || meta.GetAppCode() != "lalu" { t.Fatalf("RTC event meta mismatch: %+v", meta) } if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") { t.Fatalf("RTC callback must derive stable ids: meta=%+v event=%+v", meta, roomClient.lastRTCEvent) } } func TestTencentRTCCallbackRejectsInvalidSignatureAndIgnoresUnknownEvents(t *testing.T) { roomClient := &fakeRoomClient{} handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{}) handler.SetTencentRTC(TencentRTCConfig{ SDKAppID: 1400000000, CallbackSignKey: "callback-key", }) router := handler.Routes(auth.NewVerifier("secret")) body := []byte(`{"EventGroupId":2,"EventType":204,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42","Reason":0}}`) badRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(body)) badRequest.Header.Set("SdkAppId", "1400000000") badRequest.Header.Set("Sign", "bad-sign") badRecorder := httptest.NewRecorder() router.ServeHTTP(badRecorder, badRequest) assertTencentRTCCallback(t, badRecorder, http.StatusForbidden, 1) if roomClient.lastRTCEvent != nil { t.Fatalf("invalid signature must not reach room-service: %+v", roomClient.lastRTCEvent) } unknownBody := []byte(`{"EventGroupId":2,"EventType":201,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`) unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(unknownBody)) unknownRequest.Header.Set("SdkAppId", "1400000000") unknownRequest.Header.Set("Sign", signTencentRTCCallback("callback-key", unknownBody)) unknownRecorder := httptest.NewRecorder() router.ServeHTTP(unknownRecorder, unknownRequest) assertTencentRTCCallback(t, unknownRecorder, http.StatusOK, 0) if roomClient.lastRTCEvent != nil { t.Fatalf("unknown RTC event must be acknowledged without room-service call: %+v", roomClient.lastRTCEvent) } } func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) { guard := &fakeRoomGuardClient{} router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, CallbackAuthToken: "callback-token", }).Routes(auth.NewVerifier("secret")) body := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"room-1","Requestor_Account":"42"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-im-join") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentCallback(t, recorder, http.StatusOK, 0) if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room-1" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-im-join") { t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence) } guard.presenceResp = &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: "not_in_room"} deniedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body)) deniedRecorder := httptest.NewRecorder() router.ServeHTTP(deniedRecorder, deniedRequest) assertTencentCallback(t, deniedRecorder, http.StatusOK, 1) } func TestTencentIMSendCallbackUsesSpeakGuard(t *testing.T) { guard := &fakeRoomGuardClient{ speakResp: &roomv1.CheckSpeakPermissionResponse{Allowed: false, Reason: "user_muted"}, } router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, }).Routes(auth.NewVerifier("secret")) body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"42"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(body)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentCallback(t, recorder, http.StatusOK, 1) if guard.lastSpeak == nil || guard.lastSpeak.GetRoomId() != "room-1" || guard.lastSpeak.GetUserId() != 42 { t.Fatalf("speak guard request mismatch: %+v", guard.lastSpeak) } } func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} guard := &fakeRoomGuardClient{} router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, AdminIdentifier: "administrator", }, profileClient).Routes(auth.NewVerifier("secret")) joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`) joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(joinBody)) joinRecorder := httptest.NewRecorder() router.ServeHTTP(joinRecorder, joinRequest) assertTencentCallback(t, joinRecorder, http.StatusOK, 0) if guard.lastPresence != nil { t.Fatalf("broadcast join must not call room guard: %+v", guard.lastPresence) } mismatchBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1002","Requestor_Account":"42"}`) mismatchRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(mismatchBody)) mismatchRecorder := httptest.NewRecorder() router.ServeHTTP(mismatchRecorder, mismatchRequest) assertTencentCallback(t, mismatchRecorder, http.StatusOK, 1) userSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"42"}`) userSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(userSendBody)) userSendRecorder := httptest.NewRecorder() router.ServeHTTP(userSendRecorder, userSendRequest) assertTencentCallback(t, userSendRecorder, http.StatusOK, 1) adminSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"administrator"}`) adminSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(adminSendBody)) adminSendRecorder := httptest.NewRecorder() router.ServeHTTP(adminSendRecorder, adminSendRequest) assertTencentCallback(t, adminSendRecorder, http.StatusOK, 0) } func TestTencentIMBroadcastCallbacksUseConfiguredGroupPrefix(t *testing.T) { profileClient := &fakeUserProfileClient{regionID: 1001} router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, AdminIdentifier: "administrator", GroupIDPrefix: "test_", }, profileClient).Routes(auth.NewVerifier("secret")) joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"test_hy_lalu_bc_r_1001","Requestor_Account":"42"}`) joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(joinBody)) joinRecorder := httptest.NewRecorder() router.ServeHTTP(joinRecorder, joinRequest) assertTencentCallback(t, joinRecorder, http.StatusOK, 0) oldGroupBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`) oldGroupRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(oldGroupBody)) oldGroupRecorder := httptest.NewRecorder() router.ServeHTTP(oldGroupRecorder, oldGroupRequest) assertTencentCallback(t, oldGroupRecorder, http.StatusOK, 1) } func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) { router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{ SDKAppID: 1400000000, CallbackAuthToken: "callback-token", }).Routes(auth.NewVerifier("secret")) body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"42"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&CallbackAuthToken=bad-token", bytes.NewReader(body)) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) assertTencentCallback(t, recorder, http.StatusOK, 1) unknownBody := []byte(`{"CallbackCommand":"Group.CallbackAfterSendMsg","GroupId":"room-1","From_Account":"42"}`) unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&CallbackAuthToken=callback-token", bytes.NewReader(unknownBody)) unknownRecorder := httptest.NewRecorder() router.ServeHTTP(unknownRecorder, unknownRequest) assertTencentCallback(t, unknownRecorder, http.StatusOK, 0) } func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, requestID string) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } generatedRequestID := assertGeneratedRequestID(t, recorder, requestID) if response.Code != code || response.RequestID != generatedRequestID { t.Fatalf("unexpected envelope: %+v", response) } } func mustAppEventsBody(t *testing.T, count int) []byte { t.Helper() events := make([]map[string]any, 0, count) for i := 0; i < count; i++ { events = append(events, map[string]any{ "event_id": "evt-" + strconv.Itoa(i), "event_name": "open", "device_id": "dev-1", }) } payload, err := json.Marshal(map[string]any{"events": events}) if err != nil { t.Fatalf("marshal app events body failed: %v", err) } return payload } func assertEnvelopeMessage(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, message string, requestID string) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response httpkit.ResponseEnvelope if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode response failed: %v", err) } generatedRequestID := assertGeneratedRequestID(t, recorder, requestID) if response.Code != code || response.Message != message || response.RequestID != generatedRequestID { t.Fatalf("unexpected envelope: %+v", response) } } func assertGeneratedRequestID(t *testing.T, recorder *httptest.ResponseRecorder, inboundRequestID string) string { t.Helper() requestID := recorder.Header().Get("X-Request-ID") if requestID == "" || requestID == inboundRequestID { t.Fatalf("request_id must be generated by gateway, got %q", requestID) } return requestID } func assertTencentCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, errorCode int) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response struct { ActionStatus string `json:"ActionStatus"` ErrorInfo string `json:"ErrorInfo"` ErrorCode int `json:"ErrorCode"` } if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode tencent callback response failed: %v", err) } if response.ActionStatus != "OK" || response.ErrorCode != errorCode { t.Fatalf("unexpected tencent callback response: %+v", response) } } func assertTencentRTCCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code int) { t.Helper() if recorder.Code != statusCode { t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String()) } var response struct { Code int `json:"code"` Message string `json:"message,omitempty"` } if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { t.Fatalf("decode tencent rtc callback response failed: %v", err) } if response.Code != code { t.Fatalf("unexpected tencent rtc callback response: %+v", response) } } func signTencentRTCCallback(key string, body []byte) string { mac := hmac.New(sha256.New, []byte(key)) _, _ = mac.Write(body) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } func int64SliceToStrings(values []int64) []string { items := make([]string, 0, len(values)) for _, value := range values { items = append(items, strconv.FormatInt(value, 10)) } return items } func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig { config := AuthRateLimitConfig{ Enabled: true, Window: time.Minute, IPLimit: 1000, DeviceIDLimit: 1000, ProviderIPLimit: 1000, DisplayUserIDIPLimit: 1000, DisplayUserIDLimit: 1000, SessionIDIPLimit: 1000, } mutator(&config) return config } func signGatewayToken(t *testing.T, secret string, userID int64) string { t.Helper() return signGatewayTokenWithProfile(t, secret, userID, true) } func signGatewayTokenWithProfile(t *testing.T, secret string, userID int64, profileCompleted bool) string { t.Helper() return signGatewayTokenWithExpiresAt(t, secret, userID, profileCompleted, time.Now().Add(time.Hour)) } func signGatewayTokenWithAppCode(t *testing.T, secret string, userID int64, appCode string) string { t.Helper() return signGatewayTokenWithAppCodeAndExpiresAt(t, secret, userID, appCode, true, time.Now().Add(time.Hour)) } func signGatewayTokenWithExpiresAt(t *testing.T, secret string, userID int64, profileCompleted bool, expiresAt time.Time) string { t.Helper() return signGatewayTokenWithAppCodeAndExpiresAt(t, secret, userID, "lalu", profileCompleted, expiresAt) } func signGatewayTokenWithAppCodeAndExpiresAt(t *testing.T, secret string, userID int64, appCode string, profileCompleted bool, expiresAt time.Time) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": strconv.FormatInt(userID, 10), "app_code": appCode, "sid": "sess-test", "profile_completed": profileCompleted, "onboarding_status": map[bool]string{true: "completed", false: "profile_required"}[profileCompleted], "exp": expiresAt.Unix(), }) signed, err := token.SignedString([]byte(secret)) if err != nil { t.Fatalf("sign token failed: %v", err) } return signed } func multipartUploadBody(t *testing.T, fieldName string, filename string, contentType string, payload []byte) (*bytes.Buffer, string) { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) header := make(textproto.MIMEHeader) header.Set("Content-Disposition", `form-data; name="`+fieldName+`"; filename="`+filename+`"`) header.Set("Content-Type", contentType) part, err := writer.CreatePart(header) if err != nil { t.Fatalf("create multipart part failed: %v", err) } if _, err := part.Write(payload); err != nil { t.Fatalf("write multipart payload failed: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close multipart writer failed: %v", err) } return &body, writer.FormDataContentType() }