package invite import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "context" "errors" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "gorm.io/driver/sqlite" "gorm.io/gorm" ) type inviteTestGateway struct { grantGoldErr error grantPropsErr error grantGoldCalls int } func (g *inviteTestGateway) EnsureInviteCode(context.Context, string) (integration.InviteCode, error) { return integration.InviteCode{InviteCode: "INV-TEST"}, nil } func (g *inviteTestGateway) GetDeviceFingerprint(context.Context, int64) (string, error) { return "", nil } func (g *inviteTestGateway) GrantGold(context.Context, integration.GrantGoldRequest) error { g.grantGoldCalls++ return g.grantGoldErr } func (g *inviteTestGateway) GrantProps(context.Context, integration.GrantPropsRequest) error { return g.grantPropsErr } func newTestInviteService(t *testing.T, gateway *inviteTestGateway) (*InviteService, *gorm.DB, func()) { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate( &model.InviteCodeMapping{}, &model.UserBaseInfo{}, &model.InviteCampaignConfig{}, &model.InviteCampaignRewardRule{}, &model.InviteCampaignRelation{}, &model.InviteCampaignInviteeProgress{}, &model.InviteCampaignMonthlyProgress{}, &model.InviteCampaignTaskClaim{}, &model.InviteCampaignRewardLog{}, &model.InviteCampaignRechargeEvent{}, ); err != nil { t.Fatalf("auto migrate: %v", err) } mr, err := miniredis.Run() if err != nil { t.Fatalf("miniredis run: %v", err) } redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) if gateway == nil { gateway = &inviteTestGateway{} } service := NewInviteService(config.Config{ Invite: config.InviteConfig{PublicBaseURL: "https://invite.example.com"}, }, db, redisClient, gateway) return service, db, func() { redisClient.Close() mr.Close() } } func seedInviteCampaign(t *testing.T, db *gorm.DB) { t.Helper() now := time.Now() if err := db.Create(&model.InviteCampaignConfig{ ID: 1, SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Shanghai", CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed config: %v", err) } } func TestMonthResetInfoAtReturnsNextMonthStart(t *testing.T) { now := time.Date(2026, 4, 30, 23, 59, 30, 0, time.FixedZone("CST", 8*3600)) periodEnd, seconds := monthResetInfoAt(now, "Asia/Shanghai") if got := periodEnd.In(time.FixedZone("CST", 8*3600)).Format(time.RFC3339); got != "2026-05-01T00:00:00+08:00" { t.Fatalf("periodEnd = %s, want 2026-05-01T00:00:00+08:00", got) } if seconds != 30 { t.Fatalf("seconds = %d, want 30", seconds) } } func TestGetHomeUsesInviteCountForInviteCountTasks(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() seedInviteCampaign(t, db) now := time.Now() monthKey := monthKeyAt(now, "Asia/Shanghai") if err := db.Create(&model.InviteCodeMapping{UserID: 1001, InviteCode: "INV1001", Account: "u1001"}).Error; err != nil { t.Fatalf("seed invite code: %v", err) } if err := db.Create(&model.InviteCampaignRewardRule{ ID: 2001, SysOrigin: "LIKEI", RuleType: ruleTypeInviterValidCount, ThresholdValue: 5, GoldAmount: 100, Enabled: true, Sort: 1, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed rule: %v", err) } if err := db.Create(&model.InviteCampaignMonthlyProgress{ ID: 3001, SysOrigin: "LIKEI", InviterUserID: 1001, MonthKey: monthKey, InviteCount: 7, ValidUserCount: 0, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed monthly progress: %v", err) } resp, err := service.GetHome(context.Background(), AuthUser{UserID: 1001, SysOrigin: "LIKEI"}) if err != nil { t.Fatalf("GetHome() error = %v", err) } if len(resp.ValidCountTasks) != 1 { t.Fatalf("ValidCountTasks length = %d, want 1", len(resp.ValidCountTasks)) } task := resp.ValidCountTasks[0] if task.Progress != 7 || !task.Achieved { t.Fatalf("task progress = %d achieved = %v, want progress 7 achieved true", task.Progress, task.Achieved) } if resp.SecondsToReset <= 0 || resp.PeriodEndAtMs <= resp.ServerTimeMs { t.Fatalf("invalid countdown fields: server=%d end=%d seconds=%d", resp.ServerTimeMs, resp.PeriodEndAtMs, resp.SecondsToReset) } } func TestClaimTaskUsesInviteCountForInviteCountTasks(t *testing.T) { gateway := &inviteTestGateway{} service, db, cleanup := newTestInviteService(t, gateway) defer cleanup() seedInviteCampaign(t, db) now := time.Now() monthKey := monthKeyAt(now, "Asia/Shanghai") if err := db.Create(&model.InviteCampaignRewardRule{ ID: 2002, SysOrigin: "LIKEI", RuleType: ruleTypeInviterValidCount, ThresholdValue: 5, GoldAmount: 100, Enabled: true, Sort: 1, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed rule: %v", err) } if err := db.Create(&model.InviteCampaignMonthlyProgress{ ID: 3002, SysOrigin: "LIKEI", InviterUserID: 1002, MonthKey: monthKey, InviteCount: 5, ValidUserCount: 0, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed monthly progress: %v", err) } resp, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002}) if err != nil { t.Fatalf("ClaimTask() error = %v", err) } if !resp.Claimed || resp.Progress != 5 { t.Fatalf("claim response = %+v, want claimed progress 5", resp) } if gateway.grantGoldCalls != 1 { t.Fatalf("grantGoldCalls = %d, want 1", gateway.grantGoldCalls) } } func TestGetRankUsesLifetimeInviteAndRewardStats(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() now := time.Now() rows := []any{ &model.InviteCampaignRelation{ID: 1, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 201, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 2, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 202, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 3, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, EligibleStatus: relationStatusBlocked, BindTime: now, CreateTime: now, UpdateTime: now}, &model.UserBaseInfo{ID: 20, Account: "user20", UserNickname: "Mina", UserAvatar: "avatar-20", OriginSys: "LIKEI", CreateTime: now}, &model.UserBaseInfo{ID: 10, Account: "user10", UserNickname: "Lina", UserAvatar: "avatar-10", OriginSys: "LIKEI", CreateTime: now}, &model.InviteCampaignRewardLog{ID: 11, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "r20-1", GoldAmount: 100, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRewardLog{ID: 12, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "r20-2", GoldAmount: 200, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRewardLog{ID: 13, SysOrigin: "LIKEI", InviterUserID: refInt64(10), TargetUserID: 10, BusinessKey: "r10-1", GoldAmount: 900, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRewardLog{ID: 14, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 201, BusinessKey: "invitee-reward", GoldAmount: 999, Status: rewardLogStatusSuccess, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRewardLog{ID: 15, SysOrigin: "LIKEI", InviterUserID: refInt64(20), TargetUserID: 20, BusinessKey: "failed-reward", GoldAmount: 999, Status: rewardLogStatusFailed, CreateTime: now, UpdateTime: now}, } for _, row := range rows { if err := db.Create(row).Error; err != nil { t.Fatalf("seed row %T: %v", row, err) } } resp, err := service.GetRank(context.Background(), AuthUser{SysOrigin: "LIKEI"}, 10) if err != nil { t.Fatalf("GetRank() error = %v", err) } if len(resp.Items) != 2 { t.Fatalf("rank length = %d, want 2", len(resp.Items)) } if got := resp.Items[0]; got.Rank != 1 || got.UserID != 20 || got.Nickname != "Mina" || got.InviteCount != 2 || got.RewardGoldCoins != 300 { t.Fatalf("top rank = %+v, want user 20 count 2 reward 300", got) } if got := resp.Items[1]; got.Rank != 2 || got.UserID != 10 || got.InviteCount != 1 || got.RewardGoldCoins != 900 { t.Fatalf("second rank = %+v, want user 10 count 1 reward 900", got) } } func TestDispatchGoldReturnsGrantError(t *testing.T) { grantErr := errors.New("wallet unavailable") service, db, cleanup := newTestInviteService(t, &inviteTestGateway{grantGoldErr: grantErr}) defer cleanup() _, err := service.dispatchReward(context.Background(), rewardDispatchInput{ SysOrigin: "LIKEI", TargetUserID: 1001, Reward: RewardView{GoldAmount: 100}, Scene: ruleTypeInviterValidCount, BusinessKey: "claim:1001:202604:1", GoldOrigin: "INVITE_USER_REWARDS", Remark: "test reward", }) if !errors.Is(err, grantErr) { t.Fatalf("dispatchReward() error = %v, want %v", err, grantErr) } var log model.InviteCampaignRewardLog if err := db.Where("business_key = ?", "claim:1001:202604:1:gold").First(&log).Error; err != nil { t.Fatalf("load reward log: %v", err) } if log.Status != rewardLogStatusFailed { t.Fatalf("reward log status = %s, want %s", log.Status, rewardLogStatusFailed) } }