1056 lines
49 KiB
Go
1056 lines
49 KiB
Go
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
|
||
}
|