784 lines
31 KiB
Go
784 lines
31 KiB
Go
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}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
service := New(repository, WithWallet(wallet), WithUserPublisher(userPublisher))
|
|
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,
|
|
CountryID: 15,
|
|
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 || wallet.last.GetCountryId() != 15 {
|
|
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
|
|
}
|
|
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 0 {
|
|
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)
|
|
}
|
|
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 1300)
|
|
}
|
|
|
|
func TestDrawBatchLeavesRewardSettlementToWorker(t *testing.T) {
|
|
repository := &fakeLuckyGiftRepository{executeBatchResults: []domain.DrawResult{{
|
|
DrawID: "lucky_draw_batch_async_1",
|
|
CommandID: "cmd-gift-batch:target:42",
|
|
PoolID: "super_lucky",
|
|
GiftID: "rose",
|
|
EffectiveRewardCoins: 300,
|
|
RewardStatus: domain.StatusPending,
|
|
}}}
|
|
wallet := &fakeLuckyGiftWallet{}
|
|
service := New(repository, WithWallet(wallet))
|
|
|
|
results, err := service.DrawBatch(appcode.WithContext(context.Background(), "lalu"), []domain.DrawCommand{{
|
|
CommandID: "cmd-gift-batch:target:42",
|
|
PoolID: "super_lucky",
|
|
UserID: 42,
|
|
TargetUserID: 99,
|
|
DeviceID: "device-1",
|
|
RoomID: "room-1",
|
|
AnchorID: "42",
|
|
GiftID: "rose",
|
|
GiftCount: 1,
|
|
CoinSpent: 100,
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("DrawBatch failed: %v", err)
|
|
}
|
|
if len(results) != 1 || results[0].DrawID != "lucky_draw_batch_async_1" {
|
|
t.Fatalf("DrawBatch result mismatch: %+v", results)
|
|
}
|
|
if wallet.last != nil || repository.grantedDrawID != "" || len(repository.grantedDrawIDs) != 0 {
|
|
t.Fatalf("batch draw must not run synchronous wallet fast path: wallet=%+v granted=%s granted_ids=%v", wallet.last, repository.grantedDrawID, repository.grantedDrawIDs)
|
|
}
|
|
if len(repository.executeBatchCmds) != 1 || repository.executeBatchCmds[0].CommandID != "cmd-gift-batch:target:42" {
|
|
t.Fatalf("batch draw must pass normalized commands to repository: %+v", repository.executeBatchCmds)
|
|
}
|
|
}
|
|
|
|
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 TestProcessPendingDrawOutboxCreditsWalletAndSkipsRoomDisplayBelowTenTimes(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,
|
|
"country_id": 15,
|
|
"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{}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
|
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), 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 || wallet.last.GetCountryId() != 15 {
|
|
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
|
|
}
|
|
if publisher.last.EventID != "" {
|
|
t.Fatalf("3x lucky gift must not publish room display: %+v", publisher.last)
|
|
}
|
|
if broadcaster.last.EventID != "" {
|
|
t.Fatalf("3x lucky gift must not enter region broadcast: %+v", broadcaster.last)
|
|
}
|
|
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
|
|
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 TestProcessPendingDrawOutboxPublishesTenTimesRegionBroadcastWithSenderProfile(t *testing.T) {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"event_type": "lucky_gift_drawn",
|
|
"event_id": "lucky_gift_drawn:lucky_draw_10x",
|
|
"app_code": "lalu",
|
|
"draw_id": "lucky_draw_10x",
|
|
"command_id": "cmd-gift-10x",
|
|
"pool_id": "super_lucky",
|
|
"user_id": 42,
|
|
"sender_user_id": 42,
|
|
"target_user_id": 99,
|
|
"country_id": 15,
|
|
"visible_region_id": 210,
|
|
"room_id": "room-1",
|
|
"gift_id": "rose",
|
|
"gift_count": 1,
|
|
"coin_spent": 100,
|
|
"effective_reward_coins": 1000,
|
|
"multiplier_ppm": luckyGiftBroadcastMinMultiplierPPM,
|
|
"created_at_ms": 1760000000000,
|
|
})
|
|
repository := &fakeLuckyGiftRepository{
|
|
outbox: []domain.DrawOutbox{{
|
|
AppCode: "lalu",
|
|
OutboxID: "lucky_lucky_draw_10x",
|
|
EventType: "LuckyGiftDrawn",
|
|
PayloadJSON: string(payload),
|
|
}},
|
|
}
|
|
wallet := &fakeLuckyGiftWallet{}
|
|
publisher := &fakeLuckyGiftPublisher{}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
|
service := New(repository,
|
|
WithWallet(wallet),
|
|
WithRoomPublisher(publisher),
|
|
WithUserPublisher(userPublisher),
|
|
WithRegionBroadcaster(broadcaster),
|
|
WithSenderProfileSource(fakeLuckyGiftSenderProfileSource{profiles: map[int64]broadcastservice.SenderProfile{
|
|
42: {UserID: 42, Account: "160042", Nickname: "Real Sender", Avatar: "https://cdn.example/sender.png"},
|
|
}}),
|
|
)
|
|
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
|
|
|
processed, err := service.ProcessPendingDrawOutbox(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-1"})
|
|
if err != nil {
|
|
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
|
|
}
|
|
if processed != 1 {
|
|
t.Fatalf("processed count mismatch: %d", processed)
|
|
}
|
|
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_10x", "lucky_draw_10x", 1000)
|
|
var roomPayload map[string]any
|
|
if err := json.Unmarshal(publisher.last.PayloadJSON, &roomPayload); err != nil {
|
|
t.Fatalf("room payload is invalid: %v", err)
|
|
}
|
|
if roomPayload["sender_name"] != "Real Sender" || roomPayload["sender_display_user_id"] != "160042" {
|
|
t.Fatalf("room payload must include sender display snapshot: %+v", roomPayload)
|
|
}
|
|
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_10x" || broadcaster.last.RegionID != 210 {
|
|
t.Fatalf("10x lucky gift region broadcast mismatch: %+v", broadcaster.last)
|
|
}
|
|
var regionPayload map[string]any
|
|
if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), ®ionPayload); err != nil {
|
|
t.Fatalf("region payload is invalid: %v", err)
|
|
}
|
|
if regionPayload["sender_name"] != "Real Sender" || regionPayload["sender_display_user_id"] != "160042" || regionPayload["sender_avatar"] != "https://cdn.example/sender.png" {
|
|
t.Fatalf("region payload must include sender display snapshot: %+v", regionPayload)
|
|
}
|
|
sender, _ := regionPayload["sender"].(map[string]any)
|
|
if sender["nickname"] != "Real Sender" || sender["display_user_id"] != "160042" {
|
|
t.Fatalf("nested sender profile mismatch: %+v", sender)
|
|
}
|
|
if _, exists := regionPayload["gift_name"]; exists {
|
|
t.Fatalf("lucky gift region broadcast should not send a gift-name template field: %+v", regionPayload)
|
|
}
|
|
}
|
|
|
|
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,
|
|
"country_id": 15,
|
|
"room_id": "room-1",
|
|
"gift_id": "rose",
|
|
"gift_count": 3,
|
|
"visible_region_id": 210,
|
|
"coin_spent": 300,
|
|
"effective_reward_coins": 3600,
|
|
"multiplier_ppm": 12000000,
|
|
"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{}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
|
service := New(repository,
|
|
WithWallet(wallet),
|
|
WithRoomPublisher(publisher),
|
|
WithUserPublisher(userPublisher),
|
|
WithRegionBroadcaster(broadcaster),
|
|
WithSenderProfileSource(fakeLuckyGiftSenderProfileSource{profiles: map[int64]broadcastservice.SenderProfile{
|
|
42: {UserID: 42, Account: "160042", Nickname: "Batch Sender"},
|
|
}}),
|
|
)
|
|
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() != 3600 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || 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)
|
|
}
|
|
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 3600, 0)
|
|
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 3600)
|
|
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
|
|
t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
|
|
}
|
|
}
|
|
|
|
func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(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": 1200,
|
|
"multiplier_ppm": 12000000,
|
|
"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{}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
broadcaster := &fakeLuckyGiftRegionBroadcaster{}
|
|
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), WithRegionBroadcaster(broadcaster))
|
|
|
|
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)
|
|
}
|
|
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 1200)
|
|
if broadcaster.last.EventID != "" {
|
|
t.Fatalf("settlement without sender profile must not enter region broadcast: %+v", broadcaster.last)
|
|
}
|
|
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 1200, 0)
|
|
}
|
|
|
|
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: "batch",
|
|
MultiplierPPM: 12000000,
|
|
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 TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(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": 1200,
|
|
"multiplier_ppm": 12000000,
|
|
"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")}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), 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)
|
|
}
|
|
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
|
|
t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
|
|
}
|
|
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 1200, 0)
|
|
}
|
|
|
|
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": 1200,
|
|
"multiplier_ppm": 12_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{}
|
|
userPublisher := &fakeLuckyGiftUserPublisher{}
|
|
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithUserPublisher(userPublisher), 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 userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
|
|
t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
|
|
}
|
|
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 1200)
|
|
}
|
|
|
|
type fakeLuckyGiftRepository struct {
|
|
getConfigCalls int
|
|
upserted domain.RuleConfig
|
|
executeResult domain.DrawResult
|
|
executeCmd domain.DrawCommand
|
|
executeBatchResults []domain.DrawResult
|
|
executeBatchCmds []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) ExecuteLuckyGiftDrawBatch(_ context.Context, cmds []domain.DrawCommand, _ int64) ([]domain.DrawResult, error) {
|
|
r.executeBatchCmds = append([]domain.DrawCommand(nil), cmds...)
|
|
if len(r.executeBatchResults) > 0 {
|
|
return append([]domain.DrawResult(nil), r.executeBatchResults...), nil
|
|
}
|
|
results := make([]domain.DrawResult, 0, len(cmds))
|
|
for _, cmd := range cmds {
|
|
result := r.executeResult
|
|
result.CommandID = cmd.CommandID
|
|
results = append(results, result)
|
|
}
|
|
return results, 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 fakeLuckyGiftUserPublisher struct {
|
|
last tencentim.CustomUserMessage
|
|
err error
|
|
}
|
|
|
|
func (p *fakeLuckyGiftUserPublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) 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
|
|
}
|
|
|
|
type fakeLuckyGiftSenderProfileSource struct {
|
|
profiles map[int64]broadcastservice.SenderProfile
|
|
err error
|
|
}
|
|
|
|
func (s fakeLuckyGiftSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (broadcastservice.SenderProfile, error) {
|
|
if s.err != nil {
|
|
return broadcastservice.SenderProfile{}, s.err
|
|
}
|
|
if profile, ok := s.profiles[userID]; ok {
|
|
return profile, nil
|
|
}
|
|
return broadcastservice.SenderProfile{UserID: userID}, nil
|
|
}
|
|
|
|
func assertLuckyGiftWalletNotice(t *testing.T, message tencentim.CustomUserMessage, toAccount string, transactionID string, availableDelta int64, availableAfter int64) {
|
|
t.Helper()
|
|
if message.ToAccount != toAccount || message.Desc != "WalletBalanceChanged" || message.Ext != "wallet_notice" {
|
|
t.Fatalf("wallet notice envelope mismatch: %+v", message)
|
|
}
|
|
if message.EventID != walletBalanceChangedEventID(transactionID, 42, "COIN") {
|
|
t.Fatalf("wallet notice event id mismatch: %s", message.EventID)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
|
|
t.Fatalf("wallet notice payload is invalid: %v", err)
|
|
}
|
|
if payload["event_type"] != "WalletBalanceChanged" || payload["transaction_id"] != transactionID || payload["user_id"] != "42" || payload["asset_type"] != "COIN" {
|
|
t.Fatalf("wallet notice identity fields mismatch: %+v", payload)
|
|
}
|
|
if int64(payload["available_delta"].(float64)) != availableDelta || int64(payload["available_after"].(float64)) != availableAfter {
|
|
t.Fatalf("wallet notice balance fields mismatch: %+v", payload)
|
|
}
|
|
}
|
|
|
|
func assertLuckyGiftRoomMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string, drawID string, rewardCoins int64) {
|
|
t.Helper()
|
|
if message.GroupID != groupID || message.EventID != eventID || message.Desc != "lucky_gift_drawn" || message.Ext != "room_system_message" {
|
|
t.Fatalf("room lucky gift envelope mismatch: %+v", message)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(message.PayloadJSON, &payload); err != nil {
|
|
t.Fatalf("room lucky gift payload is invalid: %v", err)
|
|
}
|
|
if payload["event_type"] != "lucky_gift_drawn" || payload["event_id"] != eventID || payload["draw_id"] != drawID {
|
|
t.Fatalf("room lucky gift identity fields mismatch: %+v", payload)
|
|
}
|
|
if int64(payload["effective_reward_coins"].(float64)) != rewardCoins {
|
|
t.Fatalf("room lucky gift reward mismatch: %+v", payload)
|
|
}
|
|
}
|