2026-05-27 18:50:40 +08:00

5320 lines
224 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package http
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"hyapp/services/gateway-service/internal/transport/http/roomapi"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strconv"
"strings"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
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
lastJoin *roomv1.JoinRoomRequest
lastHeartbeat *roomv1.RoomHeartbeatRequest
lastLeave *roomv1.LeaveRoomRequest
lastMicUp *roomv1.MicUpRequest
lastMicDown *roomv1.MicDownRequest
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
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) 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) 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
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
lastCountry *userv1.ChangeUserCountryRequest
getErr error
statsResp *userv1.GetMyProfileStatsResponse
businessLookupResp *userv1.BusinessUserLookupResponse
businessLookupErr error
statsErr error
completeErr error
countryErr error
regionID int64
regionByUserID map[int64]int64
}
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
lastTreasure *roomv1.GetRoomTreasureRequest
lastOnline *roomv1.ListRoomOnlineUsersRequest
resp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
treasureResp *roomv1.GetRoomTreasureResponse
onlineResp *roomv1.ListRoomOnlineUsersResponse
err error
feedsErr error
myRoomErr error
currentErr error
snapshotErr error
treasureErr error
onlineErr 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 fakeUserHostClient struct {
lastSearchAgencies *userv1.SearchAgenciesRequest
searchAgenciesResp *userv1.SearchAgenciesResponse
searchAgenciesErr error
lastApplyAgency *userv1.ApplyToAgencyRequest
applyAgencyResp *userv1.ApplyToAgencyResponse
applyAgencyErr 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
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
}
type fakeWalletClient struct {
last *walletv1.GetBalancesRequest
resp *walletv1.GetBalancesResponse
err 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
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
lastTransactions *walletv1.ListWalletTransactionsRequest
transactionsResp *walletv1.ListWalletTransactionsResponse
lastWithdrawal *walletv1.ApplyWithdrawalRequest
withdrawalResp *walletv1.ApplyWithdrawalResponse
lastVipPackages *walletv1.ListVipPackagesRequest
vipPackagesResp *walletv1.ListVipPackagesResponse
lastMyVip *walletv1.GetMyVipRequest
myVipResp *walletv1.GetMyVipResponse
vipErr error
lastPurchaseVip *walletv1.PurchaseVipRequest
purchaseVipResp *walletv1.PurchaseVipResponse
lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse
transferErr 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
}
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 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 *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
}
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",
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() {
users[userID] = &userv1.User{
UserId: userID,
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) 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 *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 *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) GetRoomTreasure(_ context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) {
f.lastTreasure = req
if f.treasureErr != nil {
return nil, f.treasureErr
}
if f.treasureResp != nil {
return f.treasureResp, nil
}
return &roomv1.GetRoomTreasureResponse{}, 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 *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 *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 *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) 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) 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 *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
f.last = req
if f.err != nil {
return nil, f.err
}
if f.resp != nil {
return f.resp, nil
}
return &walletv1.GetBalancesResponse{}, 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,
WithdrawEnabled: 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) 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) ApplyWithdrawal(_ context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
f.lastWithdrawal = req
if f.err != nil {
return nil, f.err
}
if f.withdrawalResp != nil {
return f.withdrawalResp, nil
}
return &walletv1.ApplyWithdrawalResponse{}, 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) 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) 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) 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, *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) {
return &walletv1.ClaimRedPacketResponse{}, nil
}
func (f *fakeWalletClient) ListRedPackets(context.Context, *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) {
return &walletv1.ListRedPacketsResponse{}, nil
}
func (f *fakeWalletClient) GetRedPacket(context.Context, *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
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.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_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}`,
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)
}
})
}
}
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: "novice_2x",
MultiplierPpm: 2_000_000,
EffectiveRewardCoins: 200,
RewardStatus: "pending",
CreatedAtMs: 1_779_258_000_000,
},
}}
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"`
} `json:"lucky_gift"`
} `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 != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 {
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
}
}
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",
},
}}
profileClient := &fakeUserProfileClient{}
handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
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)
}
}
func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
Rooms: []*roomv1.RoomListItem{
{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true},
},
NextCursor: "cursor-2",
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&cursor=cursor-1&q=room&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 {
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" {
t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
}
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)
rooms := data["rooms"].([]any)
first := rooms[0].(map[string]any)
if first["im_group_id"] != "room-1" || first["locked"] != true {
t.Fatalf("room list must expose room IM group id: %+v", first)
}
}
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 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 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 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
}
func (r *captureAppConfigReader) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) {
r.bannerQuery = query
return r.StaticReader.ListBanners(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",
DisplayScopes: []string{"home", "room"},
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=room&platform=android&country=CN&region_id=1", nil)
request.Header.Set("X-Request-ID", "req-app-banners")
request.Header.Set("X-App-Package", "com.org.laluparty")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if reader.bannerQuery.DisplayScope != "room" || 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" || 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) != 2 || displayScopes[0] != "home" || displayScopes[1] != "room" {
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 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",
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=99", 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() != 20 {
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" || data["page_size"] != float64(20) {
t.Fatalf("host agency search response mismatch: %+v", response)
}
}
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 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 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 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("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 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"}`)
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)
}
}
func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).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" {
t.Fatalf("profile 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 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 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 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 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 TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller",
SellerBalanceAfter: 80000,
TargetBalanceAfter: 80000,
Amount: 80000,
RechargeUsdMinor: 100,
RechargeCurrencyCode: "USD",
RechargePolicyId: 7,
RechargePolicyVersion: "recharge-v1",
RechargePolicyCoinAmount: 80000,
RechargePolicyUsdMinorAmount: 100,
}}
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(100) {
t.Fatalf("coin seller transfer response mismatch: %+v", response)
}
}
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: "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"}`)))
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"}`)))
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 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"}`},
{name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby"}`},
{name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice"}`},
{name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby"}`},
}
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"}`)
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"}`)
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 := NewHandler(roomClient).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])
}
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 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()
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],
})
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()
}