package service_test import ( "context" "fmt" "slices" "sync" "testing" "time" "google.golang.org/protobuf/proto" activityv1 "hyapp.local/api/proto/activity/v1" 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/pkg/xerr" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/command" 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 balanceResponses []*walletv1.GetBalancesResponse balanceRequests []*walletv1.GetBalancesRequest } type recordingRoomDirectIMPublisher struct { mu sync.Mutex wake chan struct{} records []*roomeventsv1.EventEnvelope } func newRecordingRoomDirectIMPublisher() *recordingRoomDirectIMPublisher { return &recordingRoomDirectIMPublisher{wake: make(chan struct{}, 128)} } func (p *recordingRoomDirectIMPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error { p.mu.Lock() p.records = append(p.records, proto.Clone(envelope).(*roomeventsv1.EventEnvelope)) p.mu.Unlock() select { case p.wake <- struct{}{}: default: } return nil } func (p *recordingRoomDirectIMPublisher) counts() map[string]int { p.mu.Lock() defer p.mu.Unlock() counts := make(map[string]int, len(p.records)) for _, record := range p.records { counts[record.GetEventType()]++ } return counts } func (p *recordingRoomDirectIMPublisher) recordsByType(eventType string) []*roomeventsv1.EventEnvelope { p.mu.Lock() defer p.mu.Unlock() records := make([]*roomeventsv1.EventEnvelope, 0) for _, record := range p.records { if record.GetEventType() == eventType { records = append(records, proto.Clone(record).(*roomeventsv1.EventEnvelope)) } } return records } func TestJoinRoomDirectIMCarriesDisplayProfileSnapshot(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM) roomID := "room-join-display-profile" userID := int64(1002) createRocketRoom(t, ctx, svc, roomID, 1001, 9001) if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, userID), Role: "audience", ActorDisplayProfile: &roomv1.RoomUserDisplayProfile{ UserId: userID, Nickname: "Join Snapshot", Avatar: "https://cdn.example/join.png", DisplayUserId: "200002", PrettyDisplayUserId: "888002", }, }); err != nil { t.Fatalf("join room with display profile failed: %v", err) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1}) records := directIM.recordsByType("RoomUserJoined") if len(records) != 1 { t.Fatalf("direct IM joined records mismatch: %+v", records) } var joined roomeventsv1.RoomUserJoined if err := proto.Unmarshal(records[0].GetBody(), &joined); err != nil { t.Fatalf("decode direct IM joined body failed: %v", err) } if joined.GetNickname() != "Join Snapshot" || joined.GetAvatar() != "https://cdn.example/join.png" || joined.GetDisplayUserId() != "200002" || joined.GetPrettyDisplayUserId() != "888002" { t.Fatalf("direct IM joined profile mismatch: %+v", &joined) } } func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM) roomID := "room-repeat-join-broadcast" userID := int64(1002) createRocketRoom(t, ctx, svc, roomID, 1001, 9001) joinRocketRoom(t, ctx, svc, roomID, userID) waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1}) if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 { t.Fatalf("first join must write one durable RoomUserJoined outbox, got %d", got) } if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, userID), Role: "audience", }); err != nil { t.Fatalf("repeat join failed: %v", err) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 2}) if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != 1 { t.Fatalf("repeat join must not write durable RoomUserJoined outbox, got %d", got) } } func TestJoinRoomLiteTrimsResponseSnapshotOnly(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC)} svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, newRecordingRoomDirectIMPublisher()) roomID := "room-lite-response-snapshot" createRocketRoom(t, ctx, svc, roomID, 1001, 9001) joinRocketRoom(t, ctx, svc, roomID, 1002) joinRocketRoom(t, ctx, svc, roomID, 1003) resp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: roomservice.NewRequestMeta(roomID, 1004), Role: "audience", ResponseMode: "lite", }) if err != nil { t.Fatalf("lite join room failed: %v", err) } if len(resp.GetRoom().GetOnlineUsers()) != 0 || len(resp.GetRoom().GetGiftRank()) != 0 || len(resp.GetRoom().GetAdminUserIds()) != 0 { t.Fatalf("lite response must trim heavy room arrays: %+v", resp.GetRoom()) } snapshot, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID}, RoomId: roomID, ViewerUserId: 1004, }) if err != nil { t.Fatalf("snapshot after lite join failed: %v", err) } if got, want := resp.GetRoom().GetOnlineCount(), int32(len(snapshot.GetRoom().GetOnlineUsers())); got != want || want == 0 { t.Fatalf("lite response must keep accurate online_count without mutating stored snapshot: got=%d want=%d snapshot=%+v", got, want, snapshot.GetRoom()) } } 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 (w *rocketTestWallet) DebitRobotGift(_ context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) { if len(w.debits) == 0 { return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-default", HeatValue: 1, CoinSpent: 1, ChargeAmount: 1, ChargeAssetType: "ROBOT_COIN"}, nil } next := w.debits[0] w.debits = w.debits[1:] return next, 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}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) 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, TargetHostScopes: []*roomv1.SendGiftTargetHostScope{ {TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001}, {TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002}, }, }) 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 resp.GetCoinBalanceAfter() != 70 { t.Fatalf("multi-target gift must return latest sender balance: %d", resp.GetCoinBalanceAfter()) } if firstUser := onlineUserByID(resp.GetRoom(), firstTargetID); firstUser == nil || firstUser.GetGiftValue() != 11 { t.Fatalf("first target must receive only its own heat value: %+v", firstUser) } if secondUser := onlineUserByID(resp.GetRoom(), secondTargetID); secondUser == nil || secondUser.GetGiftValue() != 19 { t.Fatalf("second target must receive only its own heat value: %+v", secondUser) } 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()) } if !wallet.lastBatch.GetTargets()[0].GetTargetIsHost() || wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[0].GetTargetAgencyOwnerUserId() != 30001 || !wallet.lastBatch.GetTargets()[1].GetTargetIsHost() || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8802 || wallet.lastBatch.GetTargets()[1].GetTargetAgencyOwnerUserId() != 30002 { t.Fatalf("wallet batch target host scopes 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[firstTargetID].GetTargetGiftValue() != 11 { t.Fatalf("first target GiftSent must carry its cumulative value: %+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]) } if got[secondTargetID].GetTargetGiftValue() != 19 { t.Fatalf("second target GiftSent must carry its cumulative value: %+v", got[secondTargetID]) } } func TestSendGiftMirrorsGiftDisplayToDirectIMAndKeepsDurableFact(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 9, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ BillingReceiptId: "receipt-direct-im-gift", CoinSpent: 25, ChargeAmount: 25, HeatValue: 25, GiftTypeCode: "normal", BalanceAfter: 975, GiftIncomeBalanceAfter: 120, }}} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-gift-direct-im" ownerID := int64(701) targetID := int64(702) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) before := outboxEventCounts(t, ctx, repository) resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "gift-direct-im"), TargetType: "user", TargetUserId: targetID, GiftId: "rose", GiftCount: 1, }) if err != nil { t.Fatalf("send direct IM gift failed: %v", err) } if resp.GetCoinBalanceAfter() != 975 || resp.GetGiftIncomeBalanceAfter() != 120 { t.Fatalf("gift response must carry balance snapshots: %+v", resp) } after := outboxEventCounts(t, ctx, repository) if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 1 { t.Fatalf("RoomGiftSent durable fact must stay in room_outbox, delta=%d counts=%+v", delta, after) } for _, eventType := range []string{"RoomHeatChanged", "RoomRankChanged"} { if delta := after[eventType] - before[eventType]; delta != 0 { t.Fatalf("%s must not write room_outbox after directIM split, delta=%d counts=%+v", eventType, delta, after) } } waitForRoomDirectIMCounts(t, directIM, map[string]int{ "RoomGiftSent": 1, "RoomHeatChanged": 1, "RoomRankChanged": 1, }) } func TestBatchSendGiftPublishesSingleBatchDisplayAndKeepsDurableFacts(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-batch-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"}, {BillingReceiptId: "receipt-batch-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950, GiftName: "Clover", GiftAnimationUrl: "https://cdn.example/clover.svga"}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-gift-batch-direct-im" ownerID := int64(801) firstTargetID := int64(802) secondTargetID := int64(803) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, firstTargetID) joinRocketRoom(t, ctx, svc, roomID, secondTargetID) before := outboxEventCounts(t, ctx, repository) resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "gift-batch-direct-im"), TargetType: "user", TargetUserIds: []int64{firstTargetID, secondTargetID}, GiftId: "clover", GiftCount: 1, }) if err != nil { t.Fatalf("batch send gift failed: %v", err) } if resp.GetCoinBalanceAfter() != 950 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 { t.Fatalf("batch response must include final balance and batch display: %+v", resp) } if resp.GetBatchDisplay().GetEventId() == "" || resp.GetBatchDisplay().GetTotalGiftValue() != 50 || resp.GetBatchDisplay().GetTargetUserIds()[0] != firstTargetID { t.Fatalf("batch display snapshot mismatch: %+v", resp.GetBatchDisplay()) } after := outboxEventCounts(t, ctx, repository) if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 { t.Fatalf("batch send must keep one durable RoomGiftSent per target, delta=%d counts=%+v", delta, after) } if delta := after["RoomGiftBatchSent"] - before["RoomGiftBatchSent"]; delta != 0 { t.Fatalf("RoomGiftBatchSent is direct display only and must not enter durable outbox, delta=%d counts=%+v", delta, after) } waitForRoomDirectIMCounts(t, directIM, map[string]int{ "RoomGiftBatchSent": 1, "RoomHeatChanged": 1, "RoomRankChanged": 1, }) counts := directIM.counts() if counts["RoomGiftSent"] != 0 { t.Fatalf("batch display must suppress per-target GiftSent direct IM: %+v", counts) } } func TestBatchSendGiftSkipsTargetsNoLongerInRoom(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-online-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980}, {BillingReceiptId: "receipt-online-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 31, GiftTypeCode: "normal", BalanceAfter: 950}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-batch-skip-offline" ownerID := int64(8101) firstTargetID := int64(8102) offlineTargetID := int64(8103) secondTargetID := int64(8104) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, firstTargetID) joinRocketRoom(t, ctx, svc, roomID, offlineTargetID) joinRocketRoom(t, ctx, svc, roomID, secondTargetID) if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlineTargetID, "batch-skip-offline-leave")}); err != nil { t.Fatalf("offline target leave failed: %v", err) } before := outboxEventCounts(t, ctx, repository) resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-skip-offline"), TargetType: "user", TargetUserIds: []int64{firstTargetID, offlineTargetID, secondTargetID}, GiftId: "clover", GiftCount: 1, TargetHostScopes: []*roomv1.SendGiftTargetHostScope{ {TargetUserId: firstTargetID, TargetIsHost: true, TargetHostRegionId: 8801, TargetAgencyOwnerUserId: 30001}, {TargetUserId: offlineTargetID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002}, {TargetUserId: secondTargetID, TargetIsHost: true, TargetHostRegionId: 8803, TargetAgencyOwnerUserId: 30003}, }, TargetDisplayProfiles: []*roomv1.SendGiftDisplayProfile{ {UserId: firstTargetID, Username: "First Online"}, {UserId: offlineTargetID, Username: "Offline"}, {UserId: secondTargetID, Username: "Second Online"}, }, }) if err != nil { t.Fatalf("batch gift with skipped target failed: %v", err) } if wallet.lastBatch == nil || len(wallet.lastBatch.GetTargets()) != 2 { t.Fatalf("batch wallet must only include online targets: %+v", wallet.lastBatch) } if wallet.lastBatch.GetTargets()[0].GetTargetUserId() != firstTargetID || wallet.lastBatch.GetTargets()[1].GetTargetUserId() != secondTargetID { t.Fatalf("batch wallet targets mismatch after filtering: %+v", wallet.lastBatch.GetTargets()) } if wallet.lastBatch.GetTargets()[0].GetTargetHostRegionId() != 8801 || wallet.lastBatch.GetTargets()[1].GetTargetHostRegionId() != 8803 { t.Fatalf("batch wallet host scopes must be filtered with targets: %+v", wallet.lastBatch.GetTargets()) } if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) || len(display.GetTargets()) != 2 { t.Fatalf("batch display must expose actual delivered targets: %+v", display) } after := outboxEventCounts(t, ctx, repository) if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 2 { t.Fatalf("filtered batch send must keep one durable RoomGiftSent per delivered target, delta=%d counts=%+v", delta, after) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1}) batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM) if len(batchEvents) != 1 || !slices.Equal(batchEvents[0].GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) || len(batchEvents[0].GetTargets()) != 2 { t.Fatalf("direct IM batch event must only include delivered targets: %+v", batchEvents) } record, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-skip-offline").GetCommandId()) if err != nil { t.Fatalf("get batch gift command log failed: %v", err) } if !exists { t.Fatalf("batch gift command log must be persisted") } decoded, err := command.Deserialize(command.SendGift{}.Type(), record.Payload) if err != nil { t.Fatalf("decode batch gift command log failed: %v", err) } giftCmd := decoded.(*command.SendGift) if !slices.Equal(giftCmd.TargetUserIDs, []int64{firstTargetID, secondTargetID}) || !slices.Equal(giftCmd.RequestedTargetUserIDs, []int64{firstTargetID, offlineTargetID, secondTargetID}) { t.Fatalf("command log must keep delivered and requested targets separately: %+v", giftCmd) } if _, exists := giftCmd.TargetGiftValues[offlineTargetID]; exists { t.Fatalf("command log must not write gift value for skipped target: %+v", giftCmd.TargetGiftValues) } } func TestBatchSendGiftSkipsOfflineTargetAndFallsBackToSingleDebit(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 30, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-single-online", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-batch-skip-to-single" ownerID := int64(8201) onlineTargetID := int64(8202) offlineTargetID := int64(8203) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, onlineTargetID) resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-skip-to-single"), TargetType: "user", TargetUserIds: []int64{onlineTargetID, offlineTargetID}, GiftId: "clover", GiftCount: 1, }) if err != nil { t.Fatalf("batch gift filtered to one target failed: %v", err) } if wallet.lastBatch != nil || wallet.lastDebit == nil || wallet.lastDebit.GetTargetUserId() != onlineTargetID { t.Fatalf("filtered single target must use single wallet debit: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch) } if wallet.lastDebit.GetCommandId() != rocketMeta(roomID, ownerID, "batch-skip-to-single").GetCommandId() { t.Fatalf("filtered single target must preserve root command id for wallet idempotency: %+v", wallet.lastDebit) } if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{onlineTargetID}) || len(display.GetTargets()) != 1 { t.Fatalf("batch response must report the single delivered target: %+v", display) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1}) } func TestBatchSendGiftIdempotencyUsesRequestedTargetsAfterPrimarySkipped(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 35, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-idempotent-online", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-batch-skip-primary-idempotency" ownerID := int64(8231) offlinePrimaryID := int64(8232) onlineTargetID := int64(8233) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, offlinePrimaryID) joinRocketRoom(t, ctx, svc, roomID, onlineTargetID) if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlinePrimaryID, "batch-skip-primary-leave")}); err != nil { t.Fatalf("primary target leave failed: %v", err) } req := &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-skip-primary"), TargetType: "user", TargetUserIds: []int64{offlinePrimaryID, onlineTargetID}, TargetUserId: offlinePrimaryID, TargetIsHost: true, TargetHostRegionId: 8802, TargetAgencyOwnerUserId: 30002, GiftId: "clover", GiftCount: 1, } resp, err := svc.SendGiftBatch(ctx, req) if err != nil { t.Fatalf("first batch gift with skipped primary failed: %v", err) } if display := resp.GetBatchDisplay(); display == nil || !slices.Equal(display.GetTargetUserIds(), []int64{onlineTargetID}) { t.Fatalf("first response must report delivered online target: %+v", display) } if wallet.lastDebit == nil || wallet.lastDebit.GetTargetUserId() != onlineTargetID || len(wallet.debitRequests) != 1 { t.Fatalf("first send must debit only delivered target once: single=%+v requests=%+v", wallet.lastDebit, wallet.debitRequests) } if _, err := svc.SendGiftBatch(ctx, req); err != nil { t.Fatalf("idempotent retry must compare requested targets, not filtered payload: %v", err) } if len(wallet.debitRequests) != 1 { t.Fatalf("idempotent retry must not call wallet again: requests=%+v", wallet.debitRequests) } } func TestBatchSendGiftRejectsOriginalSingleTarget(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 1, 45, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-single-batch", CoinSpent: 20, ChargeAmount: 20, HeatValue: 21, GiftTypeCode: "normal", BalanceAfter: 980}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-batch-single-online" ownerID := int64(8251) targetID := int64(8252) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) before := outboxEventCounts(t, ctx, repository) _, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-single-online"), TargetType: "user", TargetUserIds: []int64{targetID}, GiftId: "clover", GiftCount: 1, }) if err == nil || xerr.CodeOf(err) != xerr.InvalidArgument { t.Fatalf("single-target batch gift must be rejected before wallet debit, err=%v", err) } if wallet.lastBatch != nil || wallet.lastDebit != nil { t.Fatalf("single-target batch must not call wallet: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch) } after := outboxEventCounts(t, ctx, repository) if after["RoomGiftSent"] != before["RoomGiftSent"] || after["RoomGiftBatchSent"] != before["RoomGiftBatchSent"] { t.Fatalf("single-target batch must not write gift outbox: before=%+v after=%+v", before, after) } if _, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-single-online").GetCommandId()); err != nil || exists { t.Fatalf("single-target batch must not write command log: exists=%t err=%v", exists, err) } } func TestBatchSendGiftFailsWhenAllTargetsLeftRoom(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 2, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{} svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher()) roomID := "room-batch-all-targets-left" ownerID := int64(8301) firstTargetID := int64(8302) secondTargetID := int64(8303) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, firstTargetID) joinRocketRoom(t, ctx, svc, roomID, secondTargetID) if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, firstTargetID, "batch-all-left-first")}); err != nil { t.Fatalf("first target leave failed: %v", err) } if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, secondTargetID, "batch-all-left-second")}); err != nil { t.Fatalf("second target leave failed: %v", err) } before := outboxEventCounts(t, ctx, repository) _, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-all-left"), TargetType: "user", TargetUserIds: []int64{firstTargetID, secondTargetID}, GiftId: "clover", GiftCount: 1, }) if err == nil || xerr.CodeOf(err) != xerr.NotFound { t.Fatalf("batch gift with no online targets must fail with NOT_FOUND, err=%v", err) } if wallet.lastDebit != nil || wallet.lastBatch != nil { t.Fatalf("failed batch gift must not call wallet: single=%+v batch=%+v", wallet.lastDebit, wallet.lastBatch) } after := outboxEventCounts(t, ctx, repository) if after["RoomGiftSent"] != before["RoomGiftSent"] || after["RoomGiftBatchSent"] != before["RoomGiftBatchSent"] { t.Fatalf("failed batch gift must not write gift outbox: before=%+v after=%+v", before, after) } if _, exists, err := repository.GetCommand(appcode.WithContext(ctx, appcode.Default), roomID, rocketMeta(roomID, ownerID, "batch-all-left").GetCommandId()); err != nil || exists { t.Fatalf("failed batch gift must not write command log: exists=%t err=%v", exists, err) } } func TestBatchLuckyGiftSkipsOfflineTargetsBeforeDraw(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 2, 30, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-lucky-online-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940}, {BillingReceiptId: "receipt-lucky-online-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900}, }} luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()} directIM := newRecordingRoomDirectIMPublisher() svc := roomservice.New(roomservice.Config{ NodeID: "node-batch-lucky-skip-offline-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, Clock: now, RoomDisplayPublisher: directIM, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift) roomID := "room-batch-lucky-skip-offline" ownerID := int64(8401) firstTargetID := int64(8402) offlineTargetID := int64(8403) secondTargetID := int64(8404) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, firstTargetID) joinRocketRoom(t, ctx, svc, roomID, offlineTargetID) joinRocketRoom(t, ctx, svc, roomID, secondTargetID) if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, offlineTargetID, "batch-lucky-skip-offline-leave")}); err != nil { t.Fatalf("offline target leave failed: %v", err) } resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-lucky-skip-offline"), TargetType: "user", TargetUserIds: []int64{firstTargetID, offlineTargetID, secondTargetID}, GiftId: "super-lucky-clover", GiftCount: 1, PoolId: "super_lucky", }) if err != nil { t.Fatalf("batch lucky gift with skipped target failed: %v", err) } if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 { t.Fatalf("batch lucky must draw only online targets: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls) } if len(resp.GetLuckyGifts()) != 2 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 { t.Fatalf("batch lucky response must only include delivered targets: %+v", resp) } if !slices.Equal(resp.GetBatchDisplay().GetTargetUserIds(), []int64{firstTargetID, secondTargetID}) { t.Fatalf("batch lucky display target ids mismatch: %+v", resp.GetBatchDisplay()) } } func TestSendGiftDisplayModeDoesNotTriggerBatchDisplay(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 10, 30, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-normal-multi-1", CoinSpent: 20, ChargeAmount: 20, HeatValue: 20, GiftTypeCode: "normal", BalanceAfter: 980}, {BillingReceiptId: "receipt-normal-multi-2", CoinSpent: 30, ChargeAmount: 30, HeatValue: 30, GiftTypeCode: "normal", BalanceAfter: 950}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) roomID := "room-gift-normal-multi-display-mode" ownerID := int64(811) firstTargetID := int64(812) secondTargetID := int64(813) 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: rocketMeta(roomID, ownerID, "gift-normal-multi-display-mode"), TargetType: "user", TargetUserIds: []int64{firstTargetID, secondTargetID}, GiftId: "clover", GiftCount: 1, DisplayMode: "batch", }) if err != nil { t.Fatalf("normal multi-target send gift failed: %v", err) } if resp.GetBatchDisplay() != nil { t.Fatalf("plain SendGift must not create batch display: %+v", resp.GetBatchDisplay()) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftSent": 2}) } func TestSendGiftKeepsStrictTargetPresenceValidation(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 7, 5, 3, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{} svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, newRecordingRoomDirectIMPublisher()) roomID := "room-normal-gift-strict-presence" ownerID := int64(8501) onlineTargetID := int64(8502) offlineTargetID := int64(8503) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, onlineTargetID) _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "normal-gift-strict-presence"), TargetType: "user", TargetUserIds: []int64{onlineTargetID, offlineTargetID}, GiftId: "clover", GiftCount: 1, }) if xerr.CodeOf(err) != xerr.NotFound { t.Fatalf("plain SendGift must keep strict target presence validation, err=%v", err) } if wallet.lastDebit != nil || wallet.lastBatch != nil || len(wallet.debitRequests) != 0 { t.Fatalf("plain SendGift strict validation must run before wallet debit: single=%+v batch=%+v requests=%+v", wallet.lastDebit, wallet.lastBatch, wallet.debitRequests) } } func TestBatchLuckyGiftUsesSingleActivityBatchDrawAndDebitCoinBalance(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 11, 0, 0, 0, time.UTC)} debits := make([]*walletv1.DebitGiftResponse, 0, 10) for index := 0; index < 10; index++ { debits = append(debits, &walletv1.DebitGiftResponse{ BillingReceiptId: fmt.Sprintf("receipt-lucky-batch-%d", index), CoinSpent: 10, ChargeAmount: 10, HeatValue: 10, GiftTypeCode: "super_lucky", BalanceAfter: int64(990 - index*10), }) } wallet := &rocketTestWallet{debits: debits} luckyGift := &batchLuckyGiftTestClient{drawDelay: 20 * time.Millisecond, createdAtBase: now.Now().UnixMilli()} directIM := newRecordingRoomDirectIMPublisher() svc := roomservice.New(roomservice.Config{ NodeID: "node-batch-lucky-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, RoomDisplayPublisher: directIM, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift) roomID := "room-batch-lucky" ownerID := int64(901) targetUserIDs := make([]int64, 0, 10) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) for offset := int64(0); offset < 10; offset++ { userID := int64(902) + offset targetUserIDs = append(targetUserIDs, userID) joinRocketRoom(t, ctx, svc, roomID, userID) } resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-lucky"), TargetType: "user", TargetUserIds: targetUserIDs, GiftId: "super-lucky-clover", GiftCount: 1, PoolId: "super_lucky", }) if err != nil { t.Fatalf("batch lucky gift failed: %v", err) } checks, singleDraws, batchDraws, batchCalls := luckyGift.stats() if checks != 1 || singleDraws != 0 || batchDraws != 10 || batchCalls != 1 { t.Fatalf("batch lucky must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls) } if len(wallet.balanceRequests) != 0 { t.Fatalf("batch lucky must not query final sender coin balance: %+v", wallet.balanceRequests) } if resp.GetCoinBalanceAfter() != 900 || resp.GetLuckyGift().GetCoinBalanceAfter() != 0 { t.Fatalf("batch lucky HTTP balance must stay at debit result and lucky balance must stay empty: %+v", resp) } if len(resp.GetLuckyGifts()) != 10 || resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 10 { t.Fatalf("batch lucky response must keep per-target lucky details and batch display: %+v", resp) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1}) } func TestBatchLuckyGiftUpdatesEachMicGiftValueAndBatchDisplay(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-batch-lucky-seat-1", CoinSpent: 60, ChargeAmount: 60, HeatValue: 60, GiftTypeCode: "super_lucky", BalanceAfter: 940}, {BillingReceiptId: "receipt-batch-lucky-seat-2", CoinSpent: 40, ChargeAmount: 40, HeatValue: 40, GiftTypeCode: "super_lucky", BalanceAfter: 900}, }} luckyGift := &batchLuckyGiftTestClient{createdAtBase: now.Now().UnixMilli()} directIM := newRecordingRoomDirectIMPublisher() svc := roomservice.New(roomservice.Config{ NodeID: "node-batch-lucky-mic-value-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, Clock: now, RoomDisplayPublisher: directIM, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift) roomID := "room-batch-lucky-mic-value" ownerID := int64(931) firstTargetID := int64(932) secondTargetID := int64(933) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, firstTargetID) joinRocketRoom(t, ctx, svc, roomID, secondTargetID) if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, firstTargetID, "batch-lucky-first-mic-up"), SeatNo: 2, }); err != nil { t.Fatalf("first target mic up failed: %v", err) } if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, secondTargetID, "batch-lucky-second-mic-up"), SeatNo: 3, }); err != nil { t.Fatalf("second target mic up failed: %v", err) } resp, err := svc.SendGiftBatch(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "batch-lucky-mic-value"), TargetType: "user", TargetUserIds: []int64{firstTargetID, secondTargetID}, GiftId: "super-lucky-seat-gift", GiftCount: 1, PoolId: "super_lucky", }) if err != nil { t.Fatalf("batch lucky gift with mic targets failed: %v", err) } if checks, singleDraws, batchDraws, batchCalls := luckyGift.stats(); checks != 1 || singleDraws != 0 || batchDraws != 2 || batchCalls != 1 { t.Fatalf("batch lucky mic gift must check once and call one activity batch draw: checks=%d single=%d batch_draws=%d batch_calls=%d", checks, singleDraws, batchDraws, batchCalls) } if user := onlineUserByID(resp.GetRoom(), firstTargetID); user == nil || user.GetGiftValue() != 60 { t.Fatalf("first target online gift_value mismatch: %+v", user) } if user := onlineUserByID(resp.GetRoom(), secondTargetID); user == nil || user.GetGiftValue() != 40 { t.Fatalf("second target online gift_value mismatch: %+v", user) } if seat := seatByNo(resp.GetRoom(), 2); seat == nil || seat.GetUserId() != firstTargetID || seat.GetGiftValue() != 60 { t.Fatalf("first target mic seat gift_value mismatch: %+v", seat) } if seat := seatByNo(resp.GetRoom(), 3); seat == nil || seat.GetUserId() != secondTargetID || seat.GetGiftValue() != 40 { t.Fatalf("second target mic seat gift_value mismatch: %+v", seat) } if resp.GetBatchDisplay() == nil || len(resp.GetBatchDisplay().GetTargets()) != 2 || len(resp.GetLuckyGifts()) != 2 { t.Fatalf("batch lucky mic response must keep batch display and per-target lucky results: %+v", resp) } if first := resp.GetBatchDisplay().GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil { t.Fatalf("first batch display target mismatch: %+v", first) } if second := resp.GetBatchDisplay().GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil { t.Fatalf("second batch display target mismatch: %+v", second) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomGiftBatchSent": 1}) batchEvents := roomDirectIMGiftBatchSentEvents(t, directIM) if len(batchEvents) != 1 || len(batchEvents[0].GetTargets()) != 2 { t.Fatalf("batch lucky mic gift must publish one batch IM with two targets: %+v", batchEvents) } if first := batchEvents[0].GetTargets()[0]; first.GetTargetUserId() != firstTargetID || first.GetTargetGiftValue() != 60 || first.GetLuckyGift() == nil { t.Fatalf("first batch IM target mismatch: %+v", first) } if second := batchEvents[0].GetTargets()[1]; second.GetTargetUserId() != secondTargetID || second.GetTargetGiftValue() != 40 || second.GetLuckyGift() == nil { t.Fatalf("second batch IM target mismatch: %+v", second) } } 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 } func (w *rocketTestWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { w.balanceRequests = append(w.balanceRequests, req) if len(w.balanceResponses) == 0 { return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{ {AssetType: "COIN"}, }}, nil } next := w.balanceResponses[0] w.balanceResponses = w.balanceResponses[1:] return proto.Clone(next).(*walletv1.GetBalancesResponse), nil } type batchLuckyGiftTestClient struct { mu sync.Mutex checks []*activityv1.CheckLuckyGiftRequest draws []*activityv1.ExecuteLuckyGiftDrawRequest batchDraws []*activityv1.BatchExecuteLuckyGiftDrawRequest inFlight int maxInFlight int drawDelay time.Duration createdAtBase int64 } func (c *batchLuckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) { c.mu.Lock() c.checks = append(c.checks, req) c.mu.Unlock() return &activityv1.CheckLuckyGiftResponse{ Enabled: true, Reason: "enabled", GiftId: req.GetGiftId(), PoolId: req.GetPoolId(), RuleVersion: 12, ExperiencePool: "novice", }, nil } func (c *batchLuckyGiftTestClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) { c.mu.Lock() c.draws = append(c.draws, req) drawIndex := len(c.draws) c.inFlight++ if c.inFlight > c.maxInFlight { c.maxInFlight = c.inFlight } c.mu.Unlock() defer func() { c.mu.Lock() c.inFlight-- c.mu.Unlock() }() if c.drawDelay > 0 { timer := time.NewTimer(c.drawDelay) select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } } meta := req.GetLuckyGift() return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{ DrawId: fmt.Sprintf("draw-%d", meta.GetTargetUserId()), CommandId: meta.GetCommandId(), PoolId: meta.GetPoolId(), GiftId: meta.GetGiftId(), RuleVersion: 12, ExperiencePool: "novice", SelectedTierId: "tier", MultiplierPpm: 2_000_000, BaseRewardCoins: 100, EffectiveRewardCoins: 100, RewardStatus: "granted", WalletTransactionId: fmt.Sprintf("wallet-tx-%d", drawIndex), CoinBalanceAfter: int64(9000 + drawIndex), CreatedAtMs: c.createdAtBase + int64(drawIndex), }}, nil } func (c *batchLuckyGiftTestClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.BatchExecuteLuckyGiftDrawRequest) (*activityv1.BatchExecuteLuckyGiftDrawResponse, error) { c.mu.Lock() c.batchDraws = append(c.batchDraws, req) drawOffset := len(c.draws) c.inFlight++ if c.inFlight > c.maxInFlight { c.maxInFlight = c.inFlight } c.mu.Unlock() defer func() { c.mu.Lock() c.inFlight-- c.mu.Unlock() }() if c.drawDelay > 0 { timer := time.NewTimer(c.drawDelay) select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } } resp := &activityv1.BatchExecuteLuckyGiftDrawResponse{Results: make([]*activityv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))} for index, meta := range req.GetLuckyGifts() { drawIndex := drawOffset + index + 1 resp.Results = append(resp.Results, &activityv1.LuckyGiftDrawResult{ DrawId: fmt.Sprintf("draw-%d", meta.GetTargetUserId()), CommandId: meta.GetCommandId(), PoolId: meta.GetPoolId(), GiftId: meta.GetGiftId(), RuleVersion: 12, ExperiencePool: "novice", SelectedTierId: "tier", MultiplierPpm: 2_000_000, BaseRewardCoins: 100, EffectiveRewardCoins: 100, RewardStatus: "pending", CreatedAtMs: c.createdAtBase + int64(drawIndex), }) } return resp, nil } func (c *batchLuckyGiftTestClient) stats() (checks int, singleDraws int, batchDraws int, batchCalls int) { c.mu.Lock() defer c.mu.Unlock() totalBatchDraws := 0 for _, req := range c.batchDraws { totalBatchDraws += len(req.GetLuckyGifts()) } return len(c.checks), len(c.draws), totalBatchDraws, len(c.batchDraws) } 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"}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) 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) } flushRocketProgress(t, ctx, svc, now) progressEvents := roomDirectIMRocketProgressEvents(t, directIM) 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]) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1}) if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 { t.Fatalf("fill gift must keep one RoomRocketIgnited outbox for launch scheduling, 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()) } flushRocketProgress(t, ctx, svc, now) progressEvents = roomDirectIMRocketProgressEvents(t, directIM) 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 := directIM.counts()["RoomRocketIgnited"]; got != 1 { t.Fatalf("next-level partial gift must not emit another ignited event, got %d", got) } } func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 12, 30, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-progress-10", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"}, {BillingReceiptId: "receipt-progress-20", GiftPointAdded: 20, HeatValue: 20, GiftTypeCode: "normal"}, {BillingReceiptId: "receipt-progress-30", GiftPointAdded: 30, HeatValue: 30, GiftTypeCode: "normal"}, }} directIM := newRecordingRoomDirectIMPublisher() svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM) writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1_000)) roomID := "room-rocket-progress-coalesce" ownerID := int64(111) viewerID := int64(222) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, viewerID) before := outboxEventCounts(t, ctx, repository) for index := 1; index <= 3; index++ { if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, fmt.Sprintf("progress-coalesce-%d", index)), TargetType: "user", TargetUserId: viewerID, GiftId: fmt.Sprintf("gift-progress-%d", index), GiftCount: 1, }); err != nil { t.Fatalf("send coalesced progress gift %d failed: %v", index, err) } } beforeFlush := outboxEventCounts(t, ctx, repository) if delta := beforeFlush["RoomGiftSent"] - before["RoomGiftSent"]; delta != 3 { t.Fatalf("RoomGiftSent must stay durable per gift, delta=%d counts=%+v", delta, beforeFlush) } for _, eventType := range []string{"RoomHeatChanged", "RoomRankChanged"} { if delta := beforeFlush[eventType] - before[eventType]; delta != 0 { t.Fatalf("%s must move to direct IM instead of durable outbox, delta=%d counts=%+v", eventType, delta, beforeFlush) } } if delta := beforeFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 { t.Fatalf("rocket progress must not write per gift before flush, delta=%d counts=%+v", delta, beforeFlush) } flushRocketProgress(t, ctx, svc, now) afterFlush := outboxEventCounts(t, ctx, repository) if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 { t.Fatalf("rocket progress must flush to direct IM instead of durable outbox, delta=%d counts=%+v", delta, afterFlush) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketFuelChanged": 1}) } 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 stateView.GetLaunchAtMs() != 0 || stateView.GetIgnitedAtMs() != 0 || stateView.GetTop1UserId() != 0 || stateView.GetIgniterUserId() != 0 { t.Fatalf("launched rocket must not leak expired launch countdown into next charging level: %+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 TestRoomRocketTop1UsesCurrentLevelFuelContributionNotRoomGiftRank(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-old-rank", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"}, {BillingReceiptId: "receipt-current-level", GiftPointAdded: 90, HeatValue: 90, GiftTypeCode: "normal"}, }} svc := newRocketTestService(t, repository, wallet, now) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) roomID := "room-rocket-current-level-top1" ownerID := int64(501) igniterID := int64(502) createRocketRoom(t, ctx, svc, roomID, ownerID, 9004) joinRocketRoom(t, ctx, svc, roomID, igniterID) if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "old-room-rank"), TargetType: "user", TargetUserId: igniterID, GiftId: "gift-old-room-rank", GiftCount: 1, }); err != nil { t.Fatalf("seed old room gift rank failed: %v", err) } if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, ownerID, "owner-leave")}); err != nil { t.Fatalf("owner leave room failed: %v", err) } if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, igniterID, "current-level-fill"), TargetType: "user", TargetUserId: igniterID, GiftId: "gift-current-level-fill", GiftCount: 1, }); err != nil { t.Fatalf("ignite current level rocket failed: %v", err) } now.now = now.now.Add(1100 * time.Millisecond) if err := svc.SweepRoomRocketLaunchings(ctx); err != nil { t.Fatalf("sweep current-level top1 rocket failed: %v", err) } if hasRocketGrant(wallet.grants, ownerID, 2001) { t.Fatalf("left historical room GiftRank top1 must not receive current-level top1 reward: %+v", wallet.grants) } if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 3001) || !hasRocketGrant(wallet.grants, igniterID, 1001) { t.Fatalf("only current-level contributor/igniter/in-room user must receive all level rewards: %+v", wallet.grants) } info, err := svc.GetRoomRocket(ctx, &roomv1.GetRoomRocketRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID}, RoomId: roomID, ViewerUserId: igniterID, }) if err != nil { t.Fatalf("get current-level top1 rocket failed: %v", err) } rewards := info.GetRocket().GetState().GetLastRewards() if len(rewards) != 3 || countRocketRewards(rewards, ownerID, "top1") != 0 || countRocketRewards(rewards, igniterID, "top1") != 1 || countRocketRewards(rewards, igniterID, "igniter") != 1 || countRocketRewards(rewards, igniterID, "in_room") != 1 { t.Fatalf("reward view must not include historical room GiftRank top1: %+v", rewards) } } func TestRoomRocketLaunchStacksAllSpecialPoolsAndRandomInRoomReward(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 13, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-stack", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"}, }} svc := newRocketTestService(t, repository, wallet, now) config := rocketConfigForTest(100, 1_000) config.Levels[0].InRoomRewards = []roomservice.RoomRocketRewardItem{ {RewardItemID: "level-1-in-room-a", ResourceGroupID: 1001, Weight: 1, DisplayName: "In Room A"}, {RewardItemID: "level-1-in-room-b", ResourceGroupID: 1101, Weight: 1, DisplayName: "In Room B"}, } config.Levels[0].Top1Rewards = []roomservice.RoomRocketRewardItem{ {RewardItemID: "level-1-top1-a", ResourceGroupID: 2001, Weight: 1, DisplayName: "Top 1 A"}, {RewardItemID: "level-1-top1-b", ResourceGroupID: 2101, Weight: 1, DisplayName: "Top 1 B"}, } config.Levels[0].IgniterRewards = []roomservice.RoomRocketRewardItem{ {RewardItemID: "level-1-igniter-a", ResourceGroupID: 3001, Weight: 1, DisplayName: "Igniter A"}, {RewardItemID: "level-1-igniter-b", ResourceGroupID: 3101, Weight: 1, DisplayName: "Igniter B"}, } writeRocketConfig(t, ctx, repository, config) roomID := "room-rocket-stack" ownerID := int64(401) igniterID := int64(402) createRocketRoom(t, ctx, svc, roomID, ownerID, 9003) joinRocketRoom(t, ctx, svc, roomID, igniterID) if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, igniterID, "stack"), TargetType: "user", TargetUserId: ownerID, GiftId: "gift-stack", GiftCount: 1, }); err != nil { t.Fatalf("ignite stack rocket failed: %v", err) } now.now = now.now.Add(1100 * time.Millisecond) if err := svc.SweepRoomRocketLaunchings(ctx); err != nil { t.Fatalf("sweep stacked room rocket launches failed: %v", err) } 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 stacked rocket after launch failed: %v", err) } rewards := info.GetRocket().GetState().GetLastRewards() if len(rewards) != 6 { t.Fatalf("launch must keep two in-room random grants plus all top1/igniter grants: %+v", rewards) } if countRocketRewards(rewards, ownerID, "in_room") != 1 { t.Fatalf("owner must receive exactly one random in-room reward: %+v", rewards) } if countRocketRewards(rewards, igniterID, "in_room") != 1 { t.Fatalf("top1 igniter must still receive exactly one random in-room reward: %+v", rewards) } if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1001) != 1 || countRocketRewardsByRoleAndGroup(rewards, "in_room", 1101) != 1 { t.Fatalf("in-room rewards must spread across the configured pool before repeating an item: %+v", rewards) } if countRocketRewards(rewards, igniterID, "top1") != 2 || countRocketRewards(rewards, igniterID, "igniter") != 2 { t.Fatalf("top1 igniter must receive every configured special reward: %+v", rewards) } if !hasRocketGrant(wallet.grants, igniterID, 2001) || !hasRocketGrant(wallet.grants, igniterID, 2101) || !hasRocketGrant(wallet.grants, igniterID, 3001) || !hasRocketGrant(wallet.grants, igniterID, 3101) { t.Fatalf("wallet must grant every top1 and igniter resource group: %+v", wallet.grants) } } func TestRoomRocketInRoomRewardQuantityCapsWinners(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 5, 20, 14, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ {BillingReceiptId: "receipt-quantity", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"}, }} svc := newRocketTestService(t, repository, wallet, now) config := rocketConfigForTest(100, 1_000) config.Levels[0].InRoomRewards = []roomservice.RoomRocketRewardItem{ {RewardItemID: "level-1-in-room-coin", ResourceGroupID: 1001, Weight: 1, Quantity: 2, DisplayName: "Coin"}, {RewardItemID: "level-1-in-room-mic", ResourceGroupID: 1101, Weight: 1, Quantity: 3, DisplayName: "Mic Wave"}, } config.Levels[0].Top1Rewards = nil config.Levels[0].IgniterRewards = nil writeRocketConfig(t, ctx, repository, config) roomID := "room-rocket-quantity" ownerID := int64(501) igniterID := int64(502) createRocketRoom(t, ctx, svc, roomID, ownerID, 9004) for userID := igniterID; userID <= 507; userID++ { joinRocketRoom(t, ctx, svc, roomID, userID) } if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, igniterID, "quantity"), TargetType: "user", TargetUserId: ownerID, GiftId: "gift-quantity", GiftCount: 1, }); err != nil { t.Fatalf("ignite quantity rocket failed: %v", err) } now.now = now.now.Add(1100 * time.Millisecond) if err := svc.SweepRoomRocketLaunchings(ctx); err != nil { t.Fatalf("sweep quantity room rocket launches failed: %v", err) } 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 quantity rocket after launch failed: %v", err) } rewards := info.GetRocket().GetState().GetLastRewards() if countRocketRewardsByRole(rewards, "in_room") != 5 { t.Fatalf("in-room rewards must stop at configured quantity 5, got %+v", rewards) } if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1001) != 2 { t.Fatalf("coin resource group must be granted exactly 2 times: %+v", rewards) } if countRocketRewardsByRoleAndGroup(rewards, "in_room", 1101) != 3 { t.Fatalf("mic wave resource group must be granted exactly 3 times: %+v", rewards) } if len(wallet.grants) != 5 { t.Fatalf("wallet must only receive 5 in-room grant calls, got %+v", wallet.grants) } if len(rocketRewardUserIDs(rewards)) != 5 { t.Fatalf("only 5 users should have reward rows; remaining online users receive empty: %+v", rewards) } } 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 newRocketTestServiceWithDirectIM(t, repository, wallet, clock, scheduler, nil) } func newRocketTestServiceWithDirectIM(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler, directIM integration.OutboxPublisher) *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, RoomDisplayPublisher: directIM, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) } func waitForRoomDirectIMCounts(t testing.TB, publisher *recordingRoomDirectIMPublisher, want map[string]int) { t.Helper() deadline := time.NewTimer(2 * time.Second) defer deadline.Stop() for { counts := publisher.counts() matched := true for eventType, expected := range want { if counts[eventType] < expected { matched = false break } } if matched { return } select { case <-publisher.wake: case <-deadline.C: t.Fatalf("room direct IM events mismatch: want at least %+v got %+v", want, counts) } } } func flushRocketProgress(t testing.TB, ctx context.Context, svc *roomservice.Service, clock *fixedRoomRocketClock) { t.Helper() clock.now = clock.now.Add(time.Second) if err := svc.FlushRoomRocketProgress(ctx); err != nil { t.Fatalf("flush rocket progress failed: %v", err) } } 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 roomDirectIMRocketProgressEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomRocketFuelChanged { t.Helper() publisher.mu.Lock() records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...) publisher.mu.Unlock() events := make([]*roomeventsv1.RoomRocketFuelChanged, 0) for _, record := range records { if record.GetEventType() != "RoomRocketFuelChanged" { continue } var event roomeventsv1.RoomRocketFuelChanged if err := proto.Unmarshal(record.GetBody(), &event); err != nil { t.Fatalf("decode directIM rocket progress failed: %v", err) } events = append(events, &event) } return events } func roomDirectIMGiftBatchSentEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomGiftBatchSent { t.Helper() publisher.mu.Lock() records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...) publisher.mu.Unlock() events := make([]*roomeventsv1.RoomGiftBatchSent, 0) for _, record := range records { if record.GetEventType() != "RoomGiftBatchSent" { continue } var event roomeventsv1.RoomGiftBatchSent if err := proto.Unmarshal(record.GetBody(), &event); err != nil { t.Fatalf("decode directIM batch gift 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 countRocketRewards(rewards []*roomv1.RoomRocketRewardGrant, userID int64, role string) int { count := 0 for _, reward := range rewards { if reward.GetUserId() == userID && reward.GetRewardRole() == role { count++ } } return count } func countRocketRewardsByRoleAndGroup(rewards []*roomv1.RoomRocketRewardGrant, role string, groupID int64) int { count := 0 for _, reward := range rewards { if reward.GetRewardRole() == role && reward.GetResourceGroupId() == groupID { count++ } } return count } func countRocketRewardsByRole(rewards []*roomv1.RoomRocketRewardGrant, role string) int { count := 0 for _, reward := range rewards { if reward.GetRewardRole() == role { count++ } } return count } 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 }