2026-04-27 19:12:00 +08:00

529 lines
20 KiB
Go

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 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 TestThirtyDayPeriodInfoAtReturnsNextPeriodStart(t *testing.T) {
anchor := time.Date(2026, 4, 1, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
now := time.Date(2026, 4, 30, 23, 59, 30, 0, time.FixedZone("CST", 8*3600))
periodKey, periodEnd, seconds := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", anchor)
if periodKey != "20260401" {
t.Fatalf("periodKey = %s, want 20260401", 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)
}
}
func TestGetHomeUsesValidUserCountForValidCountTasks(t *testing.T) {
service, db, cleanup := newTestInviteService(t, nil)
defer cleanup()
configTime := seedInviteCampaign(t, db)
now := time.Now()
periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime)
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()
configTime := seedInviteCampaign(t, db)
now := time.Now()
periodKey, _, _ := thirtyDayPeriodInfoAt(now, "Asia/Shanghai", configTime)
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 TestGetRankUsesLifetimeValidUserAndRewardStats(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.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.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 TestRechargeMarksValidUserOnlyAfterEightyThousandCoins(t *testing.T) {
service, db, cleanup := newTestInviteService(t, nil)
defer cleanup()
configTime := seedInviteCampaign(t, db)
payTime := configTime.Add(time.Hour)
periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, "Asia/Shanghai", configTime)
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)
}
}