427 lines
16 KiB
Go
427 lines
16 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"
|
|
"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 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{}
|
|
}
|
|
|
|
type fakeGrowthWallet struct {
|
|
grants []*walletv1.GrantResourceGroupRequest
|
|
resourceGrants []*walletv1.GrantResourceRequest
|
|
}
|
|
|
|
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
|
|
}
|