8885 lines
381 KiB
Go
8885 lines
381 KiB
Go
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"
|
||
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/xerr"
|
||
"hyapp/services/gateway-service/internal/appconfig"
|
||
"hyapp/services/gateway-service/internal/auth"
|
||
)
|
||
|
||
// 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
|
||
lastLoginPassword *userv1.LoginPasswordRequest
|
||
lastSetPassword *userv1.SetPasswordRequest
|
||
lastQuickCreate *userv1.QuickCreateAccountRequest
|
||
lastRefresh *userv1.RefreshTokenRequest
|
||
lastLogout *userv1.LogoutRequest
|
||
lastBlocked *userv1.RecordLoginBlockedRequest
|
||
lastAppHeartbeat *userv1.AppHeartbeatRequest
|
||
loginPasswordCount int
|
||
loginThirdCount int
|
||
refreshCount int
|
||
logoutCount int
|
||
blockedCount int
|
||
loginErr error
|
||
}
|
||
|
||
type fakeUserProfileClient struct {
|
||
lastGet *userv1.GetUserRequest
|
||
lastBusinessLookup *userv1.BusinessUserLookupRequest
|
||
lastStats *userv1.GetMyProfileStatsRequest
|
||
lastBatch *userv1.BatchGetUsersRequest
|
||
getRequests []*userv1.GetUserRequest
|
||
lastComplete *userv1.CompleteOnboardingRequest
|
||
lastUpdate *userv1.UpdateUserProfileRequest
|
||
lastBackground *userv1.UpdateUserProfileBackgroundRequest
|
||
lastCountry *userv1.ChangeUserCountryRequest
|
||
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
|
||
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 fakeUserIdentityClient struct {
|
||
lastResolve *userv1.ResolveDisplayUserIDRequest
|
||
resolveByDisplay map[string]int64
|
||
resolveErr error
|
||
}
|
||
|
||
type fakeRoomQueryClient struct {
|
||
lastList *roomv1.ListRoomsRequest
|
||
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
|
||
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
|
||
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 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
|
||
hostProfile *userv1.HostProfile
|
||
hostErr 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
|
||
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
|
||
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
|
||
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
|
||
lastH5Options *walletv1.H5RechargeOptionsRequest
|
||
h5OptionsResp *walletv1.H5RechargeOptionsResponse
|
||
lastH5CreateOrder *walletv1.CreateH5RechargeOrderRequest
|
||
h5CreateOrderResp *walletv1.H5RechargeOrderResponse
|
||
lastH5SubmitTx *walletv1.SubmitH5RechargeTxRequest
|
||
h5SubmitTxResp *walletv1.H5RechargeOrderResponse
|
||
lastH5GetOrder *walletv1.GetH5RechargeOrderRequest
|
||
h5GetOrderResp *walletv1.H5RechargeOrderResponse
|
||
lastMifaPayNotify *walletv1.HandleMifapayNotifyRequest
|
||
mifaPayNotifyResp *walletv1.HandleMifapayNotifyResponse
|
||
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
|
||
lastListResources *walletv1.ListResourcesRequest
|
||
listResourcesResp *walletv1.ListResourcesResponse
|
||
lastGetResource *walletv1.GetResourceRequest
|
||
resourcesByID map[int64]*walletv1.Resource
|
||
lastGetResourceGroup *walletv1.GetResourceGroupRequest
|
||
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
||
lastResourceShop *walletv1.ListResourceShopItemsRequest
|
||
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
||
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||
listGiftTypesResp *walletv1.ListGiftTypeConfigsResponse
|
||
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 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 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++
|
||
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) 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) 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,
|
||
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
|
||
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",
|
||
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) 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) 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) 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) 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 *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
|
||
if f.hostErr != nil {
|
||
return nil, f.hostErr
|
||
}
|
||
if f.hostProfile != nil {
|
||
return &userv1.GetHostProfileResponse{HostProfile: f.hostProfile}, nil
|
||
}
|
||
|
||
return &userv1.GetHostProfileResponse{}, 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) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) {
|
||
f.lastCapability = req
|
||
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) 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) 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) 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) 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) 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) 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{}, nil
|
||
}
|
||
|
||
func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {
|
||
f.lastGetResourceGroup = req
|
||
if f.err != nil {
|
||
return nil, f.err
|
||
}
|
||
if f.resourceGroupsByID != nil {
|
||
return &walletv1.GetResourceGroupResponse{Group: f.resourceGroupsByID[req.GetGroupId()]}, nil
|
||
}
|
||
return &walletv1.GetResourceGroupResponse{}, nil
|
||
}
|
||
|
||
func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
|
||
f.lastResourceShop = req
|
||
if f.err != nil {
|
||
return nil, f.err
|
||
}
|
||
if f.resourceShopResp != nil {
|
||
return f.resourceShopResp, nil
|
||
}
|
||
return &walletv1.ListResourceShopItemsResponse{}, nil
|
||
}
|
||
|
||
func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
|
||
f.lastPurchaseShop = req
|
||
if f.err != nil {
|
||
return nil, f.err
|
||
}
|
||
if f.purchaseShopResp != nil {
|
||
return f.purchaseShopResp, nil
|
||
}
|
||
return &walletv1.PurchaseResourceShopItemResponse{}, nil
|
||
}
|
||
|
||
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||
f.lastListGiftConfigs = req
|
||
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
|
||
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, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||
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 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,
|
||
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: 8800,
|
||
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"`
|
||
} `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 != 8800 {
|
||
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)
|
||
}
|
||
}
|
||
|
||
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
|
||
roomClient := &fakeRoomClient{}
|
||
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}}
|
||
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","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.lastHost == nil || hostClient.lastHost.GetUserId() != 43 {
|
||
t.Fatalf("gateway must query target host profile before sending gift: %+v", hostClient.lastHost)
|
||
}
|
||
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 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 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)
|
||
}
|
||
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 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 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)
|
||
}
|
||
}
|
||
|
||
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) {
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
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,
|
||
RegionID: 1,
|
||
CountryCode: "CN",
|
||
Description: "room banner",
|
||
StartsAtMs: 1700000000000,
|
||
EndsAtMs: 1800000000000,
|
||
UpdatedAtMs: 1700000002000,
|
||
},
|
||
}}}
|
||
handler.SetAppConfigReader(reader)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?display_scope=me&platform=android&country=CN®ion_id=1", nil)
|
||
request.Header.Set("X-Request-ID", "req-app-banners")
|
||
request.Header.Set("X-App-Package", "com.org.laluparty")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data, ok := response.Data.(map[string]any)
|
||
if response.Code != httpkit.CodeOK || !ok {
|
||
t.Fatalf("unexpected envelope: %+v", response)
|
||
}
|
||
if reader.bannerQuery.DisplayScope != "me" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 1 || reader.bannerQuery.Country != "CN" {
|
||
t.Fatalf("banner query mismatch: %+v", reader.bannerQuery)
|
||
}
|
||
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 TestListSplashScreensReturnsAdminAppConfig(t *testing.T) {
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{SplashScreens: []appconfig.SplashScreen{
|
||
{
|
||
ID: 12,
|
||
CoverURL: "https://cdn.example.com/splash.png",
|
||
SplashType: "h5",
|
||
Param: "https://h5.example.com/splash",
|
||
Platform: "ios",
|
||
SortOrder: 2,
|
||
RegionID: 7,
|
||
CountryCode: "BR",
|
||
Description: "launch splash",
|
||
DisplayDurationMs: 4500,
|
||
StartsAtMs: 1700000000000,
|
||
EndsAtMs: 1800000000000,
|
||
UpdatedAtMs: 1700000003000,
|
||
},
|
||
}}}
|
||
handler.SetAppConfigReader(reader)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/splash-screens?platform=ios&country=BR®ion_id=7", nil)
|
||
request.Header.Set("X-Request-ID", "req-app-splash")
|
||
request.Header.Set("X-App-Code", "lalu")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data, ok := response.Data.(map[string]any)
|
||
if response.Code != httpkit.CodeOK || !ok {
|
||
t.Fatalf("unexpected envelope: %+v", response)
|
||
}
|
||
if reader.splashQuery.AppCode != "lalu" || reader.splashQuery.Platform != "ios" || reader.splashQuery.RegionID != 7 || reader.splashQuery.Country != "BR" {
|
||
t.Fatalf("splash query mismatch: %+v", reader.splashQuery)
|
||
}
|
||
if data["total"].(float64) != 1 {
|
||
t.Fatalf("splash total mismatch: %+v", data)
|
||
}
|
||
items, ok := data["items"].([]any)
|
||
if !ok || len(items) != 1 {
|
||
t.Fatalf("splash items shape mismatch: %+v", data)
|
||
}
|
||
first, ok := items[0].(map[string]any)
|
||
if !ok || first["cover_url"] != "https://cdn.example.com/splash.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/splash" {
|
||
t.Fatalf("splash item mismatch: %+v", first)
|
||
}
|
||
if first["platform"] != "ios" || first["sort_order"].(float64) != 2 || first["region_id"].(float64) != 7 || first["country_code"] != "BR" {
|
||
t.Fatalf("splash scope mismatch: %+v", first)
|
||
}
|
||
if first["display_duration_ms"].(float64) != 4500 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000003000 {
|
||
t.Fatalf("splash metadata mismatch: %+v", first)
|
||
}
|
||
}
|
||
|
||
func TestSplashScreenJSONIncludesEmptyScopeFields(t *testing.T) {
|
||
payload, err := json.Marshal(appconfig.SplashScreen{
|
||
ID: 99,
|
||
CoverURL: "https://cdn.example.com/splash.png",
|
||
SplashType: "app",
|
||
Platform: "android",
|
||
UpdatedAtMs: 1700000003000,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("marshal splash screen failed: %v", err)
|
||
}
|
||
var item map[string]any
|
||
if err := json.Unmarshal(payload, &item); err != nil {
|
||
t.Fatalf("unmarshal splash screen failed: %v", err)
|
||
}
|
||
for _, key := range []string{"region_id", "country_code", "description", "display_duration_ms", "starts_at_ms", "ends_at_ms"} {
|
||
if _, ok := item[key]; !ok {
|
||
t.Fatalf("splash json missing %s: %+v", key, item)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestListPopupsReturnsAdminAppConfig(t *testing.T) {
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Popups: []appconfig.Popup{
|
||
{
|
||
ID: 21,
|
||
Code: "new-user-popup",
|
||
Name: "新人活动",
|
||
ImageURL: "https://cdn.example.com/popup.png",
|
||
JumpType: "h5",
|
||
JumpURL: "https://h5.example.com/new-user",
|
||
DisplayPeriodDays: 2,
|
||
SortOrder: 3,
|
||
StartsAtMs: 1700000000000,
|
||
EndsAtMs: 1800000000000,
|
||
UpdatedAtMs: 1700000004000,
|
||
},
|
||
}}}
|
||
handler.SetAppConfigReader(reader)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/popups", nil)
|
||
request.Header.Set("X-Request-ID", "req-app-popups")
|
||
request.Header.Set("X-App-Code", "lalu")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data, ok := response.Data.(map[string]any)
|
||
if response.Code != httpkit.CodeOK || !ok {
|
||
t.Fatalf("unexpected envelope: %+v", response)
|
||
}
|
||
if reader.popupQuery.AppCode != "lalu" {
|
||
t.Fatalf("popup query mismatch: %+v", reader.popupQuery)
|
||
}
|
||
if data["total"].(float64) != 1 {
|
||
t.Fatalf("popup total mismatch: %+v", data)
|
||
}
|
||
items, ok := data["items"].([]any)
|
||
if !ok || len(items) != 1 {
|
||
t.Fatalf("popup items shape mismatch: %+v", data)
|
||
}
|
||
first, ok := items[0].(map[string]any)
|
||
if !ok || first["code"] != "new-user-popup" || first["name"] != "新人活动" || first["jump_type"] != "h5" || first["jump_url"] != "https://h5.example.com/new-user" {
|
||
t.Fatalf("popup item mismatch: %+v", first)
|
||
}
|
||
if first["image_url"] != "https://cdn.example.com/popup.png" || first["display_period_days"].(float64) != 2 || first["sort_order"].(float64) != 3 {
|
||
t.Fatalf("popup display fields mismatch: %+v", first)
|
||
}
|
||
if first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000004000 {
|
||
t.Fatalf("popup metadata mismatch: %+v", first)
|
||
}
|
||
}
|
||
|
||
func TestGetAppVersionReturnsLatestVersionAndUpdateDecision(t *testing.T) {
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
handler.SetAppConfigReader(appconfig.StaticReader{Version: appconfig.Version{
|
||
AppCode: "lalu",
|
||
Platform: "ios",
|
||
Version: "1.2.0",
|
||
BuildNumber: 120,
|
||
ForceUpdate: true,
|
||
DownloadURL: "https://apps.apple.com/app/lalu",
|
||
Description: "bug fixes",
|
||
UpdatedAtMs: 1700000003000,
|
||
}})
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/version?build_number=100", nil)
|
||
request.Header.Set("X-Request-ID", "req-app-version")
|
||
request.Header.Set("X-App-Code", "lalu")
|
||
request.Header.Set("X-App-Platform", "ios")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data, ok := response.Data.(map[string]any)
|
||
if response.Code != httpkit.CodeOK || !ok {
|
||
t.Fatalf("unexpected envelope: %+v", response)
|
||
}
|
||
if data["platform"] != "ios" ||
|
||
data["version"] != "1.2.0" ||
|
||
data["build_number"] != float64(120) ||
|
||
data["current_build_number"] != float64(100) ||
|
||
data["has_update"] != true ||
|
||
data["force_update"] != true ||
|
||
data["download_url"] != "https://apps.apple.com/app/lalu" {
|
||
t.Fatalf("app version response mismatch: %+v", data)
|
||
}
|
||
}
|
||
|
||
func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
|
||
hostClient := &fakeUserHostClient{
|
||
roleSummary: &userv1.UserRoleSummary{
|
||
UserId: 42,
|
||
IsHost: true,
|
||
IsAgency: true,
|
||
IsManager: true,
|
||
IsBd: true,
|
||
IsBdLeader: true,
|
||
IsCoinSeller: true,
|
||
},
|
||
}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
handler.SetUserHostClient(hostClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||
request.Header.Set("X-Request-ID", "req-host-identity")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastRoleSummary.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-host-identity") {
|
||
t.Fatalf("host identity role summary request mismatch: %+v", hostClient.lastRoleSummary)
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data, ok := response.Data.(map[string]any)
|
||
if response.Code != httpkit.CodeOK || !ok ||
|
||
data["is_host"] != true ||
|
||
data["is_agent"] != true ||
|
||
data["is_manager"] != true ||
|
||
data["is_bd"] != true ||
|
||
data["is_bd_leader"] != true ||
|
||
data["is_coin_seller"] != true {
|
||
t.Fatalf("host identity response mismatch: %+v", response)
|
||
}
|
||
}
|
||
|
||
func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) {
|
||
hostClient := &fakeUserHostClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
handler.SetUserHostClient(hostClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||
request.Header.Set("X-Request-ID", "req-host-identity-empty")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data, ok := response.Data.(map[string]any)
|
||
if response.Code != httpkit.CodeOK || !ok ||
|
||
data["is_host"] != false ||
|
||
data["is_agent"] != false ||
|
||
data["is_manager"] != false ||
|
||
data["is_bd"] != false ||
|
||
data["is_bd_leader"] != false ||
|
||
data["is_coin_seller"] != false {
|
||
t.Fatalf("empty host identity response mismatch: %+v", response)
|
||
}
|
||
}
|
||
|
||
func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
||
hostClient := &fakeUserHostClient{searchAgenciesResp: &userv1.SearchAgenciesResponse{Agencies: []*userv1.Agency{
|
||
{
|
||
AgencyId: 7001,
|
||
OwnerUserId: 901,
|
||
RegionId: 30,
|
||
Name: "Admin Seed Agency",
|
||
Avatar: "https://cdn.example/agency.png",
|
||
HostCount: 3,
|
||
Status: "active",
|
||
JoinEnabled: true,
|
||
MaxHosts: 100,
|
||
CreatedAtMs: 1700000000000,
|
||
UpdatedAtMs: 1700000001000,
|
||
},
|
||
}}}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
handler.SetUserHostClient(hostClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=150", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||
request.Header.Set("X-Request-ID", "req-host-agency-search")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 100 {
|
||
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
|
||
}
|
||
var response httpkit.ResponseEnvelope
|
||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
data := response.Data.(map[string]any)
|
||
items := data["items"].([]any)
|
||
first := items[0].(map[string]any)
|
||
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || first["avatar"] != "https://cdn.example/agency.png" || first["host_count"] != float64(3) || data["page_size"] != float64(100) {
|
||
t.Fatalf("host agency search response mismatch: %+v", response)
|
||
}
|
||
if _, exists := first["max_hosts"]; exists {
|
||
t.Fatalf("host agency search response must not expose max_hosts: %+v", first)
|
||
}
|
||
}
|
||
|
||
func TestSearchHostAgenciesAllowsEmptyKeywordForCountryList(t *testing.T) {
|
||
hostClient := &fakeUserHostClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
handler.SetUserHostClient(hostClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||
request.Header.Set("X-Request-ID", "req-host-agency-list")
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "" || hostClient.lastSearchAgencies.GetPageSize() != 100 {
|
||
t.Fatalf("host agency list request mismatch: %+v", hostClient.lastSearchAgencies)
|
||
}
|
||
}
|
||
|
||
func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
||
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
|
||
{
|
||
UserId: 801,
|
||
DisplayUserId: "900801",
|
||
Username: "Seller One",
|
||
Avatar: "https://cdn.example/avatar.png",
|
||
CountryId: 86,
|
||
CountryCode: "CN",
|
||
CountryName: "China",
|
||
CountryDisplayName: "中国",
|
||
RegionId: 30,
|
||
RegionCode: "east-asia",
|
||
RegionName: "East Asia",
|
||
Status: "active",
|
||
MerchantAssetType: "COIN_SELLER_COIN",
|
||
UpdatedAtMs: 1700000001000,
|
||
},
|
||
}}}
|
||
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(1) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" {
|
||
t.Fatalf("coin seller list response mismatch: %+v", response)
|
||
}
|
||
}
|
||
|
||
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 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",
|
||
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-owner.png" || agency["name"] != "Yumi Star Agency" {
|
||
t.Fatalf("host center agency 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 TestAgencyCenterOverviewUsesOwnerAgencySalaryAndPendingApplications(t *testing.T) {
|
||
hostClient := &fakeUserHostClient{
|
||
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: true, 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, IsManager: true, 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, IsManager: true, 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 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, IsManager: false, AgencyId: 7001}}
|
||
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 {
|
||
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, IsManager: true, 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, IsManager: true, 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 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 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 TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
|
||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
|
||
UserId: 42,
|
||
DisplayUserId: "100001",
|
||
Username: "hy",
|
||
Gender: "female",
|
||
ProfileCompleted: true,
|
||
OnboardingStatus: "completed",
|
||
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["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 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 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,
|
||
}},
|
||
}}
|
||
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" {
|
||
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.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.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 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,
|
||
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.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 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 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: "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: "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 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 TestMapReasonToHTTPMapsGenericConflict(t *testing.T) {
|
||
// room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。
|
||
statusCode, code, message := httpkit.MapReasonToHTTP(xerr.Conflict)
|
||
if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "conflict" {
|
||
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_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_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 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 signGatewayTokenWithExpiresAt(t *testing.T, secret string, userID int64, profileCompleted bool, expiresAt time.Time) string {
|
||
t.Helper()
|
||
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||
"user_id": strconv.FormatInt(userID, 10),
|
||
"app_code": "lalu",
|
||
"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()
|
||
}
|