2026-06-11 18:14:04 +08:00

250 lines
10 KiB
Go

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)
}
}
type fakeInviteActivityRepo struct {
config domain.Config
progress domain.Progress
claims []domain.Claim
leaderboardEntries []domain.LeaderboardEntry
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) 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
}