2026-04-24 13:04:06 +08:00

273 lines
9.8 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 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)
}
}