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) } }