1056 lines
49 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package growth_test
import (
"context"
"fmt"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
activityevents "hyapp.local/api/proto/events/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/growth"
growthservice "hyapp/services/activity-service/internal/service/growth"
messageservice "hyapp/services/activity-service/internal/service/message"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
func TestGrowthLevelEventRewardFlow(t *testing.T) {
svc, wallet := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
result, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "level-event-1",
SourceEventID: "room-event-1",
SourceService: "room-service",
SourceEventType: "RoomGiftSent",
UserID: 10001,
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: 250,
OccurredAtMS: fixedGrowthNow().UnixMilli(),
DimensionsJSON: `{"room_id":"room-1"}`,
})
if err != nil {
t.Fatalf("ConsumeLevelEvent failed: %v", err)
}
if !result.LevelChanged || result.PreviousLevel != 0 || result.NewLevel != 2 || result.RewardJobCount != 2 {
t.Fatalf("level result mismatch: %+v", result)
}
duplicate, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "level-event-1",
SourceEventID: "room-event-1",
SourceService: "room-service",
SourceEventType: "RoomGiftSent",
UserID: 10001,
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: 250,
OccurredAtMS: fixedGrowthNow().UnixMilli(),
DimensionsJSON: `{"room_id":"room-1"}`,
})
if err != nil || duplicate.Status != domain.EventStatusDuplicate {
t.Fatalf("duplicate event mismatch: result=%+v err=%v", duplicate, err)
}
overview, err := svc.GetMyLevelOverview(ctx, 10001)
if err != nil {
t.Fatalf("GetMyLevelOverview failed: %v", err)
}
wealth := findTrackOverview(overview.Tracks, domain.TrackWealth)
if wealth.Level != 2 || wealth.TierID == 0 || wealth.TotalValue != 250 || wealth.DisplayAvatarFrameResourceID != 502 || wealth.DisplayBadgeResourceID != 702 || wealth.DisplayBadgeSourceLevel != 2 || wealth.RewardPendingCount != 2 {
t.Fatalf("wealth overview mismatch: %+v", wealth)
}
profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10001})
if err != nil {
t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err)
}
if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.BadgeResourceID != 702 || profiles.Profiles[0].Wealth.BadgeSourceLevel != 2 {
t.Fatalf("display profile should use highest configured rule badge: %+v", profiles.Profiles)
}
claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-1", "worker-1", 10, time.Second)
if err != nil {
t.Fatalf("ProcessLevelRewardBatch failed: %v", err)
}
if claimed != 2 || processed != 2 || success != 2 || failure != 0 || hasMore || len(wallet.grants) != 2 {
t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v grants=%d", claimed, processed, success, failure, hasMore, len(wallet.grants))
}
rewards, total, err := svc.ListLevelRewards(ctx, domain.RewardQuery{UserID: 10001, Status: domain.RewardStatusGranted})
if err != nil {
t.Fatalf("ListLevelRewards failed: %v", err)
}
if total != 2 || len(rewards) != 2 {
t.Fatalf("granted rewards mismatch: total=%d rewards=%+v", total, rewards)
}
}
func TestTemporaryGrowthLevelOverlayExpiresAndRevokesAllWalletGrants(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
notices := &fakeGrowthNoticeService{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(notices))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive,
RewardResourceGroupID: 9002, SortOrder: 2,
DisplayConfigJSON: `{"long_badge_resource_id":702,"avatar_frame_resource_id":802,"short_badge_resource_id":902}`,
OperatorAdminID: 90001,
}); err != nil || created {
t.Fatalf("update temporary reward resources failed: created=%v err=%v", created, err)
}
profile, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-1", UserID: 51001, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 7}},
})
if err != nil {
t.Fatalf("AdjustTemporaryUserLevels failed: %v", err)
}
wealth := findAdminTrack(profile.Tracks, domain.TrackWealth)
if wealth.RealLevel != 0 || wealth.DisplayLevel != 2 || wealth.DisplayValue != 200 || wealth.TemporaryLevelID == "" {
t.Fatalf("temporary profile mismatch: %+v", wealth)
}
// Same command and payload is a read-only idempotent replay; it must not create a second generation.
replayed, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-1", UserID: 51001, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 7}},
})
if err != nil || findAdminTrack(replayed.Tracks, domain.TrackWealth).TemporaryLevelID != wealth.TemporaryLevelID {
t.Fatalf("temporary command replay mismatch: profile=%+v err=%v", replayed, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-1", UserID: 51001, OperatorAdminID: 90001, Reason: "changed-payload",
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 7}},
}); err == nil {
t.Fatal("same command id with a changed reason must be rejected as an idempotency conflict")
}
if _, _, success, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "worker-notice", 10, time.Second); err != nil || success != 1 || failure != 0 || len(notices.commands) != 1 {
t.Fatalf("activation notice batch mismatch: success=%d failure=%d notices=%d err=%v", success, failure, len(notices.commands), err)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "run-temp-reward", "worker-reward", 10, time.Second); err != nil || claimed != 3 || success != 3 || failure != 0 {
t.Fatalf("temporary reward batch mismatch: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
for _, grant := range wallet.resourceGrants {
if grant.GetDurationMs() != 9999*24*time.Hour.Milliseconds() {
t.Fatalf("temporary direct resource must rely on explicit revoke instead of wallet expiry: %+v", grant)
}
}
// The end instant is exclusive: before cron runs, every read must already ignore the overlay.
now = now.Add(7 * 24 * time.Hour)
profiles, err := svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51001})
if err != nil {
t.Fatalf("BatchGetUserLevelAdminProfiles at expiry failed: %v", err)
}
atExpiry := findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth)
if atExpiry.DisplayLevel != 0 || atExpiry.TemporaryLevelID != "" {
t.Fatalf("expired overlay must be ignored immediately: %+v", atExpiry)
}
claimed, processed, success, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "worker-expiry", 20, time.Second)
if err != nil || claimed != 6 || processed != 6 || success != 6 || failure != 0 {
t.Fatalf("expiry batch mismatch: claimed=%d processed=%d success=%d failure=%d err=%v", claimed, processed, success, failure, err)
}
if len(wallet.revokes) != 5 || len(notices.commands) != 2 {
t.Fatalf("expiry side effects mismatch: revokes=%d notices=%d", len(wallet.revokes), len(notices.commands))
}
}
func TestTemporaryGrowthLevelExpiredGenerationIsMaterializedBeforeNewAdjustment(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
notices := &fakeGrowthNoticeService{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(notices))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 3, RequiredValue: 300, Name: "wealth 3", Status: domain.StatusActive,
SortOrder: 3, DisplayConfigJSON: `{}`, OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level 3 failed: created=%v err=%v", created, err)
}
first, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-expired-before-replace-1", UserID: 51007, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create first temporary generation failed: %v", err)
}
firstID := findAdminTrack(first.Tracks, domain.TrackWealth).TemporaryLevelID
now = now.Add(24 * time.Hour)
second, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-expired-before-replace-2", UserID: 51007, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 3, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create second temporary generation failed: %v", err)
}
current := findAdminTrack(second.Tracks, domain.TrackWealth)
if current.TemporaryLevelID == "" || current.TemporaryLevelID == firstID || current.DisplayLevel != 3 {
t.Fatalf("new generation must replace only after old expiry is materialized: first=%s current=%+v", firstID, current)
}
claimed, processed, success, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "worker-expired-replace", 10, time.Second)
if err != nil || claimed != 2 || processed != 2 || success != 2 || failure != 0 || len(notices.commands) != 2 {
t.Fatalf("old expiry and new activation must both remain retryable: claimed=%d processed=%d success=%d failure=%d notices=%d err=%v", claimed, processed, success, failure, len(notices.commands), err)
}
}
func TestTemporaryGrowthWorkersReclaimExpiredRunningLocks(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-reward-reclaim", UserID: 51008, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("seed reward reclaim generation failed: %v", err)
}
firstJobs, err := repository.ClaimPendingLevelRewardJobs(ctx, "crashed-reward-worker", now.UnixMilli(), time.Second, 20)
if err != nil || len(firstJobs) == 0 {
t.Fatalf("first reward claim failed: jobs=%d err=%v", len(firstJobs), err)
}
reclaimedJobs, err := repository.ClaimPendingLevelRewardJobs(ctx, "replacement-reward-worker", now.Add(time.Second).UnixMilli(), time.Second, 20)
if err != nil || len(reclaimedJobs) != len(firstJobs) {
t.Fatalf("expired running reward locks must be reclaimed: first=%d reclaimed=%d err=%v", len(firstJobs), len(reclaimedJobs), err)
}
// A separate generation is fully granted so revocation child rows can exercise the same crash-recovery boundary.
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-revoke-reclaim", UserID: 51009, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("seed revoke reclaim generation failed: %v", err)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "revoke-reclaim-grant-run", "reward-worker", 20, time.Second); err != nil || claimed == 0 || success != claimed || failure != 0 {
t.Fatalf("grant revocation seed jobs failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
expiresAt := now.Add(24 * time.Hour)
if _, err := repository.ClaimTemporaryLevelWork(ctx, "expiry-materializer", expiresAt.UnixMilli(), time.Second, 20); err != nil {
t.Fatalf("materialize temporary expiry failed: %v", err)
}
firstRevokes, err := repository.ClaimLevelRewardRevocations(ctx, "crashed-revoke-worker", expiresAt.UnixMilli(), time.Second, 100)
if err != nil || len(firstRevokes) == 0 {
t.Fatalf("first revoke claim failed: revokes=%d err=%v", len(firstRevokes), err)
}
reclaimedRevokes, err := repository.ClaimLevelRewardRevocations(ctx, "replacement-revoke-worker", expiresAt.Add(time.Second).UnixMilli(), time.Second, 100)
if err != nil || len(reclaimedRevokes) != len(firstRevokes) {
t.Fatalf("expired running revoke locks must be reclaimed: first=%d reclaimed=%d err=%v", len(firstRevokes), len(reclaimedRevokes), err)
}
}
func TestTemporaryRewardsBecomePermanentWhenRealLevelCatchesUp(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive,
RewardResourceGroupID: 9002, SortOrder: 2,
DisplayConfigJSON: `{"avatar_frame_resource_id":802,"short_badge_resource_id":902}`,
OperatorAdminID: 90001,
}); err != nil || created {
t.Fatalf("update level 2 direct rewards failed: created=%v err=%v", created, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-promote-permanent", UserID: 51010, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("create temporary promotion generation failed: %v", err)
}
if claimed, _, success, _, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-promotion-grants", "reward-worker", 20, time.Second); err != nil || claimed == 0 || success != claimed {
t.Fatalf("grant temporary promotion rewards failed: claimed=%d success=%d err=%v", claimed, success, err)
}
now = now.Add(time.Millisecond)
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-promotion-organic", SourceEventID: "temporary-promotion-organic", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51010, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 200, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("organic catch-up event failed: %v", err)
}
now = fixedGrowthNow().Add(24 * time.Hour)
if _, _, _, _, _, err := svc.ProcessTemporaryLevelBatch(ctx, "promotion-expiry-worker", 20, time.Second); err != nil {
t.Fatalf("process promoted generation expiry failed: %v", err)
}
if len(wallet.revokes) != 0 {
t.Fatalf("rewards earned by real level must remain permanent, revokes=%d", len(wallet.revokes))
}
for _, grant := range wallet.resourceGrants {
if grant.GetDurationMs() != 9999*24*time.Hour.Milliseconds() {
t.Fatalf("promoted direct resource would still expire in wallet: %+v", grant)
}
}
}
func TestTemporaryRewardGenerationReplacementCarriesLiveGrantsWithoutDuplicateIssue(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive,
RewardResourceGroupID: 9002, SortOrder: 2,
DisplayConfigJSON: `{"avatar_frame_resource_id":802,"short_badge_resource_id":902}`,
OperatorAdminID: 90001,
}); err != nil || created {
t.Fatalf("update level 2 resources failed: created=%v err=%v", created, err)
}
first, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-generation-carry-1", UserID: 51011, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create first temporary generation failed: %v", err)
}
firstID := findAdminTrack(first.Tracks, domain.TrackWealth).TemporaryLevelID
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-generation-carry-grant", "reward-worker", 20, time.Second); err != nil || claimed == 0 || success != claimed || failure != 0 {
t.Fatalf("grant first generation failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
groupGrants, directGrants := len(wallet.grants), len(wallet.resourceGrants)
// 覆盖调整建立新计时基线,但目标范围内已发放的 grant 必须迁移到新代,不能撤回再发造成重复权益。
now = now.Add(time.Hour)
second, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-generation-carry-2", UserID: 51011, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("replace temporary generation failed: %v", err)
}
secondTrack := findAdminTrack(second.Tracks, domain.TrackWealth)
if secondTrack.TemporaryLevelID == "" || secondTrack.TemporaryLevelID == firstID {
t.Fatalf("replacement must create a distinct generation: first=%s second=%+v", firstID, secondTrack)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-generation-carry-no-duplicate", "reward-worker", 20, time.Second); err != nil || claimed != 0 || success != 0 || failure != 0 {
t.Fatalf("replacement must not enqueue duplicate grants: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
if len(wallet.grants) != groupGrants || len(wallet.resourceGrants) != directGrants {
t.Fatalf("replacement duplicated wallet grants: groups=%d/%d direct=%d/%d", len(wallet.grants), groupGrants, len(wallet.resourceGrants), directGrants)
}
// 自然成长只提升当前代中迁移过来的奖励;到期后这些 grant 不得再进入撤回队列。
now = now.Add(time.Millisecond)
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-generation-carry-organic", SourceEventID: "temporary-generation-carry-organic", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51011, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 200, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("organic catch-up after replacement failed: %v", err)
}
now = time.UnixMilli(secondTrack.ExpiresAtMS).UTC()
if _, _, _, _, _, err := svc.ProcessTemporaryLevelBatch(ctx, "temporary-generation-carry-expiry", 100, time.Second); err != nil {
t.Fatalf("process replacement expiry failed: %v", err)
}
if len(wallet.revokes) != 0 {
t.Fatalf("current-generation grants promoted by real growth must not be revoked: revokes=%d", len(wallet.revokes))
}
}
func TestSupersededRewardWithFailedRevokeIsNotPromotedByNaturalGrowth(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 3, RequiredValue: 300, Name: "wealth 3", Status: domain.StatusActive,
RewardResourceGroupID: 9004, SortOrder: 3,
DisplayConfigJSON: `{"avatar_frame_resource_id":803,"short_badge_resource_id":903}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level 3 resources failed: created=%v err=%v", created, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-failed-revoke-1", UserID: 51012, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 3, DurationDays: 1}},
}); err != nil {
t.Fatalf("create high temporary generation failed: %v", err)
}
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-failed-revoke-grant", "reward-worker", 50, time.Second); err != nil || claimed == 0 || success != claimed || failure != 0 {
t.Fatalf("grant high temporary generation failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
// 降低覆盖目标时仅 Lv3 留在旧代撤回;模拟钱包撤回失败,验证后续自然 Lv3 不会把旧代事实误标永久。
now = now.Add(time.Hour)
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-failed-revoke-2", UserID: 51012, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("replace with lower temporary target failed: %v", err)
}
revocations, err := repository.ClaimLevelRewardRevocations(ctx, "failed-revoke-worker", now.UnixMilli(), time.Second, 100)
if err != nil || len(revocations) == 0 {
t.Fatalf("claim superseded level 3 revocations failed: revocations=%d err=%v", len(revocations), err)
}
for _, revoke := range revocations {
if err := repository.MarkLevelRewardRevokeFailed(ctx, revoke.RewardJobID, revoke.WalletGrantID, "wallet timeout", now.Add(time.Minute).UnixMilli(), now.UnixMilli()); err != nil {
t.Fatalf("mark superseded revoke failed: %v", err)
}
}
now = now.Add(time.Millisecond)
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-failed-revoke-organic", SourceEventID: "temporary-failed-revoke-organic", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51012, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 300, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("natural level 3 event failed: %v", err)
}
rewards, _, err := svc.ListLevelRewards(ctx, domain.RewardQuery{UserID: 51012, Track: domain.TrackWealth, PageSize: 100})
if err != nil {
t.Fatalf("list rewards after natural level 3 failed: %v", err)
}
var oldTemporary, freshOrganic int
for _, reward := range rewards {
if reward.RewardSourceType != domain.RewardSourceLevel || reward.RewardSourceID != "level:3" {
continue
}
if reward.RewardOrigin == domain.RewardOriginTemporary && !reward.Permanent && reward.RevokeStatus == domain.RevokeStatusFailed {
oldTemporary++
}
if reward.RewardOrigin == domain.RewardOriginOrganic && reward.Permanent {
freshOrganic++
}
}
if oldTemporary != 1 || freshOrganic != 1 {
t.Fatalf("natural growth must preserve failed old revoke and create one new permanent job: old=%d organic=%d rewards=%+v", oldTemporary, freshOrganic, rewards)
}
groupGrants, directGrants := len(wallet.grants), len(wallet.resourceGrants)
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-failed-revoke-organic-grant", "reward-worker", 20, time.Second); err != nil || claimed != 1 || success != 1 || failure != 0 {
t.Fatalf("fresh organic level 3 grant failed: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
if len(wallet.grants) != groupGrants+1 || len(wallet.resourceGrants) != directGrants+2 {
t.Fatalf("fresh organic job must issue independent replacement grants: groups=%d/%d direct=%d/%d", len(wallet.grants), groupGrants, len(wallet.resourceGrants), directGrants)
}
}
func TestTemporaryRewardPartialWalletSuccessIsDurableAndRevokedAtExpiry(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakePartialGrowthWallet{failNextResource: true}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet, growthservice.WithNoticeService(&fakeGrowthNoticeService{}))
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
for _, rule := range []domain.RuleCommand{
{Track: domain.TrackWealth, Level: 1, RequiredValue: 100, Name: "wealth 1", Status: domain.StatusActive, SortOrder: 1, DisplayConfigJSON: `{}`, OperatorAdminID: 90001},
{Track: domain.TrackWealth, Level: 2, RequiredValue: 200, Name: "wealth 2", Status: domain.StatusActive, RewardResourceGroupID: 9002, SortOrder: 2, DisplayConfigJSON: `{"avatar_frame_resource_id":802}`, OperatorAdminID: 90001},
} {
if _, created, err := svc.UpsertLevelRule(ctx, rule); err != nil || !created {
t.Fatalf("seed partial-grant rule level %d failed: created=%v err=%v", rule.Level, created, err)
}
}
profile, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-partial-wallet-grant", UserID: 51013, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
})
if err != nil {
t.Fatalf("create partial-grant temporary level failed: %v", err)
}
track := findAdminTrack(profile.Tracks, domain.TrackWealth)
if claimed, _, success, failure, _, err := svc.ProcessLevelRewardBatch(ctx, "temporary-partial-wallet-grant-run", "reward-worker", 20, time.Second); err != nil || claimed != 1 || success != 0 || failure != 1 {
t.Fatalf("partial wallet grant batch mismatch: claimed=%d success=%d failure=%d err=%v", claimed, success, failure, err)
}
if len(wallet.grants) != 1 || len(wallet.resourceGrants) != 0 {
t.Fatalf("test precondition requires group success followed by direct-resource failure: groups=%d resources=%d", len(wallet.grants), len(wallet.resourceGrants))
}
// 到期事务必须能看见失败任务中已成功的资源组 grant并把它交给撤回 worker。
now = time.UnixMilli(track.ExpiresAtMS).UTC()
if _, _, _, failure, _, err := svc.ProcessTemporaryLevelBatch(ctx, "temporary-partial-wallet-expiry", 20, time.Second); err != nil || failure != 0 {
t.Fatalf("process partial-grant expiry failed: failure=%d err=%v", failure, err)
}
if len(wallet.revokes) != 1 || wallet.revokes[0].GetGrantId() != "grant-1" {
t.Fatalf("successful wallet prefix must be explicitly revoked: revokes=%+v", wallet.revokes)
}
rewards, _, err := svc.ListLevelRewards(ctx, domain.RewardQuery{UserID: 51013, Track: domain.TrackWealth, PageSize: 20})
if err != nil || len(rewards) != 1 || rewards[0].Status != domain.RewardStatusCanceled || rewards[0].RevokeStatus != domain.RevokeStatusRevoked {
t.Fatalf("partial reward terminal state mismatch: rewards=%+v err=%v", rewards, err)
}
}
func TestTemporaryGrowthLevelDualTrackValidationRollsBackWholeCommand(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
_, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-command-atomic", UserID: 51002, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{
{Track: domain.TrackWealth, Level: 2, DurationDays: 7},
// charm has no active level-2 rule in this isolated schema, so the whole transaction must fail.
{Track: domain.TrackCharm, Level: 2, DurationDays: 7},
},
})
if err == nil {
t.Fatal("dual-track command should fail when one active rule is missing")
}
profiles, queryErr := svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51002})
if queryErr != nil {
t.Fatalf("BatchGetUserLevelAdminProfiles failed: %v", queryErr)
}
if wealth := findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth); wealth.TemporaryLevelID != "" || wealth.DisplayLevel != wealth.RealLevel {
t.Fatalf("wealth adjustment leaked from rolled-back command: %+v", wealth)
}
}
func TestTemporaryGrowthLevelAddsWindowExperienceWithoutMutatingRealTotal(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
now := fixedGrowthNow()
svc := growthservice.New(repository, wallet)
svc.SetClock(func() time.Time { return now })
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth, Level: 3, RequiredValue: 300, Name: "wealth 3", Status: domain.StatusActive,
SortOrder: 3, DisplayConfigJSON: `{}`, OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level 3 failed: created=%v err=%v", created, err)
}
if _, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: "temporary-level-window", UserID: 51003, OperatorAdminID: 90001,
Adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 1}},
}); err != nil {
t.Fatalf("AdjustTemporaryUserLevels failed: %v", err)
}
if _, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "temporary-window-event", SourceEventID: "temporary-window-event", SourceService: "room-service",
SourceEventType: "RoomGiftSent", UserID: 51003, Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin, ValueDelta: 100, OccurredAtMS: now.UnixMilli(), DimensionsJSON: `{}`,
}); err != nil {
t.Fatalf("ConsumeLevelEvent failed: %v", err)
}
profiles, err := svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51003})
if err != nil {
t.Fatalf("BatchGetUserLevelAdminProfiles failed: %v", err)
}
wealth := findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth)
if wealth.RealTotalValue != 100 || wealth.RealLevel != 1 || wealth.DisplayValue != 300 || wealth.DisplayLevel != 3 {
t.Fatalf("real and overlay progress must remain separate: %+v", wealth)
}
now = now.Add(24 * time.Hour)
profiles, err = svc.BatchGetUserLevelAdminProfiles(ctx, []int64{51003})
if err != nil {
t.Fatalf("expiry profile failed: %v", err)
}
wealth = findAdminTrack(profiles.Profiles[0].Tracks, domain.TrackWealth)
if wealth.RealTotalValue != 100 || wealth.DisplayValue != 100 || wealth.DisplayLevel != 1 || wealth.TemporaryLevelID != "" {
t.Fatalf("expiry must reveal untouched real account: %+v", wealth)
}
}
func TestTemporaryGrowthLevelRejectsInvalidTracksBoundsAndDuration(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
tests := []struct {
name string
adjustments []domain.TemporaryLevelAdjustment
}{
{name: "game is read only", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackGame, Level: 2, DurationDays: 7}}},
{name: "level zero", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 0, DurationDays: 7}}},
{name: "above level fifty", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackCharm, Level: 51, DurationDays: 7}}},
{name: "zero duration", adjustments: []domain.TemporaryLevelAdjustment{{Track: domain.TrackWealth, Level: 2, DurationDays: 0}}},
{name: "duplicate track", adjustments: []domain.TemporaryLevelAdjustment{
{Track: domain.TrackWealth, Level: 2, DurationDays: 7},
{Track: domain.TrackWealth, Level: 3, DurationDays: 7},
}},
}
for index, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := svc.AdjustTemporaryUserLevels(ctx, domain.AdjustTemporaryLevelsCommand{
CommandID: fmt.Sprintf("temporary-invalid-%d", index), UserID: 51004, OperatorAdminID: 90001,
Adjustments: test.adjustments,
})
if err == nil {
t.Fatalf("invalid adjustment was accepted: %+v", test.adjustments)
}
})
}
}
func TestGrowthLevelEventConcurrentSameUserTrack(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
// 先创建账户,确保并发波次命中生产故障里的“已有热点账户”路径,而不是只覆盖首次插入。
if _, err := svc.ConsumeLevelEvent(ctx, growthValueEvent("growth-hot-account-seed", 41001, 1)); err != nil {
t.Fatalf("seed hot growth account: %v", err)
}
const writers = 40
start := make(chan struct{})
errs := make(chan error, writers)
for index := 0; index < writers; index++ {
go func(index int) {
<-start
_, err := svc.ConsumeLevelEvent(ctx, growthValueEvent(fmt.Sprintf("growth-hot-account-%d", index), 41001, 1))
errs <- err
}(index)
}
close(start)
for index := 0; index < writers; index++ {
if err := <-errs; err != nil {
t.Fatalf("concurrent growth event %d failed: %v", index, err)
}
}
// 同一幂等键并发只能有一个事务增加累计值,其余事务必须在首个提交后稳定返回 duplicate。
const duplicateWriters = 10
duplicateStart := make(chan struct{})
type consumeResult struct {
status string
err error
}
duplicateResults := make(chan consumeResult, duplicateWriters)
for index := 0; index < duplicateWriters; index++ {
go func() {
<-duplicateStart
result, err := svc.ConsumeLevelEvent(ctx, growthValueEvent("growth-hot-account-duplicate", 41001, 1))
duplicateResults <- consumeResult{status: result.Status, err: err}
}()
}
close(duplicateStart)
consumed := 0
duplicates := 0
for index := 0; index < duplicateWriters; index++ {
result := <-duplicateResults
if result.err != nil {
t.Fatalf("duplicate concurrent growth event %d failed: %v", index, result.err)
}
switch result.status {
case domain.EventStatusConsumed:
consumed++
case domain.EventStatusDuplicate:
duplicates++
default:
t.Fatalf("unexpected duplicate concurrent status %q", result.status)
}
}
if consumed != 1 || duplicates != duplicateWriters-1 {
t.Fatalf("duplicate concurrency mismatch: consumed=%d duplicates=%d", consumed, duplicates)
}
overview, err := svc.GetMyLevelOverview(ctx, 41001)
if err != nil {
t.Fatalf("get concurrent growth overview: %v", err)
}
wealth := findTrackOverview(overview.Tracks, domain.TrackWealth)
if want := int64(1 + writers + 1); wealth.TotalValue != want {
t.Fatalf("concurrent total value=%d want=%d", wealth.TotalValue, want)
}
}
func growthValueEvent(eventID string, userID int64, delta int64) domain.ValueEvent {
return domain.ValueEvent{
EventID: eventID,
SourceEventID: eventID,
SourceService: "room-service",
SourceEventType: "RoomGiftSent",
UserID: userID,
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: delta,
OccurredAtMS: fixedGrowthNow().UnixMilli(),
DimensionsJSON: `{"room_id":"room-concurrency"}`,
}
}
func TestGrowthLevelRewardGrantsLevelResources(t *testing.T) {
svc, wallet := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth,
Level: 1,
RequiredValue: 100,
Name: "wealth 1",
Status: domain.StatusActive,
SortOrder: 1,
DisplayConfigJSON: `{"avatar_frame_resource_id":801,"short_badge_resource_id":901}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level resource rule failed: created=%v err=%v", created, err)
}
result, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{
EventID: "level-avatar-frame-event-1",
SourceEventID: "room-event-avatar-frame-1",
SourceService: "room-service",
SourceEventType: "RoomGiftSent",
UserID: 10002,
Track: domain.TrackWealth,
MetricType: domain.MetricGiftSpendCoin,
ValueDelta: 100,
OccurredAtMS: fixedGrowthNow().UnixMilli(),
DimensionsJSON: `{"room_id":"room-1"}`,
})
if err != nil {
t.Fatalf("ConsumeLevelEvent failed: %v", err)
}
if result.RewardJobCount != 1 {
t.Fatalf("level resources should create one reward job: %+v", result)
}
overview, err := svc.GetMyLevelOverview(ctx, 10002)
if err != nil {
t.Fatalf("GetMyLevelOverview failed: %v", err)
}
wealth := findTrackOverview(overview.Tracks, domain.TrackWealth)
if wealth.DisplayAvatarFrameResourceID != 801 {
t.Fatalf("level avatar frame should override display profile: %+v", wealth)
}
profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10002})
if err != nil {
t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err)
}
if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.AvatarFrameResourceID != 801 {
t.Fatalf("room display profile should use level avatar frame: %+v", profiles.Profiles)
}
claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-avatar-frame", "worker-1", 10, time.Second)
if err != nil {
t.Fatalf("ProcessLevelRewardBatch failed: %v", err)
}
if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore {
t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v", claimed, processed, success, failure, hasMore)
}
if len(wallet.grants) != 0 || len(wallet.resourceGrants) != 2 {
t.Fatalf("level resource reward should grant two resources only: groups=%d resources=%d", len(wallet.grants), len(wallet.resourceGrants))
}
grantedResources := make(map[int64]*walletv1.GrantResourceRequest, len(wallet.resourceGrants))
for _, req := range wallet.resourceGrants {
grantedResources[req.GetResourceId()] = req
}
for _, resourceID := range []int64{801, 901} {
req := grantedResources[resourceID]
if req == nil || req.GetDurationMs() != 9999*24*60*60*1000 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel {
t.Fatalf("level resource grant mismatch: id=%d grants=%+v", resourceID, wallet.resourceGrants)
}
}
}
func TestGrowthRoomGiftEventFeedsWealthAndCharm(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
seedGrowthRules(t, ctx, svc, domain.TrackCharm)
body, err := proto.Marshal(&activityevents.RoomGiftSent{
SenderUserId: 20001,
TargetUserId: 20002,
GiftId: "gift-1",
GiftCount: 1,
GiftValue: 150,
BillingReceiptId: "bill-1",
VisibleRegionId: 86,
CommandId: "cmd-gift-1",
})
if err != nil {
t.Fatalf("marshal room gift failed: %v", err)
}
consumed, err := svc.HandleRoomEvent(ctx, &activityevents.EventEnvelope{
EventId: "room-gift-event-1",
RoomId: "room-1",
EventType: "RoomGiftSent",
RoomVersion: 7,
OccurredAtMs: fixedGrowthNow().UnixMilli(),
Body: body,
AppCode: "lalu",
})
if err != nil {
t.Fatalf("HandleRoomEvent failed: %v", err)
}
if consumed != 2 {
t.Fatalf("room gift should create wealth and charm events, consumed=%d", consumed)
}
sender, err := svc.GetLevelTrack(ctx, 20001, domain.TrackWealth)
if err != nil {
t.Fatalf("GetLevelTrack sender failed: %v", err)
}
target, err := svc.GetLevelTrack(ctx, 20002, domain.TrackCharm)
if err != nil {
t.Fatalf("GetLevelTrack target failed: %v", err)
}
if sender.Overview.Level != 1 || sender.Overview.TotalValue != 150 {
t.Fatalf("sender wealth mismatch: %+v", sender.Overview)
}
if target.Overview.Level != 1 || target.Overview.TotalValue != 150 {
t.Fatalf("target charm mismatch: %+v", target.Overview)
}
}
func TestGrowthRoomGiftEventSkipsZeroGiftValue(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
body, err := proto.Marshal(&activityevents.RoomGiftSent{
SenderUserId: 20001,
TargetUserId: 20002,
GiftId: "gift-zero-heat",
GiftCount: 1,
GiftValue: 0,
BillingReceiptId: "bill-zero-heat",
VisibleRegionId: 86,
CommandId: "cmd-gift-zero-heat",
})
if err != nil {
t.Fatalf("marshal zero heat room gift failed: %v", err)
}
consumed, err := svc.HandleRoomEvent(ctx, &activityevents.EventEnvelope{
EventId: "room-gift-event-zero-heat",
RoomId: "room-1",
EventType: "RoomGiftSent",
RoomVersion: 8,
OccurredAtMs: fixedGrowthNow().UnixMilli(),
Body: body,
AppCode: "lalu",
})
if err != nil {
t.Fatalf("zero heat RoomGiftSent should be accepted and skipped: %v", err)
}
if consumed != 0 {
t.Fatalf("zero heat RoomGiftSent should not create growth events, consumed=%d", consumed)
}
}
func TestListLevelConfigReturnsAdminRulesAndTiers(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackGame)
config, err := svc.ListLevelConfig(ctx, domain.ConfigQuery{Track: domain.TrackGame})
if err != nil {
t.Fatalf("ListLevelConfig failed: %v", err)
}
if len(config.Tracks) != 1 || config.Tracks[0].Track != domain.TrackGame {
t.Fatalf("track config mismatch: %+v", config.Tracks)
}
if len(config.Rules) != 2 || config.Rules[0].Level != 1 || config.Rules[1].RequiredValue != 200 {
t.Fatalf("rule config mismatch: %+v", config.Rules)
}
if len(config.Tiers) != 2 || config.Tiers[1].DisplayBadgeResourceID != 602 {
t.Fatalf("tier config mismatch: %+v", config.Tiers)
}
if config.ServerTimeMS != fixedGrowthNow().UnixMilli() {
t.Fatalf("server time mismatch: %d", config.ServerTimeMS)
}
}
func TestZeroLevelRuleAndTierAreValid(t *testing.T) {
svc, _ := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: domain.TrackWealth,
Level: 0,
RequiredValue: 0,
Name: "wealth 0",
Status: domain.StatusActive,
SortOrder: 0,
DisplayConfigJSON: `{"long_badge_resource_id":700}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level rule 0 failed: created=%v err=%v", created, err)
}
if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{
Track: domain.TrackWealth,
MinLevel: 0,
MaxLevel: 9,
Name: "wealth 0-9",
Status: domain.StatusActive,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed tier 0 failed: created=%v err=%v", created, err)
}
detail, err := svc.GetLevelTrack(ctx, 30001, domain.TrackWealth)
if err != nil {
t.Fatalf("GetLevelTrack failed: %v", err)
}
if detail.Overview.Level != 0 || detail.Overview.CurrentLevelRequiredValue != 0 || detail.Overview.DisplayBadgeResourceID != 700 || detail.Overview.DisplayBadgeSourceLevel != 0 {
t.Fatalf("zero level overview mismatch: %+v", detail.Overview)
}
}
func TestIssueRegistrationLevelBadgesGrantsLevelOneBadges(t *testing.T) {
svc, wallet := newGrowthService(t)
ctx := appcode.WithContext(context.Background(), "lalu")
seedGrowthRules(t, ctx, svc, domain.TrackWealth)
seedGrowthRules(t, ctx, svc, domain.TrackGame)
seedGrowthRules(t, ctx, svc, domain.TrackCharm)
grants, err := svc.IssueRegistrationLevelBadges(ctx, 30001, "registration_level_badges:lalu:30001")
if err != nil {
t.Fatalf("IssueRegistrationLevelBadges failed: %v", err)
}
if len(grants.Grants) != 3 || len(wallet.resourceGrants) != 3 {
t.Fatalf("registration level badge grants mismatch: grants=%+v wallet=%d", grants.Grants, len(wallet.resourceGrants))
}
for _, req := range wallet.resourceGrants {
if req.GetTargetUserId() != 30001 || req.GetResourceId() != 701 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel {
t.Fatalf("wallet grant request mismatch: %+v", req)
}
}
}
func newGrowthService(t *testing.T) (*growthservice.Service, *fakeGrowthWallet) {
t.Helper()
repository := mysqltest.NewRepository(t)
wallet := &fakeGrowthWallet{}
svc := growthservice.New(repository, wallet)
svc.SetClock(fixedGrowthNow)
return svc, wallet
}
func seedGrowthRules(t *testing.T, ctx context.Context, svc *growthservice.Service, track string) {
t.Helper()
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: track,
Level: 1,
RequiredValue: 100,
Name: track + " 1",
Status: domain.StatusActive,
RewardResourceGroupID: 0,
SortOrder: 1,
DisplayConfigJSON: `{"long_badge_resource_id":701}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level rule 1 failed: created=%v err=%v", created, err)
}
if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{
Track: track,
Level: 2,
RequiredValue: 200,
Name: track + " 2",
Status: domain.StatusActive,
RewardResourceGroupID: 9002,
SortOrder: 2,
DisplayConfigJSON: `{"long_badge_resource_id":702}`,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed level rule 2 failed: created=%v err=%v", created, err)
}
if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{
Track: track,
MinLevel: 1,
MaxLevel: 1,
Name: track + " 1-1",
DisplayAvatarFrameResourceID: 501,
DisplayBadgeResourceID: 601,
RewardResourceGroupID: 9001,
Status: domain.StatusActive,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed tier 1 failed: created=%v err=%v", created, err)
}
if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{
Track: track,
MinLevel: 2,
MaxLevel: 10,
Name: track + " 2-10",
DisplayAvatarFrameResourceID: 502,
DisplayBadgeResourceID: 602,
RewardResourceGroupID: 9003,
Status: domain.StatusActive,
OperatorAdminID: 90001,
}); err != nil || !created {
t.Fatalf("seed tier 2 failed: created=%v err=%v", created, err)
}
}
func fixedGrowthNow() time.Time {
return time.Date(2026, 5, 14, 4, 0, 0, 0, time.UTC)
}
func findTrackOverview(items []domain.TrackOverview, track string) domain.TrackOverview {
for _, item := range items {
if item.Track == track {
return item
}
}
return domain.TrackOverview{}
}
func findAdminTrack(items []domain.AdminTrackProfile, track string) domain.AdminTrackProfile {
for _, item := range items {
if item.Track == track {
return item
}
}
return domain.AdminTrackProfile{}
}
type fakeGrowthWallet struct {
grants []*walletv1.GrantResourceGroupRequest
resourceGrants []*walletv1.GrantResourceRequest
revokes []*walletv1.RevokeResourceGrantRequest
}
// fakePartialGrowthWallet reproduces a multi-action reward where the resource group commits but a later direct
// resource RPC fails. Real wallet command IDs are idempotent; the test focuses on activity's durable grant ledger.
type fakePartialGrowthWallet struct {
fakeGrowthWallet
failNextResource bool
}
func (f *fakePartialGrowthWallet) GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
if f.failNextResource {
f.failNextResource = false
return nil, fmt.Errorf("injected direct resource failure")
}
return f.fakeGrowthWallet.GrantResource(ctx, req, opts...)
}
func (f *fakeGrowthWallet) RevokeResourceGrant(_ context.Context, req *walletv1.RevokeResourceGrantRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.revokes = append(f.revokes, req)
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: req.GetGrantId(), Status: "revoked"}}, nil
}
type fakeGrowthNoticeService struct {
commands []messageservice.NoticeCommand
}
func (f *fakeGrowthNoticeService) CreateSystemNotice(_ context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error) {
f.commands = append(f.commands, cmd)
return messageservice.NoticeResult{Created: true}, nil
}
func (f *fakeGrowthWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.resourceGrants = append(f.resourceGrants, req)
return &walletv1.ResourceGrantResponse{
Grant: &walletv1.ResourceGrant{
GrantId: fmt.Sprintf("resource-grant-%d", len(f.resourceGrants)),
Status: "succeeded",
},
}, nil
}
func (f *fakeGrowthWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.grants = append(f.grants, req)
return &walletv1.ResourceGrantResponse{
Grant: &walletv1.ResourceGrant{
GrantId: fmt.Sprintf("grant-%d", len(f.grants)),
Status: "succeeded",
},
}, nil
}