406 lines
21 KiB
Go
406 lines
21 KiB
Go
package wallet_test
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||
"hyapp/services/wallet-service/internal/testutil/mysqltest"
|
||
)
|
||
|
||
// TestFamiVIPReplaceAndTrialCardSwitch 使用真实 MySQL repository 验证 P1 最容易回归的三条状态边界:
|
||
// 高级购买丢弃剩余时间、体验卡可跨等级自由切换且旧卡不改期、体验卡排除金币返现。
|
||
func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
service := walletservice.New(repository)
|
||
ctx := appcode.WithContext(context.Background(), "fami")
|
||
_, levels, err := service.ListAdminVipLevels(ctx)
|
||
if err != nil {
|
||
t.Fatalf("list Fami VIP levels failed: %v", err)
|
||
}
|
||
if len(levels) != 9 {
|
||
t.Fatalf("Fami must expose 9 configurable levels, got %d", len(levels))
|
||
}
|
||
durationMS := int64(30 * 24 * time.Hour / time.Millisecond)
|
||
commands := make([]ledger.AdminVipLevelCommand, 0, len(levels))
|
||
for _, level := range levels {
|
||
for index := range level.Benefits {
|
||
if level.Benefits[index].BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
|
||
// 模拟后台误把体验返现打开;保存与运行时都必须把这项硬规则收敛为 false。
|
||
level.Benefits[index].TrialEnabled = true
|
||
level.Benefits[index].Status = ledger.VipStatusActive
|
||
level.Benefits[index].MetadataJSON = `{"coin_amount":100}`
|
||
}
|
||
}
|
||
commands = append(commands, ledger.AdminVipLevelCommand{
|
||
Level: level.Level,
|
||
Name: level.Name,
|
||
Status: ledger.VipStatusActive,
|
||
PriceCoin: 100,
|
||
DurationMS: durationMS,
|
||
RewardResourceGroupID: 0,
|
||
SortOrder: level.SortOrder,
|
||
Benefits: level.Benefits,
|
||
})
|
||
}
|
||
if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
|
||
t.Fatalf("enable Fami VIP levels failed: %v", err)
|
||
}
|
||
|
||
const userID int64 = 880001
|
||
repository.SetBalanceForApp("fami", userID, 10_000)
|
||
first, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-3", UserID: userID, Level: 3})
|
||
if err != nil {
|
||
t.Fatalf("purchase Fami VIP3 failed: %v", err)
|
||
}
|
||
// command_id 与 user_id 只在 app_code 内幂等;Lalu 使用相同值必须形成独立订单和状态。
|
||
laluCtx := appcode.WithContext(context.Background(), "lalu")
|
||
repository.SetBalanceForApp("lalu", userID, 10_000)
|
||
laluPurchase, err := service.PurchaseVip(laluCtx, ledger.PurchaseVipCommand{AppCode: "lalu", CommandID: "fami-vip-3", UserID: userID, Level: 3})
|
||
if err != nil {
|
||
t.Fatalf("same command in Lalu app scope failed: %v", err)
|
||
}
|
||
if laluPurchase.State.ProgramConfig.ProgramType != ledger.VipProgramTypeLegacyTimed || laluPurchase.OrderID == first.OrderID {
|
||
t.Fatalf("VIP purchase must stay app-scoped: fami=%+v lalu=%+v", first, laluPurchase)
|
||
}
|
||
upgrade, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-4", UserID: userID, Level: 4})
|
||
if err != nil {
|
||
t.Fatalf("upgrade Fami VIP4 failed: %v", err)
|
||
}
|
||
if upgrade.Vip.ExpiresAtMS >= first.Vip.ExpiresAtMS+durationMS/2 {
|
||
t.Fatalf("Fami upgrade incorrectly retained VIP3 remainder: first=%d upgrade=%d", first.Vip.ExpiresAtMS, upgrade.Vip.ExpiresAtMS)
|
||
}
|
||
if got := upgrade.Vip.ExpiresAtMS - upgrade.Vip.UpdatedAtMS; got < durationMS-2_000 || got > durationMS+2_000 {
|
||
t.Fatalf("Fami upgrade must reset to one configured duration, got %d want %d", got, durationMS)
|
||
}
|
||
replayedFirst, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-3", UserID: userID, Level: 3})
|
||
if err != nil {
|
||
t.Fatalf("replay original VIP3 purchase failed: %v", err)
|
||
}
|
||
if replayedFirst.Vip.Level != 3 || replayedFirst.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS || replayedFirst.State.PaidVip.Level != 4 {
|
||
t.Fatalf("idempotent receipt must keep original order snapshot and current state separately: first=%+v replay=%+v", first, replayedFirst)
|
||
}
|
||
paid, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-6", UserID: userID, Level: 6})
|
||
if err != nil {
|
||
t.Fatalf("upgrade Fami VIP6 failed: %v", err)
|
||
}
|
||
if paid.State.PaidVip.Level != 6 {
|
||
t.Fatalf("paid VIP state mismatch: %+v", paid.State)
|
||
}
|
||
|
||
card3, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
|
||
AppCode: "fami", CommandID: "trial-vip-3", TargetUserID: userID, Level: 3,
|
||
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
|
||
OperatorUserID: 9001, Reason: "test trial 3",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("grant VIP3 trial card failed: %v", err)
|
||
}
|
||
card6, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
|
||
AppCode: "fami", CommandID: "trial-vip-6", TargetUserID: userID, Level: 6,
|
||
DurationMS: int64(20 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
|
||
OperatorUserID: 9001, Reason: "test trial 6",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("grant VIP6 trial card failed: %v", err)
|
||
}
|
||
if _, err := service.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||
AppCode: "fami", CommandID: "generic-trial-bypass", TargetUserID: userID,
|
||
ResourceID: card6.TrialCard.ResourceID, Quantity: 1, DurationMS: durationMS,
|
||
Reason: "must use dedicated vip trial grant", OperatorUserID: 9001, GrantSource: resourcedomain.GrantSourceAdmin,
|
||
}); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||
t.Fatalf("generic resource grant must not bypass VIP trial metadata, got %v", err)
|
||
}
|
||
if card3.State.EffectiveSource != ledger.VipEffectiveSourcePaid || card6.State.EffectiveSource != ledger.VipEffectiveSourcePaid {
|
||
t.Fatalf("granting a card must not auto-equip it: card3=%+v card6=%+v", card3.State, card6.State)
|
||
}
|
||
|
||
// repository 仍保留通用 equipment 能力供专用状态机内部复用,但必须拒绝外层直接调用,避免绕过 program 与 VIP outbox。
|
||
if _, err := repository.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
AppCode: "fami", RequestID: "raw-equip-trial-3", UserID: userID,
|
||
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
|
||
}); !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "VIP trial-card state machine") {
|
||
t.Fatalf("raw repository equip must reject vip_trial_card bypass, got %v", err)
|
||
}
|
||
if _, err := repository.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
|
||
AppCode: "fami", RequestID: "raw-unequip-trial", UserID: userID, ResourceType: resourcedomain.TypeVIPTrialCard,
|
||
}); !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "VIP trial-card state machine") {
|
||
t.Fatalf("raw repository unequip must reject vip_trial_card bypass, got %v", err)
|
||
}
|
||
|
||
// 通用背包入口必须委托专用状态机,因此体验卡开关关闭时不能偷偷写入 equipment。
|
||
disabledProgram := card3.State.ProgramConfig
|
||
disabledProgram.GrantMode = ledger.VipGrantModeDirectMembership
|
||
disabledProgram.TrialCardEnabled = false
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: disabledProgram, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("disable trial cards before generic equip failed: %v", err)
|
||
}
|
||
if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
AppCode: "fami", RequestID: "generic-equip-disabled", UserID: userID,
|
||
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
|
||
}); !xerr.IsCode(err, xerr.Conflict) {
|
||
t.Fatalf("generic backpack equip must preserve trial_card_enabled validation, got %v", err)
|
||
}
|
||
if got := repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ? AND resource_type = ?", "fami", userID, resourcedomain.TypeVIPTrialCard); got != 0 {
|
||
t.Fatalf("disabled generic trial equip must not mutate equipment, got %d rows", got)
|
||
}
|
||
disabledProgram.GrantMode = ledger.VipGrantModeTrialCard
|
||
disabledProgram.TrialCardEnabled = true
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: disabledProgram, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("restore trial cards before generic equip failed: %v", err)
|
||
}
|
||
|
||
resourceEventsBefore := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged")
|
||
vipEventsBefore := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged")
|
||
genericCard3, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
AppCode: "fami", RequestID: "generic-equip-trial-3", UserID: userID,
|
||
ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("generic backpack equip VIP3 trial card failed: %v", err)
|
||
}
|
||
if !genericCard3.Equipped || genericCard3.EntitlementID != card3.TrialCard.EntitlementID {
|
||
t.Fatalf("generic backpack response must return the selected equipped entitlement: %+v", genericCard3)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged"); got != resourceEventsBefore+1 {
|
||
t.Fatalf("generic trial equip must publish UserResourceChanged exactly once: before=%d after=%d", resourceEventsBefore, got)
|
||
}
|
||
if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged"); got != vipEventsBefore+1 {
|
||
t.Fatalf("generic trial equip must publish VipEffectiveChanged exactly once: before=%d after=%d", vipEventsBefore, got)
|
||
}
|
||
state3, err := service.GetVipState(ctx, userID)
|
||
if err != nil {
|
||
t.Fatalf("get state after generic VIP3 trial equip failed: %v", err)
|
||
}
|
||
if state3.EffectiveSource != ledger.VipEffectiveSourceTrial || state3.EffectiveVip.Level != 3 || state3.PaidVip.Level != 6 {
|
||
t.Fatalf("lower trial card must freely overlay paid VIP without destroying it: %+v", state3)
|
||
}
|
||
_, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||
AppCode: "fami", RequestID: "generic-equip-trial-6", UserID: userID,
|
||
ResourceID: card6.TrialCard.ResourceID, EntitlementID: card6.TrialCard.EntitlementID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("generic backpack switch to VIP6 trial card failed: %v", err)
|
||
}
|
||
state6, err := service.GetVipState(ctx, userID)
|
||
if err != nil {
|
||
t.Fatalf("get state after generic VIP6 trial equip failed: %v", err)
|
||
}
|
||
if state6.EffectiveVip.Level != 6 || state6.EquippedTrialCard == nil || state6.EquippedTrialCard.EntitlementID != card6.TrialCard.EntitlementID {
|
||
t.Fatalf("trial card switch mismatch: %+v", state6)
|
||
}
|
||
card3Again, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
|
||
AppCode: "fami", RequestID: "equip-trial-3-again", UserID: userID, EntitlementID: card3.TrialCard.EntitlementID,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("re-equip VIP3 trial card failed: %v", err)
|
||
}
|
||
if card3Again.ExpiresAtMS != card3.TrialCard.ExpiresAtMS {
|
||
t.Fatalf("switching cards must preserve the old card absolute expiry: first=%d again=%d", card3.TrialCard.ExpiresAtMS, card3Again.ExpiresAtMS)
|
||
}
|
||
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
|
||
AppCode: "fami", RequestID: "equip-trial-6-again", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID,
|
||
}); err != nil {
|
||
t.Fatalf("re-equip VIP6 trial card failed: %v", err)
|
||
}
|
||
trialRebate, err := service.CheckVipBenefit(ctx, userID, ledger.VipBenefitCodeDailyCoinRebate)
|
||
if err != nil {
|
||
t.Fatalf("check trial rebate failed: %v", err)
|
||
}
|
||
if trialRebate.Allowed {
|
||
t.Fatalf("trial card must not receive daily coin rebate: %+v", trialRebate)
|
||
}
|
||
// App 级体验卡开关只控制是否参与 effective 合并,不能删除用户背包事实;关闭后应立即回落付费 VIP。
|
||
trialProgram := trialRebate.State.ProgramConfig
|
||
trialProgram.GrantMode = ledger.VipGrantModeDirectMembership
|
||
trialProgram.TrialCardEnabled = false
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: trialProgram, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("disable Fami trial-card program failed: %v", err)
|
||
}
|
||
trialOffState, err := service.GetVipState(ctx, userID)
|
||
if err != nil {
|
||
t.Fatalf("get VIP state with trial cards disabled failed: %v", err)
|
||
}
|
||
if trialOffState.EquippedTrialCard == nil || trialOffState.EffectiveSource != ledger.VipEffectiveSourcePaid || trialOffState.EffectiveVip.Level != 6 {
|
||
t.Fatalf("disabled trial cards must remain raw inventory and fall back to paid VIP: %+v", trialOffState)
|
||
}
|
||
trialProgram.GrantMode = ledger.VipGrantModeTrialCard
|
||
trialProgram.TrialCardEnabled = true
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: trialProgram, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("restore Fami trial-card program failed: %v", err)
|
||
}
|
||
restoredTrialState, err := service.GetVipState(ctx, userID)
|
||
if err != nil || restoredTrialState.EffectiveSource != ledger.VipEffectiveSourceTrial || restoredTrialState.EquippedTrialCard == nil {
|
||
t.Fatalf("restored trial-card switch must reuse the original equipped card: state=%+v err=%v", restoredTrialState, err)
|
||
}
|
||
programOff := restoredTrialState.ProgramConfig
|
||
programOff.Status = ledger.VipStatusDisabled
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: programOff, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("disable Fami VIP program failed: %v", err)
|
||
}
|
||
disabledState, err := service.GetVipState(ctx, userID)
|
||
if err != nil || disabledState.PaidVip.Level != 6 || disabledState.EquippedTrialCard == nil || disabledState.EffectiveSource != ledger.VipEffectiveSourceNone || disabledState.EffectiveVip.Active {
|
||
t.Fatalf("disabled program must expose raw facts without authorizing VIP: state=%+v err=%v", disabledState, err)
|
||
}
|
||
programOff.Status = ledger.VipStatusActive
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: programOff, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("restore Fami VIP program failed: %v", err)
|
||
}
|
||
unequipResult, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
|
||
AppCode: "fami", RequestID: "generic-unequip-trial", UserID: userID, ResourceType: resourcedomain.TypeVIPTrialCard,
|
||
})
|
||
unequipped := unequipResult.Unequipped
|
||
if err != nil || !unequipped {
|
||
t.Fatalf("generic backpack unequip trial card failed: result=%+v err=%v", unequipResult, err)
|
||
}
|
||
paidState, err := service.GetVipState(ctx, userID)
|
||
if err != nil {
|
||
t.Fatalf("get state after generic trial unequip failed: %v", err)
|
||
}
|
||
if paidState.EffectiveSource != ledger.VipEffectiveSourcePaid || paidState.EffectiveVip.Level != 6 {
|
||
t.Fatalf("unequip must fall back to paid VIP6: %+v", paidState)
|
||
}
|
||
paidRebate, err := service.CheckVipBenefit(ctx, userID, ledger.VipBenefitCodeDailyCoinRebate)
|
||
if err != nil || !paidRebate.Allowed {
|
||
t.Fatalf("paid VIP6 must retain configured rebate benefit: allowed=%v err=%v result=%+v", paidRebate.Allowed, err, paidRebate)
|
||
}
|
||
}
|
||
|
||
// TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前:
|
||
// 两个并发首发即使余额只够一单,也都返回同一成功回执,而不是让后到请求误报余额不足。
|
||
func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
service := walletservice.New(repository)
|
||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||
const userID int64 = 880003
|
||
const commandID = "vip-concurrent-exact-balance"
|
||
repository.SetBalanceForApp("lalu", userID, 1_000)
|
||
|
||
type purchaseResult struct {
|
||
receipt ledger.PurchaseVipReceipt
|
||
err error
|
||
}
|
||
start := make(chan struct{})
|
||
results := make(chan purchaseResult, 2)
|
||
for range 2 {
|
||
go func() {
|
||
<-start
|
||
receipt, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
|
||
AppCode: "lalu", CommandID: commandID, UserID: userID, Level: 1,
|
||
})
|
||
results <- purchaseResult{receipt: receipt, err: err}
|
||
}()
|
||
}
|
||
close(start)
|
||
first := <-results
|
||
second := <-results
|
||
if first.err != nil || second.err != nil {
|
||
t.Fatalf("concurrent VIP replay must return two successes: first=%v second=%v", first.err, second.err)
|
||
}
|
||
if first.receipt.OrderID == "" || first.receipt.OrderID != second.receipt.OrderID || first.receipt.TransactionID != second.receipt.TransactionID {
|
||
t.Fatalf("concurrent VIP replay returned different receipts: first=%+v second=%+v", first.receipt, second.receipt)
|
||
}
|
||
if first.receipt.CoinBalanceAfter != 0 || second.receipt.CoinBalanceAfter != 0 {
|
||
t.Fatalf("idempotent purchase must debit exact balance once: first=%d second=%d", first.receipt.CoinBalanceAfter, second.receipt.CoinBalanceAfter)
|
||
}
|
||
if got := repository.CountRows("vip_purchase_orders", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 {
|
||
t.Fatalf("concurrent replay persisted %d VIP orders, want 1", got)
|
||
}
|
||
if got := repository.CountRows("vip_command_locks", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 {
|
||
t.Fatalf("concurrent replay persisted %d VIP command guards, want 1", got)
|
||
}
|
||
}
|
||
|
||
// TestVipConfigurationListsDoNotExhaustPool 覆盖曾经的连接池自锁:主 levels rows 未关闭时,
|
||
// 每个请求再从 r.db 借连接查权益,20 个并发会占满池后相互等待。当前实现先关闭游标并在原事务内装配。
|
||
func TestVipConfigurationListsDoNotExhaustPool(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
service := walletservice.New(repository)
|
||
base := appcode.WithContext(context.Background(), "fami")
|
||
ctx, cancel := context.WithTimeout(base, 5*time.Second)
|
||
defer cancel()
|
||
|
||
start := make(chan struct{})
|
||
results := make(chan error, 24)
|
||
for index := range 24 {
|
||
go func(index int) {
|
||
<-start
|
||
if index%2 == 0 {
|
||
_, _, err := service.ListAdminVipLevels(ctx)
|
||
results <- err
|
||
return
|
||
}
|
||
_, _, err := service.ListVipPackages(ctx, int64(990000+index))
|
||
results <- err
|
||
}(index)
|
||
}
|
||
close(start)
|
||
for range 24 {
|
||
if err := <-results; err != nil {
|
||
t.Fatalf("concurrent VIP list failed before pool drain: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestVipProgramCanCreateAndResizeAppLevels(t *testing.T) {
|
||
repository := mysqltest.NewRepository(t)
|
||
service := walletservice.New(repository)
|
||
ctx := appcode.WithContext(context.Background(), "future_app")
|
||
base := ledger.VipProgramConfig{
|
||
AppCode: "future_app",
|
||
ProgramType: ledger.VipProgramTypeTieredPrivilegeV1,
|
||
LevelCount: 2,
|
||
SameLevelExpiryPolicy: ledger.VipExpiryPolicyExtendRemaining,
|
||
UpgradeExpiryPolicy: ledger.VipExpiryPolicyReplaceFromNow,
|
||
DowngradePurchasePolicy: ledger.VipDowngradePurchasePolicyReject,
|
||
BenefitInheritancePolicy: ledger.VipBenefitInheritanceTargetOnly,
|
||
GrantMode: ledger.VipGrantModeTrialCard,
|
||
TrialCardEnabled: true,
|
||
Status: ledger.VipStatusActive,
|
||
}
|
||
created, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001})
|
||
if err != nil {
|
||
t.Fatalf("create VIP program for a new app failed: %v", err)
|
||
}
|
||
if created.ConfigVersion != 1 {
|
||
t.Fatalf("new VIP program version mismatch: %+v", created)
|
||
}
|
||
_, levels, err := service.ListAdminVipLevels(ctx)
|
||
if err != nil || len(levels) != 2 || levels[0].Status != ledger.VipStatusDisabled || levels[1].DurationMS <= 0 {
|
||
t.Fatalf("new program must materialize disabled structural levels: levels=%+v err=%v", levels, err)
|
||
}
|
||
base.LevelCount = 3
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("grow VIP program level_count failed: %v", err)
|
||
}
|
||
_, levels, err = service.ListAdminVipLevels(ctx)
|
||
if err != nil || len(levels) != 3 || levels[2].Level != 3 || levels[2].Status != ledger.VipStatusDisabled {
|
||
t.Fatalf("grown program missing disabled placeholder: levels=%+v err=%v", levels, err)
|
||
}
|
||
commands := make([]ledger.AdminVipLevelCommand, 0, 3)
|
||
for _, level := range levels {
|
||
commands = append(commands, ledger.AdminVipLevelCommand{
|
||
Level: level.Level, Name: level.Name, Status: ledger.VipStatusActive,
|
||
PriceCoin: 100, DurationMS: level.DurationMS, SortOrder: level.SortOrder,
|
||
})
|
||
}
|
||
if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
|
||
t.Fatalf("activate grown VIP levels failed: %v", err)
|
||
}
|
||
base.LevelCount = 2
|
||
if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}); err != nil {
|
||
t.Fatalf("shrink VIP program level_count failed: %v", err)
|
||
}
|
||
repository.SetBalanceForApp("future_app", 880002, 1_000)
|
||
_, err = service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "future_app", CommandID: "buy-hidden-level", UserID: 880002, Level: 3})
|
||
if !xerr.IsCode(err, xerr.VIPLevelNotFound) {
|
||
t.Fatalf("level above current level_count must be unreachable, got %v", err)
|
||
}
|
||
}
|