package vip import ( "context" "encoding/json" "errors" "net/http" "strings" "testing" "time" "chatapp3-golang/internal/model" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func TestBuildPurchasePlanNewPurchase(t *testing.T) { now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) configs := vipTestConfigs() plan, err := buildPurchasePlan(now, nil, configs[2], configs) if err != nil { t.Fatalf("buildPurchasePlan returned error: %v", err) } if plan.OrderType != orderTypePurchase { t.Fatalf("order type = %s, want %s", plan.OrderType, orderTypePurchase) } if plan.CurrentLevel != 0 || plan.TargetLevel != 2 { t.Fatalf("levels = %d -> %d, want 0 -> 2", plan.CurrentLevel, plan.TargetLevel) } if plan.PayableGold != 2000 { t.Fatalf("payable = %d, want 2000", plan.PayableGold) } if !plan.StartAt.Equal(now) || !plan.ExpireAt.Equal(now.AddDate(0, 0, vipDurationDay)) { t.Fatalf("period = %s -> %s, want %s -> %s", plan.StartAt, plan.ExpireAt, now, now.AddDate(0, 0, vipDurationDay)) } } func TestBuildPurchasePlanUpgradePaysDifferenceAndKeepsExpireAt(t *testing.T) { now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) startAt := now.AddDate(0, 0, -5) expireAt := now.AddDate(0, 0, 25) configs := vipTestConfigs() state := &model.UserVipState{ Level: 2, Status: statusActive, StartAt: startAt, ExpireAt: expireAt, } plan, err := buildPurchasePlan(now, state, configs[4], configs) if err != nil { t.Fatalf("buildPurchasePlan returned error: %v", err) } if plan.OrderType != orderTypeUpgrade { t.Fatalf("order type = %s, want %s", plan.OrderType, orderTypeUpgrade) } if plan.PayableGold != 2000 { t.Fatalf("payable = %d, want 2000", plan.PayableGold) } if !plan.StartAt.Equal(startAt) || !plan.ExpireAt.Equal(expireAt) { t.Fatalf("upgrade period changed to %s -> %s, want %s -> %s", plan.StartAt, plan.ExpireAt, startAt, expireAt) } } func TestBuildPurchasePlanRenewExtendsThirtyDays(t *testing.T) { now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) startAt := now.AddDate(0, 0, -5) expireAt := now.AddDate(0, 0, 25) configs := vipTestConfigs() state := &model.UserVipState{ Level: 3, Status: statusActive, StartAt: startAt, ExpireAt: expireAt, } plan, err := buildPurchasePlan(now, state, configs[3], configs) if err != nil { t.Fatalf("buildPurchasePlan returned error: %v", err) } if plan.OrderType != orderTypeRenew { t.Fatalf("order type = %s, want %s", plan.OrderType, orderTypeRenew) } if plan.PayableGold != 3000 { t.Fatalf("payable = %d, want 3000", plan.PayableGold) } if !plan.ExpireAt.Equal(expireAt.AddDate(0, 0, vipDurationDay)) { t.Fatalf("renew expireAt = %s, want %s", plan.ExpireAt, expireAt.AddDate(0, 0, vipDurationDay)) } } func TestBuildPurchasePlanRejectsDowngrade(t *testing.T) { now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) configs := vipTestConfigs() state := &model.UserVipState{ Level: 4, Status: statusActive, StartAt: now.AddDate(0, 0, -5), ExpireAt: now.AddDate(0, 0, 25), } if _, err := buildPurchasePlan(now, state, configs[2], configs); err == nil { t.Fatal("buildPurchasePlan returned nil error for downgrade") } } func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) { var payload VipResourcePayload if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089","name":"vip","sourceUrl":"https://example.com/a.svga","coverUrl":"https://example.com/a.png"}`), &payload); err != nil { t.Fatalf("unmarshal resource payload: %v", err) } payload = normalizeResource(payload) if payload.ResourceID.Int64() != 2045029471274201089 { t.Fatalf("resource id = %d, want 2045029471274201089", payload.ResourceID.Int64()) } if payload.URL != "https://example.com/a.svga" || payload.SourceURL != payload.URL || payload.ResourceURL != payload.URL { t.Fatalf("resource urls = url:%q sourceUrl:%q resourceUrl:%q", payload.URL, payload.SourceURL, payload.ResourceURL) } if payload.Cover != "https://example.com/a.png" || payload.CoverURL != payload.Cover { t.Fatalf("resource cover = cover:%q coverUrl:%q", payload.Cover, payload.CoverURL) } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal resource payload: %v", err) } if got := string(body); got != `{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga","sourceUrl":"https://example.com/a.svga","resourceUrl":"https://example.com/a.svga","cover":"https://example.com/a.png","coverUrl":"https://example.com/a.png"}` { t.Fatalf("json = %s", got) } } func TestVipLevelPayloadMarshalsIDAsString(t *testing.T) { payload := payloadFromConfig(model.VipLevelConfig{ ID: 2049402425177018368, SysOrigin: "LIKEI", Level: 1, LevelCode: "VIP1", DisplayName: "VIP 1", Enabled: true, }) body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal vip level payload: %v", err) } if !strings.Contains(string(body), `"id":"2049402425177018368"`) { t.Fatalf("vip level id was not emitted as string: %s", body) } var decoded VipLevelPayload if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal vip level payload: %v", err) } if decoded.ID.Int64() != 2049402425177018368 { t.Fatalf("decoded id = %d, want 2049402425177018368", decoded.ID.Int64()) } } func TestResourcePayloadUsesResourceGroupDetails(t *testing.T) { row := model.VipLevelConfig{ Level: 2, LevelCode: "VIP2", DisplayName: "VIP 2", Enabled: true, DurationDays: vipDurationDay, PriceGold: 2000, AvatarFrameResourceID: 11, AvatarFrameName: "stale frame", AvatarFrameURL: "https://example.com/stale.svga", } payload := payloadFromConfigWithResources(row, vipResourceLookup{ 11: { ID: 11, Name: "resource group frame", Cover: "https://example.com/live.png", SourceURL: "https://example.com/live.svga", }, }) if payload.AvatarFrame.Name != "resource group frame" || payload.AvatarFrame.URL != "https://example.com/live.svga" || payload.AvatarFrame.Cover != "https://example.com/live.png" { t.Fatalf("avatar frame was not loaded from resource group: %+v", payload.AvatarFrame) } } func TestApplyPlanToStateKeepsOnlyResourceIDsAndPayloadUsesLookup(t *testing.T) { now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) plan := purchasePlan{ TargetLevel: 3, StartAt: now, ExpireAt: now.AddDate(0, 0, vipDurationDay), DurationDays: vipDurationDay, TargetConfig: model.VipLevelConfig{ Level: 3, DisplayName: "VIP 3", BadgeResourceID: 10, BadgeName: "long badge", BadgeURL: "https://example.com/long.png", ShortBadgeResourceID: 15, ShortBadgeName: "short badge", ShortBadgeURL: "https://example.com/short.png", AvatarFrameResourceID: 11, AvatarFrameName: "frame", AvatarFrameURL: "https://example.com/frame.svga", EntryEffectResourceID: 12, EntryEffectName: "ride", EntryEffectURL: "https://example.com/ride.svga", ChatBubbleResourceID: 13, ChatBubbleName: "bubble", ChatBubbleURL: "https://example.com/bubble.svga", FloatPictureResourceID: 14, FloatPictureName: "float", FloatPictureURL: "https://example.com/float.svga", BackgroundCardResourceID: 16, BackgroundCardName: "profile card", BackgroundCardURL: "https://example.com/card.pag", EffectImageResourceID: 17, EffectImageName: "effect image", EffectImageURL: "https://example.com/effect.svga", }, } state := &model.UserVipState{UserID: 1001, SysOrigin: "LIKEI"} applyPlanToState(state, plan, now) if state.BadgeResourceID != plan.TargetConfig.BadgeResourceID || state.ShortBadgeResourceID != plan.TargetConfig.ShortBadgeResourceID || state.AvatarFrameResourceID != plan.TargetConfig.AvatarFrameResourceID || state.EntryEffectResourceID != plan.TargetConfig.EntryEffectResourceID || state.ChatBubbleResourceID != plan.TargetConfig.ChatBubbleResourceID || state.FloatPictureResourceID != plan.TargetConfig.FloatPictureResourceID || state.BackgroundCardResourceID != plan.TargetConfig.BackgroundCardResourceID || state.EffectImageResourceID != plan.TargetConfig.EffectImageResourceID { t.Fatalf("state resource ids were not copied from target config") } if state.AvatarFrameName != "" || state.AvatarFrameURL != "" { t.Fatalf("state stored resource details: name=%q url=%q", state.AvatarFrameName, state.AvatarFrameURL) } payload := statePayloadFromModelWithResources(state, now, vipResourceLookup{ 10: {ID: 10, Name: "long badge", Cover: "https://example.com/long.png", SourceURL: "https://example.com/long.svga"}, 11: {ID: 11, Name: "frame", Cover: "https://example.com/frame.png", SourceURL: "https://example.com/frame.svga"}, 12: {ID: 12, Name: "ride", Cover: "https://example.com/ride.png", SourceURL: "https://example.com/ride.svga"}, 13: {ID: 13, Name: "bubble", Cover: "https://example.com/bubble.png", SourceURL: "https://example.com/bubble.svga"}, 14: {ID: 14, Name: "float", Cover: "https://example.com/float.png", SourceURL: "https://example.com/float.svga"}, 15: {ID: 15, Name: "short badge", Cover: "https://example.com/short.png", SourceURL: "https://example.com/short.svga"}, 16: {ID: 16, Name: "profile card", Cover: "https://example.com/card.png", SourceURL: "https://example.com/card.pag"}, 17: {ID: 17, Name: "effect image", Cover: "https://example.com/effect.png", SourceURL: "https://example.com/effect.svga"}, }) if payload.LongBadge.Cover != "https://example.com/long.png" || payload.Badge.Cover != "https://example.com/long.png" || payload.ShortBadge.Cover != "https://example.com/short.png" || payload.AvatarFrame.Cover != "https://example.com/frame.png" || payload.EntryEffect.Cover != "https://example.com/ride.png" || payload.ChatBubble.Cover != "https://example.com/bubble.png" || payload.FloatPicture.Cover != "https://example.com/float.png" || payload.BackgroundCard.Cover != "https://example.com/card.png" || payload.EffectImage.Cover != "https://example.com/effect.png" { t.Fatalf("state payload covers were not loaded from resource lookup") } } func TestMapWalletPurchaseErrorInsufficientBalance(t *testing.T) { err := errors.New(`java api failed: status=406 body={"errorCode":5000,"errorCodeName":"INSUFFICIENT_BALANCE","errorMsg":"balance not made"}`) appErr := mapWalletPurchaseError(err) if appErr.Status != http.StatusNotAcceptable { t.Fatalf("status = %d, want %d", appErr.Status, http.StatusNotAcceptable) } if appErr.Code != "vip_insufficient_balance" { t.Fatalf("code = %s, want vip_insufficient_balance", appErr.Code) } if appErr.Message != "insufficient balance" { t.Fatalf("message = %s, want insufficient balance", appErr.Message) } } func TestMapWalletPurchaseErrorGatewayFailure(t *testing.T) { err := errors.New("java api failed: status=500 body=boom") appErr := mapWalletPurchaseError(err) if appErr.Status != http.StatusBadGateway { t.Fatalf("status = %d, want %d", appErr.Status, http.StatusBadGateway) } if appErr.Code != "vip_wallet_deduct_failed" { t.Fatalf("code = %s, want vip_wallet_deduct_failed", appErr.Code) } } func TestVIPGrantResourcesFromConfigMapsBackpackTypes(t *testing.T) { config := model.VipLevelConfig{ BadgeResourceID: 10, ShortBadgeResourceID: 15, AvatarFrameResourceID: 11, EntryEffectResourceID: 12, ChatBubbleResourceID: 13, FloatPictureResourceID: 14, BackgroundCardResourceID: 16, EffectImageResourceID: 17, } resources := vipGrantResourcesFromConfig(config) if len(resources) != 7 { t.Fatalf("resources len = %d, want 7", len(resources)) } assertGrant := func(index int, resourceID int64, propsType string, badge bool) { t.Helper() got := resources[index] if got.ResourceID != resourceID || got.PropsType != propsType || got.Badge != badge { t.Fatalf("resource[%d] = %+v, want id=%d type=%s badge=%v", index, got, resourceID, propsType, badge) } } assertGrant(0, 10, "", true) assertGrant(1, 15, "", true) assertGrant(2, 11, vipPropsTypeAvatarFrame, false) assertGrant(3, 12, vipPropsTypeRide, false) assertGrant(4, 13, vipPropsTypeChatBubble, false) assertGrant(5, 14, vipPropsTypeFloatPicture, false) assertGrant(6, 16, vipPropsTypeDataCard, false) } func TestCleanupSupersededVIPResourcesExpiresOnlyOldVIPResources(t *testing.T) { db := openVIPCleanupTestDB(t) service := &Service{db: db} now := time.Date(2026, 5, 30, 10, 0, 0, 0, time.UTC) future := now.AddDate(0, 0, 20) oldTime := now.AddDate(0, 0, -1) userID := int64(1001) seedVIPCleanupRows(t, db, vipCleanupPropsRow{ID: 1, UserID: userID, PropsID: 101, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime}, vipCleanupPropsRow{ID: 2, UserID: userID, PropsID: 201, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime}, vipCleanupPropsRow{ID: 3, UserID: userID, PropsID: 999, Type: vipPropsTypeAvatarFrame, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime}, vipCleanupPropsRow{ID: 4, UserID: userID, PropsID: 102, Type: vipPropsTypeRide, ExpireTime: future, UseProps: true, AllowGive: true, UpdateTime: oldTime}, ) seedVIPCleanupBadges(t, db, vipCleanupBadgeRow{ID: 1, UserID: userID, BadgeID: 100, ExpireTime: future, UseProps: true, UpdateTime: oldTime}, vipCleanupBadgeRow{ID: 2, UserID: userID, BadgeID: 200, ExpireTime: future, UseProps: true, UpdateTime: oldTime}, ) target := vipGrantResourcesFromConfig(model.VipLevelConfig{ BadgeResourceID: 200, AvatarFrameResourceID: 201, }) all := append(vipGrantResourcesFromConfig(model.VipLevelConfig{ BadgeResourceID: 100, AvatarFrameResourceID: 101, EntryEffectResourceID: 102, }), target...) if err := service.cleanupSupersededVIPResources(context.Background(), userID, target, all, now); err != nil { t.Fatalf("cleanupSupersededVIPResources() error = %v", err) } assertVIPCleanupProps(t, db, 101, now, false, false) assertVIPCleanupProps(t, db, 102, now, false, false) assertVIPCleanupProps(t, db, 201, future, true, true) assertVIPCleanupProps(t, db, 999, future, true, true) assertVIPCleanupBadge(t, db, 100, now, false) assertVIPCleanupBadge(t, db, 200, future, true) } func TestVIPGrantDaysUntilCeilsPartialDay(t *testing.T) { now := time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC) expireAt := now.Add((24 * time.Hour) + time.Second) if got := vipGrantDaysUntil(now, expireAt); got != 2 { t.Fatalf("days = %d, want 2", got) } if got := vipGrantDaysUntil(now, now); got != 1 { t.Fatalf("expired days = %d, want 1", got) } } type vipCleanupPropsRow struct { ID int64 `gorm:"column:id;primaryKey"` UserID int64 `gorm:"column:user_id"` PropsID int64 `gorm:"column:props_id"` Type string `gorm:"column:type"` ExpireTime time.Time `gorm:"column:expire_time"` UseProps bool `gorm:"column:is_use_props"` AllowGive bool `gorm:"column:allow_give"` UpdateTime time.Time `gorm:"column:update_time"` } func (vipCleanupPropsRow) TableName() string { return "user_props_backpack" } type vipCleanupBadgeRow struct { ID int64 `gorm:"column:id;primaryKey"` UserID int64 `gorm:"column:user_id"` BadgeID int64 `gorm:"column:badge_id"` ExpireType string `gorm:"column:expire_type"` ExpireTime time.Time `gorm:"column:expire_time"` UseProps bool `gorm:"column:is_use_props"` UpdateTime time.Time `gorm:"column:update_time"` } func (vipCleanupBadgeRow) TableName() string { return "user_badge_backpack" } func openVIPCleanupTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&vipCleanupPropsRow{}, &vipCleanupBadgeRow{}); err != nil { t.Fatalf("auto migrate cleanup tables: %v", err) } return db } func seedVIPCleanupRows(t *testing.T, db *gorm.DB, rows ...vipCleanupPropsRow) { t.Helper() for _, row := range rows { if err := db.Create(&row).Error; err != nil { t.Fatalf("seed props row: %v", err) } } } func seedVIPCleanupBadges(t *testing.T, db *gorm.DB, rows ...vipCleanupBadgeRow) { t.Helper() for _, row := range rows { if err := db.Create(&row).Error; err != nil { t.Fatalf("seed badge row: %v", err) } } } func assertVIPCleanupProps(t *testing.T, db *gorm.DB, propsID int64, expireTime time.Time, useProps bool, allowGive bool) { t.Helper() var row vipCleanupPropsRow if err := db.Where("props_id = ?", propsID).First(&row).Error; err != nil { t.Fatalf("load props %d: %v", propsID, err) } if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps || row.AllowGive != allowGive { t.Fatalf("props %d = expire:%s use:%v allow:%v, want expire:%s use:%v allow:%v", propsID, row.ExpireTime, row.UseProps, row.AllowGive, expireTime, useProps, allowGive) } } func assertVIPCleanupBadge(t *testing.T, db *gorm.DB, badgeID int64, expireTime time.Time, useProps bool) { t.Helper() var row vipCleanupBadgeRow if err := db.Where("badge_id = ?", badgeID).First(&row).Error; err != nil { t.Fatalf("load badge %d: %v", badgeID, err) } if !row.ExpireTime.Equal(expireTime) || row.UseProps != useProps { t.Fatalf("badge %d = expire:%s use:%v, want expire:%s use:%v", badgeID, row.ExpireTime, row.UseProps, expireTime, useProps) } } func vipTestConfigs() map[int]model.VipLevelConfig { configs := make(map[int]model.VipLevelConfig, levelCount) for level := 1; level <= levelCount; level++ { configs[level] = model.VipLevelConfig{ Level: level, LevelCode: levelCode(level), DisplayName: levelDisplayName(level), Enabled: true, DurationDays: vipDurationDay, PriceGold: int64(level * 1000), } } return configs }