package luckygift import ( "context" "encoding/json" "errors" "fmt" "strings" "testing" "time" "google.golang.org/grpc" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" domain "hyapp/services/activity-service/internal/domain/luckygift" broadcastservice "hyapp/services/activity-service/internal/service/broadcast" ) func TestGetConfigReturnsPoolDefault(t *testing.T) { repository := &fakeLuckyGiftRepository{} service := New(repository) config, err := service.GetConfig(appcode.WithContext(context.Background(), "hyapp"), "pool_95") if err != nil { t.Fatalf("GetConfig returned error: %v", err) } if repository.getConfigCalls != 1 { t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls) } if config.PoolID != "pool_95" { t.Fatalf("expected pool config scope, got pool=%q", config.PoolID) } } func TestUpsertConfigPreservesPoolScope(t *testing.T) { repository := &fakeLuckyGiftRepository{} service := New(repository) config := DefaultRuleConfig("hyapp", "pool_98") config.Enabled = true if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil { t.Fatalf("UpsertConfig returned error: %v", err) } if repository.upserted.PoolID != "pool_98" { t.Fatalf("expected pool scope, got pool=%q", repository.upserted.PoolID) } } func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) { repository := &fakeLuckyGiftRepository{executeResult: domain.DrawResult{ DrawID: "lucky_draw_fast_1", DrawIDs: []string{"lucky_draw_fast_1", "lucky_draw_fast_2"}, CommandID: "cmd-gift-fast", PoolID: "super_lucky", GiftID: "rose", RuleVersion: 12, ExperiencePool: "novice", SelectedTierID: "batch", MultiplierPPM: 3_000_000, EffectiveRewardCoins: 300, RewardStatus: domain.StatusPending, CreatedAtMS: 1760000000000, }} wallet := &fakeLuckyGiftWallet{balanceAfter: 1300} service := New(repository, WithWallet(wallet)) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{ CommandID: "cmd-gift-fast", PoolID: "super_lucky", UserID: 42, TargetUserID: 99, DeviceID: "device-1", RoomID: "room-1", AnchorID: "99", GiftID: "rose", GiftCount: 2, CoinSpent: 200, PaidAtMS: 1760000000000, }) if err != nil { t.Fatalf("Draw failed: %v", err) } if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 { t.Fatalf("wallet fast path request mismatch: %+v", wallet.last) } if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 { t.Fatalf("fast path result mismatch: %+v", result) } if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" { t.Fatalf("fast path should mark all draw ids granted, got %s", got) } } func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) { repository := &fakeLuckyGiftRepository{executeResult: domain.DrawResult{ DrawID: "lucky_draw_retry", DrawIDs: []string{"lucky_draw_retry"}, CommandID: "cmd-gift-retry", PoolID: "super_lucky", GiftID: "rose", EffectiveRewardCoins: 300, RewardStatus: domain.StatusPending, }} wallet := &fakeLuckyGiftWallet{err: errors.New("wallet timeout")} service := New(repository, WithWallet(wallet)) result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{ CommandID: "cmd-gift-retry", PoolID: "super_lucky", UserID: 42, DeviceID: "device-1", RoomID: "room-1", AnchorID: "99", GiftID: "rose", GiftCount: 1, CoinSpent: 100, PaidAtMS: 1760000000000, }) if err != nil { t.Fatalf("Draw should keep the committed draw fact: %v", err) } if result.RewardStatus != domain.StatusPending || result.WalletTransactionID != "" || len(repository.grantedDrawIDs) != 0 { t.Fatalf("wallet failure must leave draw pending for outbox compensation: result=%+v granted=%v", result, repository.grantedDrawIDs) } } func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) { payload, _ := json.Marshal(map[string]any{ "event_type": "lucky_gift_drawn", "event_id": "lucky_gift_drawn:lucky_draw_test", "app_code": "lalu", "draw_id": "lucky_draw_test", "command_id": "cmd-gift", "pool_id": "super_lucky", "user_id": 42, "sender_user_id": 42, "target_user_id": 99, "visible_region_id": 210, "room_id": "room-1", "gift_id": "rose", "gift_count": 1, "coin_spent": 100, "effective_reward_coins": 300, "multiplier_ppm": 3000000, "stage_feedback": true, "created_at_ms": 1760000000000, }) repository := &fakeLuckyGiftRepository{ outbox: []domain.DrawOutbox{{ AppCode: "lalu", OutboxID: "lucky_lucky_draw_test", EventType: "LuckyGiftDrawn", PayloadJSON: string(payload), }}, } wallet := &fakeLuckyGiftWallet{} publisher := &fakeLuckyGiftPublisher{} broadcaster := &fakeLuckyGiftRegionBroadcaster{} service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster)) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) if err != nil { t.Fatalf("ProcessPendingDrawOutbox failed: %v", err) } if processed != 1 { t.Fatalf("processed count mismatch: %d", processed) } if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 { t.Fatalf("wallet reward request mismatch: %+v", wallet.last) } if publisher.last.EventID != "" || broadcaster.last.EventID != "" { t.Fatalf("settlement worker must not publish presentation: publisher=%+v broadcaster=%+v", publisher.last, broadcaster.last) } if repository.grantedDrawID != "lucky_draw_test" || repository.deliveredOutboxID != "lucky_lucky_draw_test" { t.Fatalf("repository settlement mismatch: granted=%s delivered=%s", repository.grantedDrawID, repository.deliveredOutboxID) } } func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *testing.T) { payload, _ := json.Marshal(map[string]any{ "event_type": "lucky_gift_drawn", "event_id": "lucky_gift_drawn:lucky_draw_batch_1", "app_code": "lalu", "draw_id": "lucky_draw_batch_1", "draw_ids": []string{"lucky_draw_batch_1", "lucky_draw_batch_2", "lucky_draw_batch_3"}, "command_id": "cmd-gift-batch", "pool_id": "lucky_100", "user_id": 42, "sender_user_id": 42, "target_user_id": 99, "room_id": "room-1", "gift_id": "rose", "gift_count": 3, "visible_region_id": 210, "coin_spent": 300, "effective_reward_coins": 800, "multiplier_ppm": 8000000, "created_at_ms": 1760000000000, }) repository := &fakeLuckyGiftRepository{ outbox: []domain.DrawOutbox{{ AppCode: "lalu", OutboxID: "lucky_lucky_draw_batch_1", EventType: "LuckyGiftDrawn", PayloadJSON: string(payload), }}, } wallet := &fakeLuckyGiftWallet{} publisher := &fakeLuckyGiftPublisher{} broadcaster := &fakeLuckyGiftRegionBroadcaster{} service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(broadcaster)) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) if err != nil { t.Fatalf("ProcessPendingDrawOutbox failed: %v", err) } if processed != 1 { t.Fatalf("processed count mismatch: %d", processed) } if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetVisibleRegionId() != 210 { t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last) } if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" { t.Fatalf("all sub draws should be granted together, got %s", got) } } func TestProcessRewardSettlementOutboxDoesNotPublishPresentation(t *testing.T) { payload, _ := json.Marshal(map[string]any{ "event_type": "lucky_gift_drawn", "event_id": "lucky_gift_drawn:lucky_draw_settle", "app_code": "lalu", "draw_id": "lucky_draw_settle", "command_id": "cmd-gift-settle", "pool_id": "super_lucky", "user_id": 42, "visible_region_id": 210, "room_id": "room-1", "gift_id": "rose", "gift_count": 1, "coin_spent": 100, "effective_reward_coins": 300, "multiplier_ppm": 3000000, "created_at_ms": 1760000000000, }) repository := &fakeLuckyGiftRepository{ outbox: []domain.DrawOutbox{{ AppCode: "lalu", OutboxID: "lucky_reward_lucky_draw_settle", EventType: domain.EventTypeLuckyGiftRewardSettlement, PayloadJSON: string(payload), }}, } wallet := &fakeLuckyGiftWallet{} publisher := &fakeLuckyGiftPublisher{} service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{})) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) if err != nil { t.Fatalf("ProcessPendingDrawOutbox failed: %v", err) } if processed != 1 || repository.deliveredOutboxID != "lucky_reward_lucky_draw_settle" { t.Fatalf("settlement outbox should be delivered: processed=%d delivered=%s", processed, repository.deliveredOutboxID) } if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" { t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID) } if publisher.last.EventID != "" { t.Fatalf("settlement outbox must not publish presentation: %+v", publisher.last) } } func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) { drawIDs := make([]string, 0, 999) for index := 0; index < 999; index++ { drawIDs = append(drawIDs, fmt.Sprintf("lucky_draw_1780217057721_%016d", index)) } publisher := &fakeLuckyGiftPublisher{} service := New(&fakeLuckyGiftRepository{}, WithRoomPublisher(publisher)) payload := luckyGiftDrawnPayload{ EventType: "lucky_gift_drawn", EventID: "lucky_gift_drawn:lucky_draw_1780217057721_0000000000000000", AppCode: "lalu", DrawID: drawIDs[0], DrawIDs: drawIDs, CommandID: "cmd-gift-batch", PoolID: "lucky", RoomID: "lalu_9aca4db6-35f0-4490-9cbc-1a452b7fa67d", GiftID: "31", GiftCount: 999, UserID: 42, SenderUserID: 42, TargetUserID: 99, VisibleRegionID: 210, CoinSpent: 99900, RuleVersion: 2, ExperiencePool: "advanced", SelectedTierID: "advanced_1x", MultiplierPPM: 1000000, BaseRewardCoins: 73983, RoomAtmosphereRewardCoins: 0, ActivitySubsidyCoins: 0, EffectiveRewardCoins: 73983, CreatedAtMS: 1780217057721, } if err := service.publishLuckyGiftDrawn(context.Background(), payload, WorkerOptions{PublishTimeout: time.Second}); err != nil { t.Fatalf("publishLuckyGiftDrawn failed: %v", err) } if len(publisher.last.PayloadJSON) >= 12*1024 { t.Fatalf("room IM payload should stay below Tencent 12KB limit, got %d bytes", len(publisher.last.PayloadJSON)) } var body map[string]any if err := json.Unmarshal(publisher.last.PayloadJSON, &body); err != nil { t.Fatalf("decode room IM payload failed: %v", err) } if _, exists := body["draw_ids"]; exists { t.Fatalf("room IM payload must not include internal draw_ids") } if body["draw_id"] != drawIDs[0] || body["event_id"] != payload.EventID || body["gift_count"].(float64) != 999 { t.Fatalf("room IM payload lost display fields: %+v", body) } } func TestProcessPendingDrawOutboxIgnoresRoomIMPublisher(t *testing.T) { payload, _ := json.Marshal(map[string]any{ "event_type": "lucky_gift_drawn", "event_id": "lucky_gift_drawn:lucky_draw_im_fail", "app_code": "lalu", "draw_id": "lucky_draw_im_fail", "command_id": "cmd-gift-im-fail", "pool_id": "super_lucky", "user_id": 42, "room_id": "room-1", "gift_id": "rose", "gift_count": 1, "coin_spent": 100, "effective_reward_coins": 300, "multiplier_ppm": 3000000, "created_at_ms": 1760000000000, }) repository := &fakeLuckyGiftRepository{ outbox: []domain.DrawOutbox{{ AppCode: "lalu", OutboxID: "lucky_lucky_draw_im_fail", EventType: "LuckyGiftDrawn", PayloadJSON: string(payload), }}, } wallet := &fakeLuckyGiftWallet{} publisher := &fakeLuckyGiftPublisher{err: errors.New("im unavailable")} service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{})) service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() }) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) if err != nil { t.Fatalf("ProcessPendingDrawOutbox failed: %v", err) } if processed != 1 { t.Fatalf("processed count mismatch: %d", processed) } if repository.grantedDrawID != "lucky_draw_im_fail" { t.Fatalf("draw should be settled through wallet, got %q", repository.grantedDrawID) } if repository.deliveredOutboxID != "lucky_lucky_draw_im_fail" || repository.retryableOutboxID != "" { t.Fatalf("IM publisher must not affect settlement outbox: delivered=%s retryable=%s", repository.deliveredOutboxID, repository.retryableOutboxID) } } func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) { payload, _ := json.Marshal(map[string]any{ "event_type": "lucky_gift_drawn", "event_id": "lucky_gift_drawn:lucky_draw_granted", "app_code": "lalu", "draw_id": "lucky_draw_granted", "command_id": "cmd-gift-granted", "pool_id": "super_lucky", "user_id": 42, "room_id": "room-1", "gift_id": "rose", "gift_count": 1, "coin_spent": 100, "effective_reward_coins": 300, "multiplier_ppm": 3_000_000, "created_at_ms": 1760000000000, }) repository := &fakeLuckyGiftRepository{ rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet_tx_existing"}, outbox: []domain.DrawOutbox{{ AppCode: "lalu", OutboxID: "lucky_lucky_draw_granted", EventType: "LuckyGiftDrawn", PayloadJSON: string(payload), }}, } wallet := &fakeLuckyGiftWallet{} publisher := &fakeLuckyGiftPublisher{} service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{})) processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"}) if err != nil { t.Fatalf("ProcessPendingDrawOutbox failed: %v", err) } if processed != 1 { t.Fatalf("processed count mismatch: %d", processed) } if wallet.last != nil { t.Fatalf("already granted draw must not call wallet again: %+v", wallet.last) } if repository.grantedDrawID != "" { t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID) } if publisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" { t.Fatalf("already granted draw should only deliver settlement outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID) } } type fakeLuckyGiftRepository struct { getConfigCalls int upserted domain.RuleConfig executeResult domain.DrawResult executeCmd domain.DrawCommand rewardState domain.DrawRewardState outbox []domain.DrawOutbox grantedDrawID string grantedDrawIDs []string deliveredOutboxID string retryableOutboxID string } func (r *fakeLuckyGiftRepository) GetLuckyGiftRuleConfig(context.Context, string) (domain.RuleConfig, bool, error) { r.getConfigCalls++ return domain.RuleConfig{}, false, nil } func (r *fakeLuckyGiftRepository) PublishLuckyGiftRuleConfig(_ context.Context, config domain.RuleConfig, _ int64) (domain.RuleConfig, error) { r.upserted = config return config, nil } func (r *fakeLuckyGiftRepository) ListLuckyGiftRuleConfigs(context.Context) ([]domain.RuleConfig, error) { return nil, nil } func (r *fakeLuckyGiftRepository) CheckLuckyGift(context.Context, domain.CheckCommand) (domain.CheckResult, error) { return domain.CheckResult{}, nil } func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd domain.DrawCommand, _ int64) (domain.DrawResult, error) { r.executeCmd = cmd return r.executeResult, nil } func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) { return nil, 0, nil } func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawSummary(context.Context, domain.DrawQuery) (domain.DrawSummary, error) { return domain.DrawSummary{}, nil } func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawRewardState(context.Context, string, []string) (domain.DrawRewardState, error) { return r.rewardState, nil } func (r *fakeLuckyGiftRepository) ClaimPendingLuckyGiftOutbox(context.Context, string, int64, time.Duration, int) ([]domain.DrawOutbox, error) { return r.outbox, nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxDelivered(_ context.Context, event domain.DrawOutbox, _ int64) error { r.deliveredOutboxID = event.OutboxID return nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error { r.retryableOutboxID = event.OutboxID return nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftOutboxFailed(context.Context, domain.DrawOutbox, int, string, int64) error { return nil } func (r *fakeLuckyGiftRepository) RefreshLuckyGiftAdminStatsSnapshots(context.Context, int64, int) (int, error) { return 0, nil } func (r *fakeLuckyGiftRepository) RefreshLuckyGiftDatabiStatsSnapshots(context.Context, int64, int) (int, error) { return 0, nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawGranted(_ context.Context, _ string, drawID string, _ string, _ int64) error { r.grantedDrawID = drawID return nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawFailed(context.Context, string, string, string, int64) error { return nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsGranted(_ context.Context, _ string, drawIDs []string, _ string, _ int64) error { r.grantedDrawIDs = append([]string(nil), drawIDs...) if len(drawIDs) > 0 { r.grantedDrawID = drawIDs[0] } return nil } func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsFailed(context.Context, string, []string, string, int64) error { return nil } type fakeLuckyGiftWallet struct { last *walletv1.CreditLuckyGiftRewardRequest err error balanceAfter int64 } func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *walletv1.CreditLuckyGiftRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) { w.last = req if w.err != nil { return nil, w.err } return &walletv1.CreditLuckyGiftRewardResponse{ TransactionId: "wallet_tx_lucky", Amount: req.GetAmount(), Balance: &walletv1.AssetBalance{AvailableAmount: w.balanceAfter}, }, nil } type fakeLuckyGiftPublisher struct { last tencentim.CustomGroupMessage err error } func (p *fakeLuckyGiftPublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error { p.last = message return p.err } type fakeLuckyGiftRegionBroadcaster struct { last broadcastservice.PublishInput } func (b *fakeLuckyGiftRegionBroadcaster) PublishRegionBroadcast(_ context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error) { b.last = input return broadcastdomain.PublishResult{EventID: input.EventID, Status: broadcastdomain.StatusPending, Created: true}, nil }