675 lines
34 KiB
Go
675 lines
34 KiB
Go
package http
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
userv1 "hyapp.local/api/proto/user/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/services/gateway-service/internal/auth"
|
||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||
)
|
||
|
||
func newVIPTestRouter(walletClient *fakeWalletClient) http.Handler {
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||
handler.SetWalletClient(walletClient)
|
||
return handler.Routes(auth.NewVerifier("secret"))
|
||
}
|
||
|
||
func TestTriggerVIPOnlineNoticeChecksBenefitAndPublishesTrustedGlobalPayload(t *testing.T) {
|
||
walletClient := &fakeWalletClient{
|
||
benefitCheckResp: &walletv1.CheckVipBenefitResponse{
|
||
Allowed: true,
|
||
Benefit: &walletv1.VipBenefit{
|
||
BenefitCode: "online_global_notice",
|
||
MetadataJson: `{"message":"欢迎进入Fami,祝你有美好的一天"}`,
|
||
},
|
||
State: &walletv1.VipState{EffectiveVip: &walletv1.UserVip{
|
||
UserId: 42, Level: 9, Name: "VIP9", Active: true, ProgramType: "tiered_privilege_v1",
|
||
}},
|
||
EvaluatedAtMs: 1_800_000_000_000,
|
||
},
|
||
batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{Users: []*walletv1.UserEquippedResources{
|
||
{UserId: 99, Resources: []*walletv1.UserResourceEntitlement{{Resource: &walletv1.Resource{
|
||
ResourceId: 9000, ResourceCode: "other_user_frame", ResourceType: "avatar_frame", AssetUrl: "https://cdn.example/wrong-user.png",
|
||
}}}},
|
||
{UserId: 42, Resources: []*walletv1.UserResourceEntitlement{
|
||
{Resource: &walletv1.Resource{ResourceId: 7000, ResourceCode: "not_a_frame", ResourceType: "vehicle", AssetUrl: "https://cdn.example/car.svga"}},
|
||
{Resource: &walletv1.Resource{
|
||
ResourceId: 7001, ResourceCode: "vip9_gold_frame", ResourceType: "avatar_frame",
|
||
AssetUrl: "https://cdn.example/frame.svga", PreviewUrl: "https://cdn.example/frame.png", AnimationUrl: "https://cdn.example/frame.json",
|
||
}},
|
||
}},
|
||
}},
|
||
}
|
||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
|
||
UserId: 42,
|
||
Username: "I am a Rich",
|
||
Avatar: "https://cdn.example/vip9.png",
|
||
DisplayUserId: "100042",
|
||
PrettyDisplayUserId: "251145",
|
||
}}}
|
||
broadcastClient := &fakeBroadcastClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||
handler.SetWalletClient(walletClient)
|
||
handler.SetBroadcastClient(broadcastClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-1"}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if req := walletClient.lastBenefitCheck; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetBenefitCode() != "online_global_notice" || req.GetRequestId() != recorder.Header().Get("X-Request-ID") {
|
||
t.Fatalf("CheckVipBenefit request mismatch: %+v", req)
|
||
}
|
||
if req := walletClient.lastBatchEquipped; req == nil || req.GetAppCode() != "fami" || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || len(req.GetUserIds()) != 1 || req.GetUserIds()[0] != 42 || len(req.GetResourceTypes()) != 1 || req.GetResourceTypes()[0] != "avatar_frame" {
|
||
t.Fatalf("BatchGetUserEquippedResources request mismatch: %+v", req)
|
||
}
|
||
if req := profileClient.lastGet; req == nil || req.GetUserId() != 42 || req.GetMeta().GetAppCode() != "fami" {
|
||
t.Fatalf("GetUser request mismatch: %+v", req)
|
||
}
|
||
publish := broadcastClient.lastGlobal
|
||
if publish == nil || publish.GetMeta().GetAppCode() != "fami" || publish.GetBroadcastType() != "vip_online_notice" || !strings.HasPrefix(publish.GetEventId(), "vip_online_notice:42:") {
|
||
t.Fatalf("PublishGlobalBroadcast request mismatch: %+v", publish)
|
||
}
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(publish.GetPayloadJson()), &payload); err != nil {
|
||
t.Fatalf("decode broadcast payload: %v", err)
|
||
}
|
||
if payload["user_id"] != float64(42) || payload["nickname"] != "I am a Rich" || payload["avatar"] != "https://cdn.example/vip9.png" || payload["display_user_id"] != "251145" || payload["vip_level"] != float64(9) || payload["vip_name"] != "VIP9" || payload["message"] != "欢迎进入Fami,祝你有美好的一天" {
|
||
t.Fatalf("broadcast payload mismatch: %+v", payload)
|
||
}
|
||
if payload["avatar_frame_url"] != "https://cdn.example/frame.svga" || payload["avatar_frame_resource_id"] != float64(7001) || payload["avatar_frame_code"] != "vip9_gold_frame" || payload["avatar_frame_asset_url"] != "https://cdn.example/frame.svga" || payload["avatar_frame_preview_url"] != "https://cdn.example/frame.png" || payload["avatar_frame_animation_url"] != "https://cdn.example/frame.json" {
|
||
t.Fatalf("avatar frame payload mismatch: %+v", payload)
|
||
}
|
||
if _, leaked := payload["command_id"]; leaked || strings.Contains(publish.GetPayloadJson(), "process-launch-1") {
|
||
t.Fatalf("client command must not be broadcast: %s", publish.GetPayloadJson())
|
||
}
|
||
var envelope struct {
|
||
Code string `json:"code"`
|
||
Data struct {
|
||
EventID string `json:"event_id"`
|
||
GroupID string `json:"group_id"`
|
||
Status string `json:"status"`
|
||
Created bool `json:"created"`
|
||
EvaluatedAtMS int64 `json:"evaluated_at_ms"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
if envelope.Code != httpkit.CodeOK || envelope.Data.EventID != publish.GetEventId() || envelope.Data.GroupID != "hy_fami_bc_g_v2" || envelope.Data.Status != "pending" || !envelope.Data.Created || envelope.Data.EvaluatedAtMS != 1_800_000_000_000 {
|
||
t.Fatalf("response mismatch: %+v", envelope)
|
||
}
|
||
}
|
||
|
||
func TestTriggerVIPOnlineNoticeWithoutEquippedFramePublishesExplicitEmptyFields(t *testing.T) {
|
||
walletClient := &fakeWalletClient{
|
||
benefitCheckResp: &walletv1.CheckVipBenefitResponse{
|
||
Allowed: true,
|
||
Benefit: &walletv1.VipBenefit{BenefitCode: "online_global_notice"},
|
||
State: &walletv1.VipState{EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 9, Name: "VIP9", Active: true}},
|
||
},
|
||
batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{},
|
||
}
|
||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {UserId: 42, Username: "No Frame"}}}
|
||
broadcastClient := &fakeBroadcastClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||
handler.SetWalletClient(walletClient)
|
||
handler.SetBroadcastClient(broadcastClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-no-frame"}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK || broadcastClient.lastGlobal == nil {
|
||
t.Fatalf("no-frame request must still broadcast: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(broadcastClient.lastGlobal.GetPayloadJson()), &payload); err != nil {
|
||
t.Fatalf("decode broadcast payload: %v", err)
|
||
}
|
||
if payload["avatar_frame_resource_id"] != float64(0) || payload["avatar_frame_url"] != "" || payload["avatar_frame_code"] != "" || payload["avatar_frame_asset_url"] != "" || payload["avatar_frame_preview_url"] != "" || payload["avatar_frame_animation_url"] != "" {
|
||
t.Fatalf("empty avatar frame fields mismatch: %+v", payload)
|
||
}
|
||
}
|
||
|
||
func TestTriggerVIPOnlineNoticeEquippedResourceFailureDoesNotBroadcast(t *testing.T) {
|
||
walletClient := &fakeWalletClient{
|
||
benefitCheckResp: &walletv1.CheckVipBenefitResponse{Allowed: true},
|
||
err: errors.New("wallet equipped resources unavailable"),
|
||
}
|
||
profileClient := &fakeUserProfileClient{}
|
||
broadcastClient := &fakeBroadcastClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||
handler.SetWalletClient(walletClient)
|
||
handler.SetBroadcastClient(broadcastClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-frame-rpc-error"}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusBadGateway || walletClient.lastBatchEquipped == nil || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
|
||
t.Fatalf("equipped-resource failure must stop before profile/broadcast: status=%d equipped=%+v profile=%+v broadcast=%+v body=%s", recorder.Code, walletClient.lastBatchEquipped, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestTriggerVIPOnlineNoticeDenialDoesNotReadProfileOrBroadcast(t *testing.T) {
|
||
walletClient := &fakeWalletClient{benefitCheckResp: &walletv1.CheckVipBenefitResponse{Allowed: false, DenialReason: "benefit_not_enabled"}}
|
||
profileClient := &fakeUserProfileClient{}
|
||
broadcastClient := &fakeBroadcastClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||
handler.SetWalletClient(walletClient)
|
||
handler.SetBroadcastClient(broadcastClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"process-launch-denied"}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusForbidden || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
|
||
t.Fatalf("denied request leaked into downstream calls: status=%d profile=%+v broadcast=%+v body=%s", recorder.Code, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestTriggerVIPOnlineNoticeUserSettingDenialDoesNotReadProfileOrBroadcast(t *testing.T) {
|
||
walletClient := &fakeWalletClient{benefitCheckResp: &walletv1.CheckVipBenefitResponse{
|
||
Allowed: false, DenialReason: "user_setting_disabled",
|
||
}}
|
||
profileClient := &fakeUserProfileClient{}
|
||
broadcastClient := &fakeBroadcastClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||
handler.SetWalletClient(walletClient)
|
||
handler.SetBroadcastClient(broadcastClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(`{"command_id":"setting-disabled"}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusForbidden || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
|
||
t.Fatalf("user setting denial leaked into downstream calls: status=%d profile=%+v broadcast=%+v body=%s", recorder.Code, profileClient.lastGet, broadcastClient.lastGlobal, recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestVIPSettingsHTTPContractPreservesExplicitFalseAndAuthScope(t *testing.T) {
|
||
walletClient := &fakeWalletClient{
|
||
myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{
|
||
EvaluatedAtMs: 1_800_000_000_000,
|
||
UserSettings: &walletv1.VipUserSettings{
|
||
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: true,
|
||
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_799_999_999_000,
|
||
},
|
||
}},
|
||
updateVIPSettingsResp: &walletv1.UpdateMyVipSettingsResponse{
|
||
Settings: &walletv1.VipUserSettings{
|
||
AppCode: "fami", UserId: 42, RoomEntryNoticeEnabled: false,
|
||
OnlineGlobalNoticeEnabled: false, UpdatedAtMs: 1_800_000_000_100,
|
||
},
|
||
ServerTimeMs: 1_800_000_000_200,
|
||
},
|
||
}
|
||
router := newVIPTestRouter(walletClient)
|
||
token := "Bearer " + signGatewayTokenWithAppCode(t, "secret", 42, "fami")
|
||
|
||
getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/vip/settings", nil)
|
||
getRequest.Header.Set("Authorization", token)
|
||
getRecorder := httptest.NewRecorder()
|
||
router.ServeHTTP(getRecorder, getRequest)
|
||
if getRecorder.Code != http.StatusOK {
|
||
t.Fatalf("GET settings status mismatch: got=%d body=%s", getRecorder.Code, getRecorder.Body.String())
|
||
}
|
||
if req := walletClient.lastMyVip; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != getRecorder.Header().Get("X-Request-ID") {
|
||
t.Fatalf("GET settings must use authenticated scope: %+v", req)
|
||
}
|
||
var getEnvelope struct {
|
||
Data struct {
|
||
Settings struct {
|
||
AppCode string `json:"app_code"`
|
||
UserID int64 `json:"user_id"`
|
||
RoomEntryNoticeEnabled bool `json:"room_entry_notice_enabled"`
|
||
OnlineGlobalNoticeEnabled bool `json:"online_global_notice_enabled"`
|
||
} `json:"settings"`
|
||
EvaluatedAtMS int64 `json:"evaluated_at_ms"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(getRecorder.Body).Decode(&getEnvelope); err != nil {
|
||
t.Fatalf("decode GET settings response failed: %v", err)
|
||
}
|
||
if getEnvelope.Data.Settings.AppCode != "fami" || getEnvelope.Data.Settings.UserID != 42 || !getEnvelope.Data.Settings.RoomEntryNoticeEnabled || getEnvelope.Data.Settings.OnlineGlobalNoticeEnabled || getEnvelope.Data.EvaluatedAtMS != 1_800_000_000_000 {
|
||
t.Fatalf("GET settings response mismatch: %+v", getEnvelope.Data)
|
||
}
|
||
|
||
patchRequest := httptest.NewRequest(http.MethodPatch, "/api/v1/vip/settings", bytes.NewReader([]byte(`{"room_entry_notice_enabled":false}`)))
|
||
patchRequest.Header.Set("Authorization", token)
|
||
patchRecorder := httptest.NewRecorder()
|
||
router.ServeHTTP(patchRecorder, patchRequest)
|
||
if patchRecorder.Code != http.StatusOK {
|
||
t.Fatalf("PATCH settings status mismatch: got=%d body=%s", patchRecorder.Code, patchRecorder.Body.String())
|
||
}
|
||
patch := walletClient.lastUpdateVIPSettings
|
||
if patch == nil || patch.GetAppCode() != "fami" || patch.GetUserId() != 42 || patch.RoomEntryNoticeEnabled == nil || patch.GetRoomEntryNoticeEnabled() || patch.OnlineGlobalNoticeEnabled != nil || patch.GetRequestId() != patchRecorder.Header().Get("X-Request-ID") {
|
||
t.Fatalf("PATCH settings lost explicit false or auth scope: %+v", patch)
|
||
}
|
||
|
||
// /vip/me 复用同一 DTO 转换,必须把 state.user_settings 一并透出。
|
||
meRequest := httptest.NewRequest(http.MethodGet, "/api/v1/vip/me", nil)
|
||
meRequest.Header.Set("Authorization", token)
|
||
meRecorder := httptest.NewRecorder()
|
||
router.ServeHTTP(meRecorder, meRequest)
|
||
if meRecorder.Code != http.StatusOK || !strings.Contains(meRecorder.Body.String(), `"user_settings"`) || !strings.Contains(meRecorder.Body.String(), `"online_global_notice_enabled":false`) {
|
||
t.Fatalf("/vip/me missing user settings: status=%d body=%s", meRecorder.Code, meRecorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestVIPSettingsEmptyPatchRejectedBeforeWallet(t *testing.T) {
|
||
walletClient := &fakeWalletClient{}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodPatch, "/api/v1/vip/settings", bytes.NewReader([]byte(`{}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusBadRequest || walletClient.lastUpdateVIPSettings != nil {
|
||
t.Fatalf("empty settings PATCH must fail before wallet: status=%d req=%+v body=%s", recorder.Code, walletClient.lastUpdateVIPSettings, recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestTriggerVIPOnlineNoticeValidatesCommandID(t *testing.T) {
|
||
walletClient := &fakeWalletClient{}
|
||
profileClient := &fakeUserProfileClient{}
|
||
broadcastClient := &fakeBroadcastClient{}
|
||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||
handler.SetWalletClient(walletClient)
|
||
handler.SetBroadcastClient(broadcastClient)
|
||
router := handler.Routes(auth.NewVerifier("secret"))
|
||
for name, body := range map[string]string{
|
||
"empty": `{"command_id":""}`,
|
||
"too_long": `{"command_id":"` + strings.Repeat("x", 129) + `"}`,
|
||
} {
|
||
t.Run(name, func(t *testing.T) {
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/online-notice", bytes.NewReader([]byte(body)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
router.ServeHTTP(recorder, request)
|
||
if recorder.Code != http.StatusBadRequest {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
})
|
||
}
|
||
if walletClient.lastBenefitCheck != nil || profileClient.lastGet != nil || broadcastClient.lastGlobal != nil {
|
||
t.Fatal("invalid command must be rejected before downstream calls")
|
||
}
|
||
}
|
||
|
||
func TestVIPCoinRebateHTTPContractUsesAuthenticatedUser(t *testing.T) {
|
||
rebate := &walletv1.VipDailyCoinRebate{
|
||
RebateId: "vip_rebate_01", UserId: 42, TaskDay: "2027-01-15", VipLevel: 9, VipName: "VIP9",
|
||
CoinAmount: 100000, Status: "claimable", AvailableAtMs: 1800000000000, ExpiresAtMs: 1800086400000,
|
||
ConfigVersion: 7, CreatedAtMs: 1800000000100, UpdatedAtMs: 1800000000100,
|
||
}
|
||
walletClient := &fakeWalletClient{
|
||
currentVIPRebateResp: &walletv1.GetMyCurrentVipDailyCoinRebateResponse{Found: true, Rebate: rebate, ServerTimeMs: 1800000000200},
|
||
vipRebateStatusesResp: &walletv1.ListMyVipDailyCoinRebateStatusesResponse{Rebates: []*walletv1.VipDailyCoinRebate{rebate}, Total: 1, ServerTimeMs: 1800000000300},
|
||
claimVIPRebateResp: &walletv1.ClaimVipDailyCoinRebateResponse{
|
||
Rebate: &walletv1.VipDailyCoinRebate{
|
||
RebateId: rebate.GetRebateId(), UserId: 42, TaskDay: rebate.GetTaskDay(), VipLevel: 9, VipName: "VIP9",
|
||
CoinAmount: 100000, Status: "claimed", AvailableAtMs: rebate.GetAvailableAtMs(), ExpiresAtMs: rebate.GetExpiresAtMs(),
|
||
ConfigVersion: 7, ClaimedAtMs: 1800000000400, WalletTransactionId: "wtx-rebate-1",
|
||
},
|
||
TransactionId: "wtx-rebate-1",
|
||
CoinBalance: &walletv1.AssetBalance{AssetType: "COIN", AvailableAmount: 500000, Version: 3},
|
||
ServerTimeMs: 1800000000400,
|
||
},
|
||
}
|
||
router := newVIPTestRouter(walletClient)
|
||
token := "Bearer " + signGatewayTokenWithAppCode(t, "secret", 42, "fami")
|
||
|
||
currentRequest := httptest.NewRequest(http.MethodGet, "/api/v1/vip/coin-rebates/current", nil)
|
||
currentRequest.Header.Set("Authorization", token)
|
||
currentRecorder := httptest.NewRecorder()
|
||
router.ServeHTTP(currentRecorder, currentRequest)
|
||
if currentRecorder.Code != http.StatusOK {
|
||
t.Fatalf("current status mismatch: got %d body=%s", currentRecorder.Code, currentRecorder.Body.String())
|
||
}
|
||
if req := walletClient.lastCurrentVIPRebate; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != currentRecorder.Header().Get("X-Request-ID") {
|
||
t.Fatalf("current rebate request mismatch: %+v", req)
|
||
}
|
||
var currentEnvelope struct {
|
||
Data struct {
|
||
Found bool `json:"found"`
|
||
Rebate struct {
|
||
RebateID string `json:"rebate_id"`
|
||
UserID string `json:"user_id"`
|
||
CoinAmount int64 `json:"coin_amount"`
|
||
Status string `json:"status"`
|
||
} `json:"rebate"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(currentRecorder.Body).Decode(¤tEnvelope); err != nil || !currentEnvelope.Data.Found || currentEnvelope.Data.Rebate.RebateID != rebate.GetRebateId() || currentEnvelope.Data.Rebate.UserID != "42" || currentEnvelope.Data.Rebate.CoinAmount != 100000 || currentEnvelope.Data.Rebate.Status != "claimable" {
|
||
t.Fatalf("current rebate response mismatch: envelope=%+v err=%v", currentEnvelope, err)
|
||
}
|
||
|
||
statusesRequest := httptest.NewRequest(http.MethodPost, "/api/v1/vip/coin-rebates/statuses", bytes.NewReader([]byte(`{"rebate_ids":["vip_rebate_01"]}`)))
|
||
statusesRequest.Header.Set("Authorization", token)
|
||
statusesRecorder := httptest.NewRecorder()
|
||
router.ServeHTTP(statusesRecorder, statusesRequest)
|
||
if statusesRecorder.Code != http.StatusOK {
|
||
t.Fatalf("statuses mismatch: got %d body=%s", statusesRecorder.Code, statusesRecorder.Body.String())
|
||
}
|
||
if req := walletClient.lastVIPRebateStatuses; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || len(req.GetRebateIds()) != 1 || req.GetRebateIds()[0] != "vip_rebate_01" {
|
||
t.Fatalf("rebate statuses request mismatch: %+v", req)
|
||
}
|
||
|
||
claimRequest := httptest.NewRequest(http.MethodPost, "/api/v1/vip/coin-rebates/vip_rebate_01/claim", bytes.NewReader([]byte(`{"command_id":"claim-rebate-01"}`)))
|
||
claimRequest.Header.Set("Authorization", token)
|
||
claimRecorder := httptest.NewRecorder()
|
||
router.ServeHTTP(claimRecorder, claimRequest)
|
||
if claimRecorder.Code != http.StatusOK {
|
||
t.Fatalf("claim mismatch: got %d body=%s", claimRecorder.Code, claimRecorder.Body.String())
|
||
}
|
||
if req := walletClient.lastClaimVIPRebate; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRebateId() != "vip_rebate_01" || req.GetCommandId() != "claim-rebate-01" {
|
||
t.Fatalf("claim rebate request mismatch: %+v", req)
|
||
}
|
||
var claimEnvelope struct {
|
||
Data struct {
|
||
TransactionID string `json:"transaction_id"`
|
||
Rebate struct {
|
||
Status string `json:"status"`
|
||
} `json:"rebate"`
|
||
CoinBalance struct {
|
||
AvailableAmount int64 `json:"available_amount"`
|
||
} `json:"coin_balance"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(claimRecorder.Body).Decode(&claimEnvelope); err != nil || claimEnvelope.Data.TransactionID != "wtx-rebate-1" || claimEnvelope.Data.Rebate.Status != "claimed" || claimEnvelope.Data.CoinBalance.AvailableAmount != 500000 {
|
||
t.Fatalf("claim response mismatch: envelope=%+v err=%v", claimEnvelope, err)
|
||
}
|
||
}
|
||
|
||
func TestVIPCoinRebateClaimRejectsClientControlledInvalidKeys(t *testing.T) {
|
||
walletClient := &fakeWalletClient{}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/coin-rebates/rebate-1/claim", bytes.NewReader([]byte(`{"command_id":""}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
router.ServeHTTP(recorder, request)
|
||
if recorder.Code != http.StatusBadRequest || walletClient.lastClaimVIPRebate != nil {
|
||
t.Fatalf("invalid claim must stop before wallet: status=%d req=%+v body=%s", recorder.Code, walletClient.lastClaimVIPRebate, recorder.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestGetMyVIPKeepsLegacyFieldsAndAddsEffectiveState(t *testing.T) {
|
||
config := &walletv1.VipProgramConfig{
|
||
AppCode: "fami",
|
||
ProgramType: "tiered_privilege_v1",
|
||
LevelCount: 9,
|
||
SameLevelExpiryPolicy: "extend_remaining",
|
||
UpgradeExpiryPolicy: "replace_from_now",
|
||
DowngradePurchasePolicy: "reject",
|
||
BenefitInheritancePolicy: "target_only",
|
||
GrantMode: "trial_card",
|
||
TrialCardEnabled: true,
|
||
Status: "active",
|
||
ConfigVersion: 3,
|
||
}
|
||
state := &walletv1.VipState{
|
||
PaidVip: &walletv1.UserVip{UserId: 42, Level: 3, Name: "VIP3", Active: true, ExpiresAtMs: 2_000, ProgramType: config.GetProgramType(), ConfigVersion: 3},
|
||
EquippedTrialCard: &walletv1.VipTrialCard{
|
||
TrialCardId: "card-4", EntitlementId: "ent-4", UserId: 42, Level: 4, Name: "VIP4体验卡", Equipped: true, ExpiresAtMs: 3_000,
|
||
},
|
||
EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 4, Name: "VIP4", Active: true, ExpiresAtMs: 3_000, ProgramType: config.GetProgramType(), ConfigVersion: 3},
|
||
EffectiveSource: "trial",
|
||
EffectiveBenefits: []*walletv1.VipBenefit{{BenefitCode: "room_entry_notice", Name: "进房通知", TrialEnabled: true, Status: "active"}},
|
||
EvaluatedAtMs: 1_500,
|
||
ProgramConfig: config,
|
||
}
|
||
walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{Vip: state.GetEffectiveVip(), State: state}}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/vip/me", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
request.Header.Set("X-Request-ID", "req-vip-me")
|
||
recorder := 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 req := walletClient.lastMyVip; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != generatedRequestID {
|
||
t.Fatalf("GetMyVip request mismatch: %+v", req)
|
||
}
|
||
var envelope struct {
|
||
Code string `json:"code"`
|
||
Data struct {
|
||
Level int32 `json:"level"`
|
||
ConfigVersion int64 `json:"config_version"`
|
||
ProgramConfig struct {
|
||
ProgramType string `json:"program_type"`
|
||
TrialCardEnabled bool `json:"trial_card_enabled"`
|
||
} `json:"program_config"`
|
||
State struct {
|
||
EffectiveSource string `json:"effective_source"`
|
||
PaidVIP struct {
|
||
Level int32 `json:"level"`
|
||
} `json:"paid_vip"`
|
||
EffectiveVIP struct {
|
||
Level int32 `json:"level"`
|
||
} `json:"effective_vip"`
|
||
EquippedTrialCard struct {
|
||
EntitlementID string `json:"entitlement_id"`
|
||
} `json:"equipped_trial_card"`
|
||
EffectiveBenefits []struct {
|
||
BenefitCode string `json:"benefit_code"`
|
||
} `json:"effective_benefits"`
|
||
} `json:"state"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
if envelope.Code != httpkit.CodeOK || envelope.Data.Level != 4 || envelope.Data.ConfigVersion != 3 {
|
||
t.Fatalf("legacy VIP fields mismatch: %+v", envelope.Data)
|
||
}
|
||
if envelope.Data.ProgramConfig.ProgramType != "tiered_privilege_v1" || !envelope.Data.ProgramConfig.TrialCardEnabled {
|
||
t.Fatalf("program config mismatch: %+v", envelope.Data.ProgramConfig)
|
||
}
|
||
if envelope.Data.State.PaidVIP.Level != 3 || envelope.Data.State.EffectiveVIP.Level != 4 || envelope.Data.State.EffectiveSource != "trial" || envelope.Data.State.EquippedTrialCard.EntitlementID != "ent-4" {
|
||
t.Fatalf("effective state mismatch: %+v", envelope.Data.State)
|
||
}
|
||
if len(envelope.Data.State.EffectiveBenefits) != 1 || envelope.Data.State.EffectiveBenefits[0].BenefitCode != "room_entry_notice" {
|
||
t.Fatalf("effective benefits mismatch: %+v", envelope.Data.State.EffectiveBenefits)
|
||
}
|
||
}
|
||
|
||
func TestListVIPPackagesAddsProgramStateAndLevelBenefits(t *testing.T) {
|
||
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", LevelCount: 9, ConfigVersion: 7, TrialCardEnabled: true}
|
||
walletClient := &fakeWalletClient{vipPackagesResp: &walletv1.ListVipPackagesResponse{
|
||
CurrentVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true},
|
||
Packages: []*walletv1.VipLevel{{
|
||
Level: 3, Name: "VIP3", PriceCoin: 300, DurationMs: 2_592_000_000, CanPurchase: true, ConfigVersion: 7,
|
||
Benefits: []*walletv1.VipBenefit{{BenefitCode: "voice_wave", Name: "语音波纹", ExecutionScope: "room", TrialEnabled: true}},
|
||
}},
|
||
State: &walletv1.VipState{EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 2, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
|
||
ProgramConfig: config,
|
||
}}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodGet, "/api/v1/vip/packages", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if req := walletClient.lastVipPackages; req == nil || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetRequestId() != recorder.Header().Get("X-Request-ID") {
|
||
t.Fatalf("ListVipPackages request mismatch: %+v", req)
|
||
}
|
||
var envelope struct {
|
||
Data struct {
|
||
CurrentVIP struct {
|
||
Level int32 `json:"level"`
|
||
} `json:"current_vip"`
|
||
ProgramConfig struct {
|
||
LevelCount int32 `json:"level_count"`
|
||
} `json:"program_config"`
|
||
State struct {
|
||
EffectiveSource string `json:"effective_source"`
|
||
} `json:"state"`
|
||
Packages []struct {
|
||
ConfigVersion int64 `json:"config_version"`
|
||
Benefits []struct {
|
||
BenefitCode string `json:"benefit_code"`
|
||
} `json:"benefits"`
|
||
} `json:"packages"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
if envelope.Data.CurrentVIP.Level != 2 || envelope.Data.ProgramConfig.LevelCount != 9 || envelope.Data.State.EffectiveSource != "paid" || len(envelope.Data.Packages) != 1 {
|
||
t.Fatalf("packages response mismatch: %+v", envelope.Data)
|
||
}
|
||
if envelope.Data.Packages[0].ConfigVersion != 7 || len(envelope.Data.Packages[0].Benefits) != 1 || envelope.Data.Packages[0].Benefits[0].BenefitCode != "voice_wave" {
|
||
t.Fatalf("package benefits mismatch: %+v", envelope.Data.Packages)
|
||
}
|
||
}
|
||
|
||
func TestPurchaseVIPKeepsLegacyResultAndAddsState(t *testing.T) {
|
||
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", UpgradeExpiryPolicy: "replace_from_now", ConfigVersion: 8}
|
||
walletClient := &fakeWalletClient{purchaseVipResp: &walletv1.PurchaseVipResponse{
|
||
OrderId: "vip-order-1", TransactionId: "vip-tx-1", Vip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true, ExpiresAtMs: 4_000}, CoinSpent: 400, CoinBalanceAfter: 600,
|
||
State: &walletv1.VipState{PaidVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 4, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
|
||
}}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/purchase", bytes.NewReader([]byte(`{"command_id":"vip-buy-4","level":4}`)))
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if req := walletClient.lastPurchaseVip; req == nil || req.GetCommandId() != "vip-buy-4" || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetLevel() != 4 {
|
||
t.Fatalf("PurchaseVip request mismatch: %+v", req)
|
||
}
|
||
var envelope struct {
|
||
Data struct {
|
||
OrderID string `json:"order_id"`
|
||
TransactionID string `json:"transaction_id"`
|
||
VIP struct {
|
||
Level int32 `json:"level"`
|
||
} `json:"vip"`
|
||
ProgramConfig struct {
|
||
UpgradeExpiryPolicy string `json:"upgrade_expiry_policy"`
|
||
} `json:"program_config"`
|
||
State struct {
|
||
EffectiveSource string `json:"effective_source"`
|
||
} `json:"state"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
if envelope.Data.OrderID != "vip-order-1" || envelope.Data.TransactionID != "vip-tx-1" || envelope.Data.VIP.Level != 4 || envelope.Data.ProgramConfig.UpgradeExpiryPolicy != "replace_from_now" || envelope.Data.State.EffectiveSource != "paid" {
|
||
t.Fatalf("purchase response mismatch: %+v", envelope.Data)
|
||
}
|
||
}
|
||
|
||
func TestEquipVIPTrialCardUsesAuthenticatedEntitlement(t *testing.T) {
|
||
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", TrialCardEnabled: true}
|
||
walletClient := &fakeWalletClient{trialEquipResp: &walletv1.EquipVipTrialCardResponse{
|
||
TrialCard: &walletv1.VipTrialCard{TrialCardId: "card-9", EntitlementId: "ent-card-9", UserId: 42, Level: 9, Equipped: true, ExpiresAtMs: 9_000},
|
||
State: &walletv1.VipState{EquippedTrialCard: &walletv1.VipTrialCard{EntitlementId: "ent-card-9", Level: 9, Equipped: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 9, Active: true}, EffectiveSource: "trial", ProgramConfig: config},
|
||
ServerTimeMs: 1_800,
|
||
}}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vip/trial-cards/ent-card-9/equip", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if req := walletClient.lastTrialEquip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 || req.GetEntitlementId() != "ent-card-9" {
|
||
t.Fatalf("EquipVipTrialCard request mismatch: %+v", req)
|
||
}
|
||
var envelope struct {
|
||
Data struct {
|
||
TrialCard struct {
|
||
EntitlementID string `json:"entitlement_id"`
|
||
} `json:"trial_card"`
|
||
State struct {
|
||
EffectiveSource string `json:"effective_source"`
|
||
} `json:"state"`
|
||
ServerTimeMS int64 `json:"server_time_ms"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
if envelope.Data.TrialCard.EntitlementID != "ent-card-9" || envelope.Data.State.EffectiveSource != "trial" || envelope.Data.ServerTimeMS != 1_800 {
|
||
t.Fatalf("equip response mismatch: %+v", envelope.Data)
|
||
}
|
||
}
|
||
|
||
func TestUnequipVIPTrialCardRestoresPaidState(t *testing.T) {
|
||
config := &walletv1.VipProgramConfig{AppCode: "fami", ProgramType: "tiered_privilege_v1", TrialCardEnabled: true}
|
||
walletClient := &fakeWalletClient{trialUnequipResp: &walletv1.UnequipVipTrialCardResponse{
|
||
Unequipped: true,
|
||
State: &walletv1.VipState{PaidVip: &walletv1.UserVip{UserId: 42, Level: 3, Active: true}, EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 3, Active: true}, EffectiveSource: "paid", ProgramConfig: config},
|
||
ServerTimeMs: 1_900,
|
||
}}
|
||
router := newVIPTestRouter(walletClient)
|
||
request := httptest.NewRequest(http.MethodDelete, "/api/v1/vip/trial-cards/equipped", nil)
|
||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
|
||
recorder := httptest.NewRecorder()
|
||
|
||
router.ServeHTTP(recorder, request)
|
||
|
||
if recorder.Code != http.StatusOK {
|
||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||
}
|
||
if req := walletClient.lastTrialUnequip; req == nil || req.GetRequestId() != recorder.Header().Get("X-Request-ID") || req.GetAppCode() != "fami" || req.GetUserId() != 42 {
|
||
t.Fatalf("UnequipVipTrialCard request mismatch: %+v", req)
|
||
}
|
||
var envelope struct {
|
||
Data struct {
|
||
Unequipped bool `json:"unequipped"`
|
||
State struct {
|
||
EffectiveSource string `json:"effective_source"`
|
||
EffectiveVIP struct {
|
||
Level int32 `json:"level"`
|
||
} `json:"effective_vip"`
|
||
} `json:"state"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||
t.Fatalf("decode response failed: %v", err)
|
||
}
|
||
if !envelope.Data.Unequipped || envelope.Data.State.EffectiveSource != "paid" || envelope.Data.State.EffectiveVIP.Level != 3 {
|
||
t.Fatalf("unequip response mismatch: %+v", envelope.Data)
|
||
}
|
||
}
|