package inviteactivity import ( "context" "testing" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" domain "hyapp/services/activity-service/internal/domain/inviteactivity" ) func TestClaimRechargeOnlyHighestTierDelta(t *testing.T) { repo := newFakeInviteActivityRepo() repo.config = domain.Config{ AppCode: "lalu", Enabled: true, Tiers: []domain.Tier{ {TierID: 1, RewardType: domain.RewardTypeRecharge, TierCode: "r1", TierName: "80k", ThresholdCoinAmount: 80000, RewardCoinAmount: 1000, Status: domain.TierStatusActive, SortOrder: 1}, {TierID: 2, RewardType: domain.RewardTypeRecharge, TierCode: "r2", TierName: "160k", ThresholdCoinAmount: 160000, RewardCoinAmount: 2500, Status: domain.TierStatusActive, SortOrder: 2}, }, } repo.progress = domain.Progress{AppCode: "lalu", CycleKey: "2026-06", UserID: 42, TotalRechargeCoinAmount: 200000} repo.claims = []domain.Claim{ {ClaimID: "IAR_42_2026-06_recharge_1", AppCode: "lalu", CycleKey: "2026-06", UserID: 42, RewardType: domain.RewardTypeRecharge, TierID: 1, RewardCoinAmount: 1000, Status: domain.ClaimStatusGranted}, } wallet := &fakeInviteActivityWallet{} service := New(repo, wallet, nil) service.SetClock(func() time.Time { return time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) }) ctx := appcode.WithContext(context.Background(), "lalu") if _, _, err := service.Claim(ctx, 42, domain.RewardTypeRecharge, 1, "cmd-low"); err == nil { t.Fatalf("expected lower recharge tier claim to be rejected") } claim, status, err := service.Claim(ctx, 42, domain.RewardTypeRecharge, 2, "cmd-high") if err != nil { t.Fatalf("claim highest recharge tier: %v", err) } if claim.RewardCoinAmount != 1500 { t.Fatalf("expected delta reward 1500, got %d", claim.RewardCoinAmount) } if len(wallet.requests) != 1 || wallet.requests[0].Amount != 1500 { t.Fatalf("wallet amount mismatch: %#v", wallet.requests) } if len(status.Claims) != 2 { t.Fatalf("expected refreshed status with 2 claims, got %d", len(status.Claims)) } } func TestClaimValidInviteEachReachedTier(t *testing.T) { repo := newFakeInviteActivityRepo() repo.config = domain.Config{ AppCode: "lalu", Enabled: true, Tiers: []domain.Tier{ {TierID: 11, RewardType: domain.RewardTypeValidInvite, TierCode: "v1", TierName: "one", ThresholdValidInviteCount: 1, RewardCoinAmount: 100, Status: domain.TierStatusActive, SortOrder: 1}, {TierID: 12, RewardType: domain.RewardTypeValidInvite, TierCode: "v3", TierName: "three", ThresholdValidInviteCount: 3, RewardCoinAmount: 500, Status: domain.TierStatusActive, SortOrder: 2}, }, } repo.progress = domain.Progress{AppCode: "lalu", CycleKey: "2026-06", UserID: 77, ValidInviteCount: 3} wallet := &fakeInviteActivityWallet{} service := New(repo, wallet, nil) service.SetClock(func() time.Time { return time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) }) ctx := appcode.WithContext(context.Background(), "lalu") first, _, err := service.Claim(ctx, 77, domain.RewardTypeValidInvite, 11, "cmd-valid-1") if err != nil { t.Fatalf("claim first valid invite tier: %v", err) } second, _, err := service.Claim(ctx, 77, domain.RewardTypeValidInvite, 12, "cmd-valid-3") if err != nil { t.Fatalf("claim second valid invite tier: %v", err) } if first.RewardCoinAmount != 100 || second.RewardCoinAmount != 500 { t.Fatalf("unexpected rewards: first=%d second=%d", first.RewardCoinAmount, second.RewardCoinAmount) } if len(wallet.requests) != 2 { t.Fatalf("expected 2 wallet credits, got %d", len(wallet.requests)) } if _, _, err := service.Claim(ctx, 77, domain.RewardTypeValidInvite, 11, "cmd-valid-repeat"); err == nil { t.Fatalf("expected duplicate valid invite tier claim to be rejected") } } func TestConsumeInviteEventGrantsPerInviteRewardsIdempotently(t *testing.T) { repo := newFakeInviteActivityRepo() repo.config = domain.Config{ AppCode: "lalu", Enabled: true, PerInviteInviterRewardCoinAmount: 700, PerInviteInviteeRewardCoinAmount: 200, } wallet := &fakeInviteActivityWallet{} service := New(repo, wallet, nil) service.SetClock(func() time.Time { return time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) }) ctx := appcode.WithContext(context.Background(), "lalu") event := domain.InviteEvent{ AppCode: "lalu", EventID: "evt-user-invited-1", EventType: domain.EventTypeUserInvited, InviterUserID: 42, InvitedUserID: 99, InviteCode: "ABC123", Source: "invite_h5", OccurredAtMS: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC).UnixMilli(), } if err := service.ConsumeInviteEvent(ctx, event); err != nil { t.Fatalf("consume invite event: %v", err) } if len(wallet.requests) != 2 { t.Fatalf("expected inviter and invitee wallet credits, got %d", len(wallet.requests)) } if wallet.requests[0].GetTargetUserId() != 42 || wallet.requests[0].GetAmount() != 700 || wallet.requests[0].GetRewardType() != domain.RewardTypeInviteInviter { t.Fatalf("inviter reward mismatch: %#v", wallet.requests[0]) } if wallet.requests[1].GetTargetUserId() != 99 || wallet.requests[1].GetAmount() != 200 || wallet.requests[1].GetRewardType() != domain.RewardTypeInvitee { t.Fatalf("invitee reward mismatch: %#v", wallet.requests[1]) } if len(repo.claims) != 2 || repo.claims[0].Status != domain.ClaimStatusGranted || repo.claims[1].Status != domain.ClaimStatusGranted { t.Fatalf("expected 2 granted claims, got %#v", repo.claims) } if err := service.ConsumeInviteEvent(ctx, event); err != nil { t.Fatalf("consume duplicate invite event: %v", err) } if len(wallet.requests) != 2 || len(repo.claims) != 2 { t.Fatalf("duplicate event should reuse existing claims: wallet=%d claims=%d", len(wallet.requests), len(repo.claims)) } } func TestListLeaderboardDefaultsCurrentCycleAndCapsPageSize(t *testing.T) { repo := newFakeInviteActivityRepo() repo.leaderboardEntries = []domain.LeaderboardEntry{ {RankNo: 1, UserID: 1001, ValidInviteCount: 3, TotalRechargeCoinAmount: 240000}, } service := New(repo, nil, nil) service.SetClock(func() time.Time { return time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) }) ctx := appcode.WithContext(context.Background(), "lalu") result, err := service.ListLeaderboard(ctx, domain.LeaderboardQuery{PageSize: 1000}) if err != nil { t.Fatalf("list leaderboard: %v", err) } if result.CycleKey != "2026-06" { t.Fatalf("expected current cycle, got %q", result.CycleKey) } if repo.lastLeaderboardQuery.Page != 1 || repo.lastLeaderboardQuery.PageSize != 100 { t.Fatalf("unexpected normalized query: %#v", repo.lastLeaderboardQuery) } if result.Total != 1 || len(result.Entries) != 1 || result.Entries[0].UserID != 1001 { t.Fatalf("unexpected leaderboard result: %#v", result) } } func TestListSummariesTrimsCycleAndCapsPageSize(t *testing.T) { repo := newFakeInviteActivityRepo() repo.summaries = []domain.Summary{ {AppCode: "lalu", CycleKey: "2026-06", UserID: 1001, InviteCount: 2, ValidInviteCount: 1, TotalRechargeCoinAmount: 9000, TotalRewardCoinAmount: 700}, } service := New(repo, nil, nil) ctx := appcode.WithContext(context.Background(), "lalu") summaries, total, err := service.ListSummaries(ctx, domain.SummaryQuery{CycleKey: " 2026-06 ", PageSize: 1000}) if err != nil { t.Fatalf("list summaries: %v", err) } if repo.lastSummaryQuery.CycleKey != "2026-06" || repo.lastSummaryQuery.Page != 1 || repo.lastSummaryQuery.PageSize != 100 { t.Fatalf("unexpected normalized summary query: %#v", repo.lastSummaryQuery) } if total != 1 || len(summaries) != 1 || summaries[0].UserID != 1001 { t.Fatalf("unexpected summary result: total=%d summaries=%#v", total, summaries) } } type fakeInviteActivityRepo struct { config domain.Config progress domain.Progress claims []domain.Claim summaries []domain.Summary leaderboardEntries []domain.LeaderboardEntry lastSummaryQuery domain.SummaryQuery lastLeaderboardQuery domain.LeaderboardQuery } func newFakeInviteActivityRepo() *fakeInviteActivityRepo { return &fakeInviteActivityRepo{} } func (r *fakeInviteActivityRepo) GetInviteActivityRewardConfig(context.Context) (domain.Config, bool, error) { return r.config, true, nil } func (r *fakeInviteActivityRepo) UpdateInviteActivityRewardConfig(context.Context, domain.Config, int64) (domain.Config, error) { return domain.Config{}, nil } func (r *fakeInviteActivityRepo) GetInviteActivityRewardProgress(context.Context, string, int64) (domain.Progress, bool, error) { return r.progress, true, nil } func (r *fakeInviteActivityRepo) ListInviteActivityRewardClaimsByUserCycle(context.Context, string, int64) ([]domain.Claim, error) { return append([]domain.Claim(nil), r.claims...), nil } func (r *fakeInviteActivityRepo) ConsumeInviteActivityRechargeEvent(context.Context, domain.RechargeEvent, domain.Cycle, int64) (domain.ConsumeResult, error) { return domain.ConsumeResult{}, nil } func (r *fakeInviteActivityRepo) RecordInviteActivityInviteEvent(context.Context, domain.InviteEvent, domain.Cycle, string, string, int64) (domain.ConsumeResult, error) { return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, nil } func (r *fakeInviteActivityRepo) ConsumeInviteActivityValidInviteEvent(context.Context, domain.ValidInviteEvent, domain.Cycle, int64) (domain.ConsumeResult, error) { return domain.ConsumeResult{}, nil } func (r *fakeInviteActivityRepo) CreateInviteActivityRewardClaim(_ context.Context, claim domain.Claim, nowMS int64) (domain.Claim, error) { for _, existing := range r.claims { if existing.ClaimID == claim.ClaimID { return existing, nil } } claim.Status = domain.ClaimStatusPending claim.CreatedAtMS = nowMS claim.UpdatedAtMS = nowMS r.claims = append(r.claims, claim) return claim, nil } func (r *fakeInviteActivityRepo) MarkInviteActivityRewardClaimGranted(_ context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (domain.Claim, error) { for index := range r.claims { if r.claims[index].ClaimID != claimID { continue } r.claims[index].Status = domain.ClaimStatusGranted r.claims[index].WalletTransactionID = walletTransactionID r.claims[index].GrantedAtMS = grantedAtMS r.claims[index].UpdatedAtMS = grantedAtMS r.progress.ClaimedRewardCoinAmount += r.claims[index].RewardCoinAmount return r.claims[index], nil } return domain.Claim{}, nil } func (r *fakeInviteActivityRepo) MarkInviteActivityRewardClaimFailed(context.Context, string, string, int64) error { return nil } func (r *fakeInviteActivityRepo) ListInviteActivityRewardClaims(context.Context, domain.ClaimQuery) ([]domain.Claim, int64, error) { return append([]domain.Claim(nil), r.claims...), int64(len(r.claims)), nil } func (r *fakeInviteActivityRepo) ListInviteActivityRewardSummaries(_ context.Context, query domain.SummaryQuery) ([]domain.Summary, int64, error) { r.lastSummaryQuery = query return append([]domain.Summary(nil), r.summaries...), int64(len(r.summaries)), nil } func (r *fakeInviteActivityRepo) ListInviteActivityLeaderboard(_ context.Context, query domain.LeaderboardQuery) ([]domain.LeaderboardEntry, int64, error) { r.lastLeaderboardQuery = query return append([]domain.LeaderboardEntry(nil), r.leaderboardEntries...), int64(len(r.leaderboardEntries)), nil } type fakeInviteActivityWallet struct { requests []*walletv1.CreditInviteActivityRewardRequest } func (w *fakeInviteActivityWallet) CreditInviteActivityReward(_ context.Context, req *walletv1.CreditInviteActivityRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditInviteActivityRewardResponse, error) { w.requests = append(w.requests, req) return &walletv1.CreditInviteActivityRewardResponse{ TransactionId: "wallet-" + req.GetCommandId(), GrantedAtMs: 1781179200000, }, nil }