675 lines
34 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"
"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(&currentEnvelope); 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)
}
}