277 lines
12 KiB
Go
277 lines
12 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|