2026-07-05 01:55:03 +08:00

11159 lines
484 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
lastGetGiftCatalog *walletv1.GetGiftCatalogVersionRequest
getGiftCatalogCalls int
giftCatalogVersion int64
giftCatalogVersionResp *walletv1.GetGiftCatalogVersionResponse
giftCatalogVersionErr error
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) GetGiftCatalogVersion(_ context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
f.lastGetGiftCatalog = req
f.getGiftCatalogCalls++
if f.giftCatalogVersionErr != nil {
return nil, f.giftCatalogVersionErr
}
if f.err != nil {
return nil, f.err
}
if f.giftCatalogVersionResp != nil {
return f.giftCatalogVersionResp, nil
}
return &walletv1.GetGiftCatalogVersionResponse{Version: f.giftCatalogVersion}, 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&region_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&region_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&region_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}}},
giftCatalogVersion: 1,
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"))
serveGiftPanel := func(attempt int) *httptest.ResponseRecorder {
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())
}
return recorder
}
for attempt := 0; attempt < 2; attempt++ {
serveGiftPanel(attempt)
}
if walletClient.getGiftCatalogCalls != 2 || walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 {
t.Fatalf("gift panel same-version config should be cached, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, 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.lastGetGiftCatalog == nil || walletClient.lastGetGiftCatalog.GetAppCode() != "lalu" {
t.Fatalf("gift panel catalog version request mismatch: %+v", walletClient.lastGetGiftCatalog)
}
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)
}
lilyResource := &walletv1.Resource{
ResourceId: 12,
ResourceCode: "gift_lily",
ResourceType: "gift",
Name: "Lily",
Status: "active",
UsageScopes: []string{"gift"},
AssetUrl: "https://cdn.example/lily.svga",
SortOrder: 2,
}
walletClient.giftCatalogVersion = 2
walletClient.listGiftTypesResp = &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
TypeCode: "lucky",
Name: "幸运礼物",
TabKey: "Lucky",
Status: "active",
SortOrder: 20,
}}}
walletClient.listGiftConfigsResp = &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
GiftId: "lily",
ResourceId: lilyResource.GetResourceId(),
Resource: lilyResource,
Status: "active",
Name: "Lily",
GiftTypeCode: "lucky",
PriceVersion: "v2",
ChargeAssetType: "COIN",
CoinPrice: 200,
HeatValue: 200,
RegionIds: []int64{0, 1001},
}}, Total: 1}
recorder := serveGiftPanel(2)
if walletClient.getGiftCatalogCalls != 3 || walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 {
t.Fatalf("gift panel changed-version config should be refetched, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
}
if len(walletClient.balanceRequests) != 3 || walletClient.listUserResourcesCalls != 3 {
t.Fatalf("gift panel realtime data must stay per-request after version change, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Gifts []struct {
GiftID string `json:"gift_id"`
GiftTypeCode string `json:"gift_type_code"`
CoinPrice int64 `json:"coin_price"`
} `json:"gifts"`
} `json:"data"`
}
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode changed-version gift panel failed: %v", err)
}
if envelope.Code != httpkit.CodeOK || len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "lucky" || envelope.Data.Gifts[0].CoinPrice != 200 {
t.Fatalf("gift panel changed-version response should use refetched config: %+v", envelope)
}
}
func TestRoomGiftPanelFallsBackWhenGiftCatalogVersionIsUnimplemented(t *testing.T) {
giftResource := &walletv1.Resource{
ResourceId: 11,
ResourceCode: "gift_rose",
ResourceType: "gift",
Name: "Rose",
Status: "active",
UsageScopes: []string{"gift"},
}
walletClient := &fakeWalletClient{
resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
giftCatalogVersionErr: status.Error(codes.Unimplemented, "unknown method GetGiftCatalogVersion"),
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",
CoinPrice: 100,
RegionIds: []int64{0, 1001},
}}, Total: 1},
}
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
RoomId: "room-gift-version-fallback",
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-version-fallback/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 fallback status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
}
}
if walletClient.getGiftCatalogCalls != 2 || walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 {
t.Fatalf("unimplemented catalog version should disable config cache, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
}
if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
t.Fatalf("gift panel fallback realtime data must stay per-request, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
}
}
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()
}