311 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package service_test
import (
"context"
"testing"
"time"
"google.golang.org/protobuf/proto"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type luckyGiftTestClient struct {
checks []*luckygiftv1.CheckLuckyGiftRequest
draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest
batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
drawResults []*luckygiftv1.LuckyGiftDrawResult
// checkStrategy 让 room 测试显式区分 fixed_v2 滚动兼容与 dynamic_v3 强设备事实。
checkStrategy string
}
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
c.checks = append(c.checks, req)
return &luckygiftv1.CheckLuckyGiftResponse{
Enabled: true,
Reason: "enabled",
GiftId: req.GetGiftId(),
PoolId: req.GetPoolId(),
RuleVersion: 12,
ExperiencePool: "novice",
StrategyVersion: c.checkStrategy,
}, nil
}
func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
c.draws = append(c.draws, req)
meta := req.GetLuckyGift()
if len(c.drawResults) > 0 {
index := len(c.draws) - 1
if index >= len(c.drawResults) {
index = len(c.drawResults) - 1
}
result := proto.Clone(c.drawResults[index]).(*luckygiftv1.LuckyGiftDrawResult)
if result.CommandId == "" {
result.CommandId = meta.GetCommandId()
}
if result.PoolId == "" {
result.PoolId = meta.GetPoolId()
}
if result.GiftId == "" {
result.GiftId = meta.GetGiftId()
}
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: result}, nil
}
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
DrawId: "lucky_draw_test",
CommandId: meta.GetCommandId(),
PoolId: meta.GetPoolId(),
GiftId: meta.GetGiftId(),
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 200,
EffectiveRewardCoins: 200,
RewardStatus: "granted",
WalletTransactionId: "wallet_tx_lucky",
CoinBalanceAfter: 8800,
CreatedAtMs: 1_779_258_000_000,
}}, nil
}
func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.BatchExecuteLuckyGiftDrawRequest) (*luckygiftv1.BatchExecuteLuckyGiftDrawResponse, error) {
c.batchDraws = append(c.batchDraws, req)
resp := &luckygiftv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*luckygiftv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))}
for index, meta := range req.GetLuckyGifts() {
if len(c.drawResults) > 0 {
resultIndex := index
if resultIndex >= len(c.drawResults) {
resultIndex = len(c.drawResults) - 1
}
result := proto.Clone(c.drawResults[resultIndex]).(*luckygiftv1.LuckyGiftDrawResult)
if result.CommandId == "" {
result.CommandId = meta.GetCommandId()
}
if result.PoolId == "" {
result.PoolId = meta.GetPoolId()
}
if result.GiftId == "" {
result.GiftId = meta.GetGiftId()
}
resp.Results = append(resp.Results, result)
continue
}
resp.Results = append(resp.Results, &luckygiftv1.LuckyGiftDrawResult{
DrawId: "lucky_draw_test",
CommandId: meta.GetCommandId(),
PoolId: meta.GetPoolId(),
GiftId: meta.GetGiftId(),
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 200,
EffectiveRewardCoins: 200,
RewardStatus: "pending",
CreatedAtMs: 1_779_258_000_000,
})
}
return resp, nil
}
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{
BillingReceiptId: "receipt-lucky",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
},
{
BillingReceiptId: "receipt-lucky-legacy-token",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
},
}}
luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-lucky-gift"
ownerID := int64(101)
viewerID := int64(202)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, viewerID)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky",
CommandId: "cmd-lucky",
ActorUserId: ownerID,
RoomId: roomID,
SessionId: "sess-must-not-be-device-1",
DeviceId: "device-auth-session-1",
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user",
TargetUserId: viewerID,
GiftId: "rose",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("send lucky gift failed: %v", err)
}
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 {
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
}
if len(resp.GetLuckyGifts()) != 1 || resp.GetLuckyGifts()[0].GetTargetUserId() != viewerID {
t.Fatalf("single-target lucky_gifts response mismatch: %+v", resp.GetLuckyGifts())
}
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" {
t.Fatalf("lucky check request mismatch: %+v", luckyGift.checks)
}
if len(luckyGift.draws) != 1 {
t.Fatalf("expected one lucky draw, got %d", len(luckyGift.draws))
}
drawMeta := luckyGift.draws[0].GetLuckyGift()
if drawMeta.GetCommandId() != "cmd-lucky" ||
drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-auth-session-1" ||
drawMeta.GetAnchorId() != "101" ||
drawMeta.GetVisibleRegionId() != 9001 {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
}
// 旧 access JWT 没有 device_id 时room 必须原样传空以保留 fixed_v2 兼容性;
// 不能因为需要一个非空风控键就用 session_id 或 command_id 代替设备。
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-legacy-token", CommandId: "cmd-lucky-legacy-token",
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-legacy-not-device",
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 1, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
}); err != nil {
t.Fatalf("legacy-token fixed-compatible lucky send failed: %v", err)
}
legacyDraw := luckyGift.draws[len(luckyGift.draws)-1].GetLuckyGift()
if legacyDraw.GetDeviceId() != "" {
t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", legacyDraw.GetDeviceId())
}
luckyGift.checkStrategy = "dynamic_v3"
debitsBeforeDynamicReject := len(wallet.debitRequests)
_, err = svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-dynamic-missing-device", CommandId: "cmd-lucky-dynamic-missing-device",
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-still-not-a-device",
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 2, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing signed device_id should fail before debit, got %v", err)
}
if len(wallet.debitRequests) != debitsBeforeDynamicReject {
t.Fatalf("dynamic_v3 missing device reached wallet: before=%d after=%d", debitsBeforeDynamicReject, len(wallet.debitRequests))
}
}
func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-lucky-202", CoinSpent: 100, ChargeAmount: 100, GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "super_lucky"},
{BillingReceiptId: "receipt-lucky-303", CoinSpent: 200, ChargeAmount: 200, GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "super_lucky"},
}}
luckyGift := &luckyGiftTestClient{drawResults: []*luckygiftv1.LuckyGiftDrawResult{
{DrawId: "draw-first", MultiplierPpm: 2_000_000, BaseRewardCoins: 200, EffectiveRewardCoins: 200, RewardStatus: "pending", StageFeedback: true, CreatedAtMs: 1_779_258_000_001},
{DrawId: "draw-second", MultiplierPpm: 3_000_000, BaseRewardCoins: 300, EffectiveRewardCoins: 300, RewardStatus: "pending", HighMultiplier: true, CreatedAtMs: 1_779_258_000_002},
}}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-multi-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-lucky-gift-multi"
ownerID := int64(101)
firstTargetID := int64(202)
secondTargetID := int64(303)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, firstTargetID)
joinRocketRoom(t, ctx, svc, roomID, secondTargetID)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-multi",
CommandId: "cmd-lucky-multi",
ActorUserId: ownerID,
RoomId: roomID,
SessionId: "sess-must-not-be-device-multi",
DeviceId: "device-auth-session-multi",
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "rose",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("send multi-target lucky gift failed: %v", err)
}
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" {
t.Fatalf("multi-target lucky gift should check pool once: %+v", luckyGift.checks)
}
if len(luckyGift.draws) != 0 || len(luckyGift.batchDraws) != 1 {
t.Fatalf("multi-target lucky gift should call one batch draw: draws=%d batch=%d", len(luckyGift.draws), len(luckyGift.batchDraws))
}
if len(luckyGift.batchDraws[0].GetLuckyGifts()) != 2 {
t.Fatalf("multi-target lucky batch should include two targets: %+v", luckyGift.batchDraws[0])
}
firstDraw := luckyGift.batchDraws[0].GetLuckyGifts()[0]
secondDraw := luckyGift.batchDraws[0].GetLuckyGifts()[1]
if firstDraw.GetCommandId() != "cmd-lucky-multi:target:202" || firstDraw.GetTargetUserId() != firstTargetID || firstDraw.GetCoinSpent() != 100 {
t.Fatalf("first target lucky draw mismatch: %+v", firstDraw)
}
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
t.Fatalf("second target lucky draw mismatch: %+v", secondDraw)
}
if firstDraw.GetDeviceId() != "device-auth-session-multi" || secondDraw.GetDeviceId() != "device-auth-session-multi" {
t.Fatalf("multi-target draws must share the signed JWT device scope: first=%q second=%q", firstDraw.GetDeviceId(), secondDraw.GetDeviceId())
}
if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" ||
resp.GetLuckyGift().GetTargetUserId() != 0 ||
resp.GetLuckyGift().GetSelectedTierId() != "batch" ||
resp.GetLuckyGift().GetMultiplierPpm() != 5_000_000 ||
resp.GetLuckyGift().GetEffectiveRewardCoins() != 500 ||
resp.GetLuckyGift().GetWalletTransactionId() != "" ||
resp.GetLuckyGift().GetCoinBalanceAfter() != 0 ||
!resp.GetLuckyGift().GetStageFeedback() ||
!resp.GetLuckyGift().GetHighMultiplier() ||
len(resp.GetLuckyGifts()) != 2 {
t.Fatalf("multi-target lucky response must include aggregate and list results: %+v", resp)
}
if resp.GetLuckyGifts()[0].GetCommandId() != "cmd-lucky-multi:target:202" || resp.GetLuckyGifts()[0].GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGifts()[1].GetCommandId() != "cmd-lucky-multi:target:303" || resp.GetLuckyGifts()[1].GetMultiplierPpm() != 3_000_000 || resp.GetLuckyGifts()[1].GetTargetUserId() != secondTargetID {
t.Fatalf("multi-target lucky_gifts response mismatch: %+v", resp.GetLuckyGifts())
}
}