675 lines
26 KiB
Go

package service_test
import (
"context"
"fmt"
"slices"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roommq"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/room/state"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type fixedRoomRocketClock struct {
now time.Time
}
func (c *fixedRoomRocketClock) Now() time.Time {
return c.now.UTC()
}
type rocketTestWallet struct {
debits []*walletv1.DebitGiftResponse
grants []*walletv1.GrantResourceGroupRequest
lastDebit *walletv1.DebitGiftRequest
debitRequests []*walletv1.DebitGiftRequest
lastBatch *walletv1.BatchDebitGiftRequest
}
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.lastDebit = req
w.debitRequests = append(w.debitRequests, req)
if len(w.debits) == 0 {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil
}
next := w.debits[0]
w.debits = w.debits[1:]
return next, nil
}
func (w *rocketTestWallet) BatchDebitGift(_ context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) {
w.lastBatch = req
receipts := make([]*walletv1.BatchDebitGiftReceipt, 0, len(req.GetTargets()))
aggregate := &walletv1.DebitGiftResponse{}
for _, target := range req.GetTargets() {
var next *walletv1.DebitGiftResponse
if len(w.debits) == 0 {
next = &walletv1.DebitGiftResponse{BillingReceiptId: fmt.Sprintf("receipt-%d", target.GetTargetUserId()), GiftPointAdded: 1, HeatValue: 1, CoinSpent: 1, ChargeAmount: 1}
} else {
next = w.debits[0]
w.debits = w.debits[1:]
}
aggregate.BillingReceiptId = joinReceiptIDs(aggregate.GetBillingReceiptId(), next.GetBillingReceiptId())
aggregate.CoinSpent += next.GetCoinSpent()
aggregate.ChargeAmount += next.GetChargeAmount()
aggregate.GiftPointAdded += next.GetGiftPointAdded()
aggregate.HeatValue += next.GetHeatValue()
aggregate.HostPeriodDiamondAdded += next.GetHostPeriodDiamondAdded()
if aggregate.PriceVersion == "" {
aggregate.PriceVersion = next.GetPriceVersion()
}
if aggregate.GiftTypeCode == "" {
aggregate.GiftTypeCode = next.GetGiftTypeCode()
}
aggregate.BalanceAfter = next.GetBalanceAfter()
receipts = append(receipts, &walletv1.BatchDebitGiftReceipt{
TargetUserId: target.GetTargetUserId(),
CommandId: target.GetCommandId(),
Billing: next,
})
}
return &walletv1.BatchDebitGiftResponse{Aggregate: aggregate, Receipts: receipts}, nil
}
func joinReceiptIDs(left string, right string) string {
if left == "" {
return right
}
if right == "" {
return left
}
return left + "," + right
}
func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-202", CoinSpent: 10, ChargeAmount: 10, GiftPointAdded: 5, HeatValue: 11, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 90},
{BillingReceiptId: "receipt-303", CoinSpent: 20, ChargeAmount: 20, GiftPointAdded: 7, HeatValue: 19, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 70},
}}
svc := newRocketTestService(t, repository, wallet, now)
roomID := "room-gift-multi-target"
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-gift-multi",
CommandId: "cmd-gift-multi",
ActorUserId: ownerID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: now.Now().UnixMilli(),
},
TargetType: "user",
TargetUserIds: []int64{firstTargetID, secondTargetID},
GiftId: "rose",
GiftCount: 2,
})
if err != nil {
t.Fatalf("send multi-target gift failed: %v", err)
}
if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" {
t.Fatalf("multi-target gift response must aggregate wallet results: %+v", resp)
}
if len(wallet.debitRequests) != 0 {
t.Fatalf("multi-target gift must use wallet BatchDebitGift, single debits=%+v", wallet.debitRequests)
}
if wallet.lastBatch == nil || len(wallet.lastBatch.GetTargets()) != 2 {
t.Fatalf("wallet batch request missing targets: %+v", wallet.lastBatch)
}
if wallet.lastBatch.GetTargets()[0].GetCommandId() != "cmd-gift-multi:target:202" || wallet.lastBatch.GetTargets()[1].GetCommandId() != "cmd-gift-multi:target:303" {
t.Fatalf("wallet batch target command ids mismatch: %+v", wallet.lastBatch.GetTargets())
}
events := roomGiftSentEvents(t, ctx, repository)
if len(events) != 2 {
t.Fatalf("multi-target gift must emit one GiftSent per target, got %d", len(events))
}
got := map[int64]*roomeventsv1.RoomGiftSent{}
for _, event := range events {
got[event.GetTargetUserId()] = event
}
if got[firstTargetID].GetBillingReceiptId() != "receipt-202" || got[firstTargetID].GetGiftValue() != 11 || got[firstTargetID].GetCommandId() != "cmd-gift-multi:target:202" {
t.Fatalf("first target GiftSent mismatch: %+v", got[firstTargetID])
}
if got[secondTargetID].GetBillingReceiptId() != "receipt-303" || got[secondTargetID].GetGiftValue() != 19 || got[secondTargetID].GetCommandId() != "cmd-gift-multi:target:303" {
t.Fatalf("second target GiftSent mismatch: %+v", got[secondTargetID])
}
}
func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
w.grants = append(w.grants, req)
return &walletv1.ResourceGrantResponse{
Grant: &walletv1.ResourceGrant{
GrantId: fmt.Sprintf("grant-%d-%d", req.GetTargetUserId(), req.GetGroupId()),
CommandId: req.GetCommandId(),
TargetUserId: req.GetTargetUserId(),
},
}, nil
}
type rocketLaunchScheduleRecorder struct {
schedules []integration.RoomRocketLaunchSchedule
}
func (r *rocketLaunchScheduleRecorder) ScheduleRoomRocketLaunch(_ context.Context, schedule integration.RoomRocketLaunchSchedule) error {
r.schedules = append(r.schedules, schedule)
return nil
}
func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-next", GiftPointAdded: 50, HeatValue: 50, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-flow"
ownerID := int64(101)
viewerID := int64(202)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, viewerID)
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "fill"),
TargetType: "user",
TargetUserId: viewerID,
GiftId: "gift-overflow",
GiftCount: 1,
})
if err != nil {
t.Fatalf("send fill gift failed: %v", err)
}
rocket := fillResp.GetRocket()
if rocket.GetCurrentLevel() != 2 || rocket.GetCurrentFuel() != 0 || rocket.GetStatus() != state.RocketStatusCharging {
t.Fatalf("fill gift must ignite level 1 and immediately start level 2: %+v", rocket)
}
if len(rocket.GetPendingLaunches()) != 1 {
t.Fatalf("filled level must create exactly one pending launch: %+v", rocket)
}
pending := rocket.GetPendingLaunches()[0]
if pending.GetLevel() != 1 || pending.GetFuelThreshold() != 100 || pending.GetLaunchAtMs() != now.Now().Add(time.Second).UnixMilli() {
t.Fatalf("pending launch must lock the filled level snapshot: %+v", pending)
}
if rocket.GetResetAtMs() != time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC).UnixMilli() {
t.Fatalf("rocket timers must use UTC business time: %+v", rocket)
}
if pending.GetTop1UserId() != ownerID || pending.GetIgniterUserId() != ownerID {
t.Fatalf("top1 and igniter must lock at ignited start: %+v", pending)
}
progressEvents := rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 {
t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents)
}
if progressEvents[0].GetLevel() != 1 || progressEvents[0].GetStatus() != state.RocketStatusIgnited {
t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0])
}
if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 {
t.Fatalf("fill gift must emit one ignited event, got %d", got)
}
nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, viewerID, "next-level"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-next-level",
GiftCount: 1,
})
if err != nil {
t.Fatalf("send next-level gift failed: %v", err)
}
if nextResp.GetRocket().GetCurrentLevel() != 2 || nextResp.GetRocket().GetCurrentFuel() != 50 || nextResp.GetRocket().GetStatus() != state.RocketStatusCharging {
t.Fatalf("gift after ignition must add fuel to the next level immediately: %+v", nextResp.GetRocket())
}
if len(nextResp.GetRocket().GetPendingLaunches()) != 1 {
t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
}
progressEvents = rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 {
t.Fatalf("second progress event must target level 2: %+v", progressEvents)
}
if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 {
t.Fatalf("next-level partial gift must not emit another ignited event, got %d", got)
}
}
func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-fill", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
}}
scheduler := &rocketLaunchScheduleRecorder{}
svc := newRocketTestServiceWithScheduler(t, repository, wallet, now, scheduler)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-schedule"
ownerID := int64(801)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9008)
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "fill"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-fill",
GiftCount: 1,
}); err != nil {
t.Fatalf("fill rocket failed: %v", err)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
t.Fatalf("process outbox failed: %v", err)
}
if len(scheduler.schedules) != 1 {
t.Fatalf("scheduled launches=%d, want 1: %+v", len(scheduler.schedules), scheduler.schedules)
}
scheduled := scheduler.schedules[0]
if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.LaunchAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.ResetAtMS == 0 || scheduled.CommandID == "" {
t.Fatalf("unexpected delayed launch schedule: %+v", scheduled)
}
}
func TestRoomRocketLaunchDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-mq-launch"
ownerID := int64(901)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9009)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "ignite"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-ignite",
GiftCount: 1,
})
if err != nil {
t.Fatalf("ignite rocket failed: %v", err)
}
rocket := resp.GetRocket()
if len(rocket.GetPendingLaunches()) != 1 {
t.Fatalf("ignite should create one pending launch: %+v", rocket)
}
pending := rocket.GetPendingLaunches()[0]
due := roommq.RoomRocketLaunchDueMessage{
MessageType: roommq.MessageTypeRoomRocketLaunchDue,
AppCode: appcode.Default,
RoomID: roomID,
RocketID: pending.GetRocketId(),
Level: pending.GetLevel(),
LaunchAtMS: pending.GetLaunchAtMs(),
ResetAtMS: pending.GetResetAtMs(),
CommandID: "cmd_room_rocket_launch_" + pending.GetRocketId(),
}
if err := svc.HandleRoomRocketLaunchDue(ctx, due); err == nil {
t.Fatal("early delayed message should ask MQ to retry")
}
now.now = now.now.Add(time.Second)
if err := svc.HandleRoomRocketLaunchDue(ctx, due); err != nil {
t.Fatalf("due delayed message should launch rocket after retry: %v", err)
}
info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
RoomId: roomID,
ViewerUserId: ownerID,
Meta: &roomv1.RequestMeta{
AppCode: appcode.Default,
RequestId: "req-check",
},
})
if err != nil {
t.Fatalf("get rocket failed: %v", err)
}
if info.GetRocket().GetState().GetStatus() != state.RocketStatusCharging || info.GetRocket().GetState().GetCurrentLevel() != 2 || len(info.GetRocket().GetState().GetPendingLaunches()) != 0 {
t.Fatalf("rocket should launch once after early retry: %+v", info.GetRocket().GetState())
}
if len(wallet.grants) == 0 {
t.Fatal("launch should grant configured rewards after retry")
}
}
func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-launch"
ownerID := int64(301)
igniterID := int64(302)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9002)
joinRocketRoom(t, ctx, svc, roomID, igniterID)
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, igniterID, "ignite"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-ignite",
GiftCount: 1,
}); err != nil {
t.Fatalf("ignite rocket failed: %v", err)
}
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, igniterID, "leave")}); err != nil {
t.Fatalf("igniter leave room failed: %v", err)
}
now.now = now.now.Add(1100 * time.Millisecond)
if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
t.Fatalf("sweep room rocket launches failed: %v", err)
}
if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 3001) {
t.Fatalf("left top1/igniter must still receive locked rewards: %+v", wallet.grants)
}
if hasRocketGrant(wallet.grants, igniterID, 1001) {
t.Fatalf("left igniter must not receive in-room reward: %+v", wallet.grants)
}
if !hasRocketGrant(wallet.grants, ownerID, 1001) {
t.Fatalf("remaining in-room owner must receive in-room reward: %+v", wallet.grants)
}
info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: ownerID,
})
if err != nil {
t.Fatalf("get rocket after launch failed: %v", err)
}
stateView := info.GetRocket().GetState()
if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging || len(stateView.GetPendingLaunches()) != 0 {
t.Fatalf("launch rocket must keep current next-level progress and clear pending launch: %+v", stateView)
}
if len(stateView.GetLastRewards()) != 3 {
t.Fatalf("launch rocket must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards())
}
if got := rocketRewardUserIDs(stateView.GetLastRewards()); !slices.Equal(got, []int64{301, 302, 302}) {
t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards())
}
}
func TestRoomRocketUTCResetCancelsCrossDayIgnited(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 23, 59, 59, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{BillingReceiptId: "receipt-cross-day", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-new-day", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 2_000))
roomID := "room-rocket-reset"
ownerID := int64(401)
viewerID := int64(402)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9003)
joinRocketRoom(t, ctx, svc, roomID, viewerID)
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, viewerID, "cross-day-fill"),
TargetType: "user",
TargetUserId: ownerID,
GiftId: "gift-cross-day",
GiftCount: 1,
})
if err != nil {
t.Fatalf("send cross-day gift failed: %v", err)
}
if len(fillResp.GetRocket().GetPendingLaunches()) != 1 || fillResp.GetRocket().GetPendingLaunches()[0].GetLaunchAtMs() <= fillResp.GetRocket().GetResetAtMs() {
t.Fatalf("fixture must create a pending launch whose launch time crosses UTC reset: %+v", fillResp.GetRocket())
}
now.now = time.Date(2026, 5, 21, 0, 0, 2, 0, time.UTC)
if err := svc.SweepRoomRocketLaunchings(ctx); err != nil {
t.Fatalf("sweep cross-day rocket failed: %v", err)
}
if len(wallet.grants) != 0 {
t.Fatalf("cross-day ignited must be canceled by UTC reset, grants=%+v", wallet.grants)
}
info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
RoomId: roomID,
ViewerUserId: ownerID,
})
if err != nil {
t.Fatalf("get rocket after UTC reset failed: %v", err)
}
if stateView := info.GetRocket().GetState(); stateView.GetCurrentLevel() != 1 || stateView.GetCurrentFuel() != 0 || stateView.GetStatus() != state.RocketStatusCharging {
t.Fatalf("query view must reset stale ignited at UTC boundary: %+v", stateView)
}
nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "new-day-gift"),
TargetType: "user",
TargetUserId: viewerID,
GiftId: "gift-new-day",
GiftCount: 1,
})
if err != nil {
t.Fatalf("send new-day gift failed: %v", err)
}
if nextResp.GetRocket().GetCurrentLevel() != 1 || nextResp.GetRocket().GetCurrentFuel() != 10 || nextResp.GetRocket().GetStatus() != state.RocketStatusCharging {
t.Fatalf("first new-day gift must start a fresh level 1 rocket: %+v", nextResp.GetRocket())
}
}
func newRocketTestService(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock) *roomservice.Service {
t.Helper()
return newRocketTestServiceWithScheduler(t, repository, wallet, clock, nil)
}
func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler) *roomservice.Service {
t.Helper()
return roomservice.New(roomservice.Config{
NodeID: "node-rocket-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: clock,
RoomRocketLaunchScheduler: scheduler,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}
func rocketConfigForTest(threshold int64, launchDelayMS int64) roomservice.RoomRocketConfig {
levels := make([]roomservice.RoomRocketLevelConfig, 0, 5)
for level := int32(1); level <= 5; level++ {
levels = append(levels, roomservice.RoomRocketLevelConfig{
Level: level,
FuelThreshold: threshold + int64(level-1)*100,
CoverURL: fmt.Sprintf("https://example.test/rocket/%d-cover.png", level),
AnimationURL: fmt.Sprintf("https://example.test/rocket/%d-idle.webp", level),
LaunchAnimationURL: fmt.Sprintf("https://example.test/rocket/%d-launch.webp", level),
LaunchedImageURL: fmt.Sprintf("https://example.test/rocket/%d-launched.png", level),
InRoomRewards: []roomservice.RoomRocketRewardItem{{
RewardItemID: fmt.Sprintf("level-%d-in-room", level),
ResourceGroupID: 1000 + int64(level),
Weight: 1,
DisplayName: "In Room",
IconURL: "https://example.test/reward/in-room.png",
}},
Top1Rewards: []roomservice.RoomRocketRewardItem{{
RewardItemID: fmt.Sprintf("level-%d-top1", level),
ResourceGroupID: 2000 + int64(level),
Weight: 1,
DisplayName: "Top 1",
IconURL: "https://example.test/reward/top1.png",
}},
IgniterRewards: []roomservice.RoomRocketRewardItem{{
RewardItemID: fmt.Sprintf("level-%d-igniter", level),
ResourceGroupID: 3000 + int64(level),
Weight: 1,
DisplayName: "Igniter",
IconURL: "https://example.test/reward/igniter.png",
}},
})
}
return roomservice.RoomRocketConfig{
AppCode: appcode.Default,
Enabled: true,
ConfigVersion: 1,
FuelSource: "heat_value",
LaunchDelayMS: launchDelayMS,
BroadcastEnabled: true,
BroadcastScope: "region",
RewardStackPolicy: "allow_stack",
Levels: levels,
}
}
func writeRocketConfig(t *testing.T, ctx context.Context, repository *mysqltest.Repository, config roomservice.RoomRocketConfig) {
t.Helper()
if err := repository.UpsertRoomRocketConfig(ctx, config); err != nil {
t.Fatalf("upsert rocket config failed: %v", err)
}
}
func createRocketRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, ownerID int64, regionID int64) {
t.Helper()
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: regionID,
RoomName: roomID,
RoomAvatar: testRoomCoverURL,
RoomShortId: roomID,
}); err != nil {
t.Fatalf("create rocket room failed: %v", err)
}
}
func joinRocketRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, userID int64) {
t.Helper()
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, userID),
Role: "audience",
}); err != nil {
t.Fatalf("join rocket room failed: %v", err)
}
}
func rocketMeta(roomID string, actorUserID int64, suffix string) *roomv1.RequestMeta {
return &roomv1.RequestMeta{
RequestId: "req-room-rocket-" + suffix,
CommandId: "cmd-room-rocket-" + suffix,
ActorUserId: actorUserID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
}
}
func rocketProgressEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomRocketFuelChanged {
t.Helper()
records, err := repository.ListPendingOutbox(ctx, 50)
if err != nil {
t.Fatalf("list pending outbox failed: %v", err)
}
events := make([]*roomeventsv1.RoomRocketFuelChanged, 0)
for _, record := range records {
if record.EventType != "RoomRocketFuelChanged" {
continue
}
var event roomeventsv1.RoomRocketFuelChanged
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
t.Fatalf("decode rocket progress event failed: %v", err)
}
events = append(events, &event)
}
return events
}
func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
t.Helper()
records, err := repository.ListPendingOutbox(ctx, 50)
if err != nil {
t.Fatalf("list pending outbox failed: %v", err)
}
events := make([]*roomeventsv1.RoomGiftSent, 0)
for _, record := range records {
if record.EventType != "RoomGiftSent" {
continue
}
var event roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
t.Fatalf("decode gift sent event failed: %v", err)
}
events = append(events, &event)
}
return events
}
func countRocketIgnitedEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) int {
t.Helper()
records, err := repository.ListPendingOutbox(ctx, 50)
if err != nil {
t.Fatalf("list pending outbox failed: %v", err)
}
count := 0
for _, record := range records {
if record.EventType == "RoomRocketIgnited" {
count++
}
}
return count
}
func hasRocketGrant(grants []*walletv1.GrantResourceGroupRequest, userID int64, groupID int64) bool {
for _, grant := range grants {
if grant.GetTargetUserId() == userID && grant.GetGroupId() == groupID {
return true
}
}
return false
}
func rocketRewardUserIDs(rewards []*roomv1.RoomRocketRewardGrant) []int64 {
userIDs := make([]int64, 0, len(rewards))
for _, reward := range rewards {
userIDs = append(userIDs, reward.GetUserId())
}
slices.Sort(userIDs)
return userIDs
}