package invite import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "context" "encoding/json" "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 TestBuildInviteLinkUsesAppInviteH5Page(t *testing.T) { service, _, cleanup := newTestInviteService(t, nil) defer cleanup() got := service.buildInviteLink("INV1001") want := "https://invite.example.com/h5/app-invite/landing.html?code=INV1001" if got != want { t.Fatalf("invite link = %s, want %s", got, want) } service.cfg.Invite.PublicH5Path = "/custom/landing.html?view=gift" got = service.buildInviteLink("INV1001") want = "https://invite.example.com/custom/landing.html?code=INV1001&view=gift" if got != want { t.Fatalf("custom invite link = %s, want %s", got, want) } } func seedInviteCampaign(t *testing.T, db *gorm.DB) time.Time { 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) } return now } func TestCalendarMonthPeriodInfoAtReturnsNextMonthStart(t *testing.T) { now := time.Date(2026, 4, 30, 23, 59, 30, 0, time.FixedZone("CST", 8*3600)) periodKey, periodEnd, seconds, periodDays := calendarMonthPeriodInfoAt(now, "Asia/Shanghai") if periodKey != "202604" { t.Fatalf("periodKey = %s, want 202604", periodKey) } 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) } if periodDays != 30 { t.Fatalf("periodDays = %d, want 30", periodDays) } } func TestGetHomeUsesValidUserCountForValidCountTasks(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() seedInviteCampaign(t, db) now := time.Now() periodKey, _, _, _ := calendarMonthPeriodInfoAt(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: periodKey, InviteCount: 7, ValidUserCount: 4, 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 != 4 || task.Achieved { t.Fatalf("task progress = %d achieved = %v, want progress 4 achieved false", 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 TestClaimTaskUsesValidUserCountForValidCountTasks(t *testing.T) { gateway := &inviteTestGateway{} service, db, cleanup := newTestInviteService(t, gateway) defer cleanup() seedInviteCampaign(t, db) now := time.Now() periodKey, _, _, _ := calendarMonthPeriodInfoAt(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: periodKey, InviteCount: 5, ValidUserCount: 4, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed monthly progress: %v", err) } _, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002}) if err == nil { t.Fatalf("ClaimTask() error = nil, want task_not_reached") } if appErr, ok := err.(*AppError); !ok || appErr.Code != "task_not_reached" { t.Fatalf("ClaimTask() error = %v, want task_not_reached", err) } if gateway.grantGoldCalls != 0 { t.Fatalf("grantGoldCalls = %d, want 0", gateway.grantGoldCalls) } if err := db.Model(&model.InviteCampaignMonthlyProgress{}). Where("id = ?", 3002). Update("valid_user_count", 5).Error; err != nil { t.Fatalf("update valid user count: %v", err) } resp, err := service.ClaimTask(context.Background(), AuthUser{UserID: 1002, SysOrigin: "LIKEI"}, TaskClaimRequest{RuleID: 2002}) if err != nil { t.Fatalf("ClaimTask() after valid count reached 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 TestBindRegisteredCodeUsesRequestDeviceFingerprint(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() now := time.Now() if err := db.Create(&model.InviteCampaignConfig{ ID: 101, SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Shanghai", AntiCheatSameIPOnce: false, AntiCheatSameDeviceOnce: true, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed config: %v", err) } if err := db.Create(&model.InviteCodeMapping{UserID: 9001, InviteCode: "INV9001", Account: "u9001"}).Error; err != nil { t.Fatalf("seed invite code: %v", err) } if err := db.Create(&model.UserBaseInfo{ID: 9001, Account: "u9001", UserNickname: "Inviter", OriginSys: "LIKEI", CreateTime: now}).Error; err != nil { t.Fatalf("seed inviter profile: %v", err) } resp, err := service.BindRegisteredCode(context.Background(), RegisterBindCodeRequest{ InviteCode: "INV9001", SysOrigin: "LIKEI", InviteeUserID: 9101, ClientIP: "10.0.0.1", DeviceFingerprint: "device-a", }) if err != nil { t.Fatalf("BindRegisteredCode() error = %v", err) } if !resp.Eligible || resp.InviterUserID != 9001 { t.Fatalf("BindRegisteredCode() response = %+v, want eligible inviter 9001", resp) } var relation model.InviteCampaignRelation if err := db.Where("invitee_user_id = ?", 9101).First(&relation).Error; err != nil { t.Fatalf("load relation: %v", err) } if relation.DeviceFingerprint != "device-a" { t.Fatalf("relation device fingerprint = %q, want device-a", relation.DeviceFingerprint) } duplicateResp, err := service.BindRegisteredCode(context.Background(), RegisterBindCodeRequest{ InviteCode: "INV9001", SysOrigin: "LIKEI", InviteeUserID: 9102, ClientIP: "10.0.0.2", DeviceFingerprint: "device-a", }) if err != nil { t.Fatalf("BindRegisteredCode() duplicate device error = %v", err) } if duplicateResp.Eligible || duplicateResp.BlockReason != blockReasonDuplicateDevice { t.Fatalf("duplicate response = %+v, want blocked duplicate device", duplicateResp) } } func TestBindCodeAcceptsSnakeInviteCodeAndIsIdempotent(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() now := time.Now() if err := db.Create(&model.InviteCampaignConfig{ ID: 201, SysOrigin: "LIKEI", Enabled: true, Timezone: "Asia/Shanghai", AntiCheatSameIPOnce: false, AntiCheatSameDeviceOnce: false, CreateTime: now, UpdateTime: now, }).Error; err != nil { t.Fatalf("seed config: %v", err) } if err := db.Create(&model.InviteCodeMapping{UserID: 9201, InviteCode: "INV9201", Account: "u9201"}).Error; err != nil { t.Fatalf("seed invite code: %v", err) } if err := db.Create(&model.UserBaseInfo{ID: 9201, Account: "u9201", UserNickname: "Inviter", OriginSys: "LIKEI", CreateTime: now}).Error; err != nil { t.Fatalf("seed inviter profile: %v", err) } user := AuthUser{UserID: 9301, SysOrigin: "LIKEI"} resp, err := service.BindCode(context.Background(), user, "10.0.0.3", BindCodeRequest{InviteCodeSnake: "INV9201"}) if err != nil { t.Fatalf("BindCode() error = %v", err) } if resp.AlreadyBound || !resp.Eligible || resp.InviterUserID != 9201 { t.Fatalf("BindCode() response = %+v, want first bind eligible inviter 9201", resp) } again, err := service.BindCode(context.Background(), user, "10.0.0.3", BindCodeRequest{InviteCodeSnake: "INV9201"}) if err != nil { t.Fatalf("BindCode() idempotent error = %v", err) } if !again.AlreadyBound || again.RelationID != resp.RelationID { t.Fatalf("idempotent response = %+v, want same relation alreadyBound", again) } var relationCount int64 if err := db.Model(&model.InviteCampaignRelation{}). Where("sys_origin = ? AND invitee_user_id = ?", "LIKEI", 9301). Count(&relationCount).Error; err != nil { t.Fatalf("count relations: %v", err) } if relationCount != 1 { t.Fatalf("relation count = %d, want 1", relationCount) } var progress model.InviteCampaignMonthlyProgress if err := db.Where("sys_origin = ? AND inviter_user_id = ?", "LIKEI", 9201).First(&progress).Error; err != nil { t.Fatalf("load progress: %v", err) } if progress.InviteCount != 1 { t.Fatalf("invite count = %d, want 1", progress.InviteCount) } } 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: 20, InviteeUserID: 203, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 5, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, EligibleStatus: relationStatusBlocked, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 6, SysOrigin: "LIKEI", InviterUserID: 30, InviteeUserID: 301, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignRelation{ID: 7, SysOrigin: "LIKEI", InviterUserID: 30, InviteeUserID: 302, EligibleStatus: relationStatusEligible, BindTime: now, CreateTime: now, UpdateTime: now}, &model.InviteCampaignInviteeProgress{ID: 101, RelationID: 1, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 201, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, &model.InviteCampaignInviteeProgress{ID: 102, RelationID: 2, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 202, TotalRecharge: 79999, ValidUser: false, CreateTime: now, UpdateTime: now}, &model.InviteCampaignInviteeProgress{ID: 103, RelationID: 3, SysOrigin: "LIKEI", InviterUserID: 20, InviteeUserID: 203, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, &model.InviteCampaignInviteeProgress{ID: 104, RelationID: 4, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 101, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(now), CreateTime: now, UpdateTime: now}, &model.InviteCampaignInviteeProgress{ID: 105, RelationID: 5, SysOrigin: "LIKEI", InviterUserID: 10, InviteeUserID: 102, TotalRecharge: 80000, ValidUser: true, ValidTime: refTime(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.UserBaseInfo{ID: 30, Account: "user30", UserNickname: "Nora", UserAvatar: "avatar-30", 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) != 3 { t.Fatalf("rank length = %d, want 3", len(resp.Items)) } if got := resp.Items[0]; got.Rank != 1 || got.UserID != 10 || got.Nickname != "Lina" || got.InviteCount != 1 || got.RewardGoldCoins != 900 { t.Fatalf("top rank = %+v, want user 10 count 1 reward 900", got) } if got := resp.Items[1]; got.Rank != 2 || got.UserID != 20 || got.InviteCount != 3 || got.RewardGoldCoins != 300 { t.Fatalf("second rank = %+v, want user 20 count 3 reward 300", got) } if got := resp.Items[2]; got.Rank != 3 || got.UserID != 30 || got.Nickname != "Nora" || got.InviteCount != 2 || got.RewardGoldCoins != 0 { t.Fatalf("third rank = %+v, want user 30 count 2 reward 0", got) } } func TestRechargeMarksValidUserOnlyAfterEightyThousandCoins(t *testing.T) { service, db, cleanup := newTestInviteService(t, nil) defer cleanup() configTime := seedInviteCampaign(t, db) payTime := configTime.Add(time.Hour) periodKey, _, _, _ := calendarMonthPeriodInfoAt(payTime, "Asia/Shanghai") rows := []any{ &model.InviteCampaignRewardRule{ ID: 2101, SysOrigin: "LIKEI", RuleType: ruleTypeInviteeRecharge, ThresholdValue: 10000, GoldAmount: 0, Enabled: true, Sort: 1, CreateTime: configTime, UpdateTime: configTime, }, &model.InviteCampaignRelation{ ID: 4101, SysOrigin: "LIKEI", InviterUserID: 5101, InviteeUserID: 5201, EligibleStatus: relationStatusEligible, BindTime: configTime, CreateTime: configTime, UpdateTime: configTime, }, &model.InviteCampaignInviteeProgress{ ID: 4201, RelationID: 4101, SysOrigin: "LIKEI", InviterUserID: 5101, InviteeUserID: 5201, CreateTime: configTime, UpdateTime: configTime, }, } for _, row := range rows { if err := db.Create(row).Error; err != nil { t.Fatalf("seed row %T: %v", row, err) } } resp, err := service.HandleRechargeSuccess(context.Background(), RechargeSuccessRequest{ OrderID: "order-79999", UserID: 5201, RechargeCoins: 79999, PayTime: payTime.UnixMilli(), SysOrigin: "LIKEI", }) if err != nil { t.Fatalf("HandleRechargeSuccess() 79999 error = %v", err) } if resp.ValidUser { t.Fatalf("ValidUser = true after 79999 recharge, want false") } monthly, err := service.findMonthlyProgress(context.Background(), "LIKEI", 5101, periodKey) if err != nil { t.Fatalf("find monthly progress: %v", err) } if monthly.ValidUserCount != 0 || monthly.TotalRecharge != 79999 { t.Fatalf("monthly after 79999 = %+v, want valid 0 total 79999", monthly) } resp, err = service.HandleRechargeSuccess(context.Background(), RechargeSuccessRequest{ OrderID: "order-80000", UserID: 5201, RechargeCoins: 1, PayTime: payTime.Add(time.Minute).UnixMilli(), SysOrigin: "LIKEI", }) if err != nil { t.Fatalf("HandleRechargeSuccess() 80000 error = %v", err) } if !resp.ValidUser { t.Fatalf("ValidUser = false after 80000 recharge, want true") } monthly, err = service.findMonthlyProgress(context.Background(), "LIKEI", 5101, periodKey) if err != nil { t.Fatalf("find monthly progress after 80000: %v", err) } if monthly.ValidUserCount != 1 || monthly.TotalRecharge != 80000 { t.Fatalf("monthly after 80000 = %+v, want valid 1 total 80000", monthly) } } 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) } } func TestInviteRuleIDJSONUsesStringForSnowflakeIDs(t *testing.T) { const ruleID int64 = 2048743937920790530 payload, err := json.Marshal(HomeResponse{ ValidCountTasks: []TaskView{{RuleID: ruleID, RuleType: ruleTypeInviterValidCount}}, InviterBindReward: RewardView{ RuleID: ruleID, RuleType: ruleTypeInviterBind, }, }) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } raw := string(payload) if !strings.Contains(raw, `"ruleId":"2048743937920790530"`) { t.Fatalf("ruleId was not encoded as a string: %s", raw) } } func TestTaskClaimRequestAcceptsStringRuleID(t *testing.T) { var req TaskClaimRequest if err := json.Unmarshal([]byte(`{"ruleId":"2048743937920790530"}`), &req); err != nil { t.Fatalf("json.Unmarshal() error = %v", err) } if req.RuleID != 2048743937920790530 { t.Fatalf("RuleID = %d, want 2048743937920790530", req.RuleID) } }