2026-05-17 00:47:27 +08:00

1130 lines
36 KiB
Go

package voiceroomrocket
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/integration"
"chatapp3-golang/internal/model"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestProcessGiftPayloadDropsOverflowAndLaunchesOnce(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
now := time.Now()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, "GOLD", 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, "GOLD", 5)
firstPayload := `{
"trackId":"track-1",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":99,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(now) + `
}`
first, err := service.ProcessGiftPayload(ctx, firstPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(first) error = %v", err)
}
if !first.Processed || first.AcceptedEnergy != 99 || first.Launched {
t.Fatalf("first response = %+v", first)
}
secondPayload := `{
"trackId":"track-2",
"sysOrigin":"LIKEI",
"sendUserId":"1002",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"12","giftCandy":101,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(now.Add(time.Second)) + `
}`
second, err := service.ProcessGiftPayload(ctx, secondPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(second) error = %v", err)
}
if !second.Processed || second.AcceptedEnergy != 1 || !second.Launched {
t.Fatalf("second response = %+v", second)
}
var status model.VoiceRoomRocketStatus
if err := db.Where("sys_origin = ? AND room_id = ?", "LIKEI", int64(9001)).First(&status).Error; err != nil {
t.Fatalf("load status: %v", err)
}
if status.RoundNo != 1 || status.CurrentLevel != 2 || status.CurrentEnergy != 0 || status.NeedEnergy != 200 {
t.Fatalf("status = %+v, want round 1 level 2 energy 0 need 200", status)
}
var eventLog model.VoiceRoomRocketGiftEventLog
if err := db.Where("track_id = ?", "track-2").First(&eventLog).Error; err != nil {
t.Fatalf("load event log: %v", err)
}
if eventLog.RawEnergy != 101 || eventLog.AcceptedEnergy != 1 {
t.Fatalf("eventLog energy raw=%d accepted=%d, want 101/1", eventLog.RawEnergy, eventLog.AcceptedEnergy)
}
var launch model.VoiceRoomRocketLaunch
if err := db.Where("room_id = ? AND round_no = ? AND level = ?", int64(9001), 1, 1).First(&launch).Error; err != nil {
t.Fatalf("load launch: %v", err)
}
if launch.Top1UserID == nil || *launch.Top1UserID != 1001 || launch.IgniteUserID == nil || *launch.IgniteUserID != 1002 {
t.Fatalf("launch users top1=%v ignite=%v, want 1001/1002", launch.Top1UserID, launch.IgniteUserID)
}
var rewardCount int64
if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("launch_id = ?", launch.ID).Count(&rewardCount).Error; err != nil {
t.Fatalf("count reward records: %v", err)
}
if rewardCount != 2 {
t.Fatalf("reward count = %d, want 2", rewardCount)
}
duplicate, err := service.ProcessGiftPayload(ctx, secondPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(duplicate) error = %v", err)
}
if duplicate.Reason != "duplicate_event" {
t.Fatalf("duplicate response = %+v, want duplicate_event", duplicate)
}
var launchCount int64
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launches: %v", err)
}
if launchCount != 1 {
t.Fatalf("launch count = %d, want 1", launchCount)
}
}
func TestPageRoomRewardRecordsReturnsAllLaunchWinnersWithProfiles(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
gateway := newFakeRocketGateway()
gateway.profiles = map[int64]integration.UserProfile{
1001: {ID: integration.Int64Value(1001), Account: "1001026", UserNickname: "room1001026", UserAvatar: "https://avatar.example/1001.png"},
1002: {ID: integration.Int64Value(1002), Account: "1001027", UserNickname: "room1001027", UserAvatar: "https://avatar.example/1002.png"},
}
service.userProfiles = gateway
seedRocketConfig(t, db)
now := time.Now()
launch := model.VoiceRoomRocketLaunch{
ID: 1,
LaunchNo: "VRR-test-launch-1",
SysOrigin: "LIKEI",
RoomID: 9001,
DayKey: "2026-05-15",
RoundNo: 1,
Level: 3,
LaunchTime: now,
CreateTime: now,
UpdateTime: now,
}
if err := db.Create(&launch).Error; err != nil {
t.Fatalf("create launch: %v", err)
}
rewardItemID := int64(2001)
expireDays := 15
rows := []model.VoiceRoomRocketRewardRecord{
{
ID: 11,
LaunchID: launch.ID,
LaunchNo: launch.LaunchNo,
SysOrigin: "LIKEI",
RoomID: launch.RoomID,
DayKey: launch.DayKey,
RoundNo: launch.RoundNo,
Level: launch.Level,
UserID: 1001,
RewardScene: rewardSceneTop1,
RewardType: "GOLD",
RewardName: "Yumi",
RewardAmount: 5000,
GrantEventID: "grant-event-11",
GrantStatus: rewardStatusSuccess,
PopupStatus: popupStatusUnread,
CreateTime: now,
UpdateTime: now,
},
{
ID: 12,
LaunchID: launch.ID,
LaunchNo: launch.LaunchNo,
SysOrigin: "LIKEI",
RoomID: launch.RoomID,
DayKey: launch.DayKey,
RoundNo: launch.RoundNo,
Level: launch.Level,
UserID: 1002,
RewardScene: rewardSceneInRoom,
RewardType: "AVATAR_FRAME",
RewardItemID: &rewardItemID,
RewardName: "Frame",
RewardCover: "https://reward.example/frame.png",
RewardAmount: 1,
ExpireDays: &expireDays,
GrantEventID: "grant-event-12",
GrantStatus: rewardStatusSuccess,
PopupStatus: popupStatusUnread,
CreateTime: now.Add(time.Second),
UpdateTime: now.Add(time.Second),
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("create reward records: %v", err)
}
resp, err := service.PageRoomRewardRecords(ctx, AuthUser{UserID: 9999, SysOrigin: "LIKEI"}, launch.RoomID, launch.LaunchNo, 1, 20)
if err != nil {
t.Fatalf("PageRoomRewardRecords() error = %v", err)
}
if len(resp.Records) != 2 {
t.Fatalf("records len = %d, want 2: %+v", len(resp.Records), resp.Records)
}
users := map[int64]RewardRecordView{}
for _, record := range resp.Records {
users[record.UserID] = record
}
if got := users[1001]; got.Account != "1001026" || got.UserNickname != "room1001026" || got.UserAvatar == "" {
t.Fatalf("user 1001 profile = %+v", got)
}
if got := users[1002]; got.Account != "1001027" || got.UserNickname != "room1001027" || got.UserAvatar == "" {
t.Fatalf("user 1002 profile = %+v", got)
}
}
func TestDecodeGiftEventPayloadEnvelope(t *testing.T) {
payload := `{"tag":"give_gift_v3","body":"{\"trackId\":\"t1\",\"sendUserId\":\"10\",\"roomId\":\"20\",\"giftValue\":{\"actualAmount\":\"30\"}}"}`
event, err := decodeGiftEventPayload(payload, "give_gift_v3")
if err != nil {
t.Fatalf("decodeGiftEventPayload() error = %v", err)
}
if event.TrackID.String() != "t1" || event.SendUserID.Int64() != 10 || event.RoomID.Int64() != 20 || calculateRawEnergy(event) != 30 {
t.Fatalf("event = %+v", event)
}
}
func TestGiftEnergyRulesRejectUnsupportedAndAllowConfiguredGift(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
backpackPayload := `{
"trackId":"track-backpack",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":99,"type":"GOLD","giftTab":"BACKPACK"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
ignored, err := service.ProcessGiftPayload(ctx, backpackPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(backpack) error = %v", err)
}
if ignored.Processed || ignored.Reason != "unsupported_gift" {
t.Fatalf("ignored = %+v, want unsupported_gift", ignored)
}
if err := db.Model(&model.VoiceRoomRocketConfig{}).
Where("sys_origin = ?", "LIKEI").
Update("energy_rule_json", `{"giftRules":[{"giftId":"22","energy":7,"enabled":true}]}`).Error; err != nil {
t.Fatalf("update energy rule: %v", err)
}
customPayload := `{
"trackId":"track-custom",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":2,
"giftConfig":{"id":"22","giftCandy":99,"type":"MAGIC","giftTab":"MAGIC"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
accepted, err := service.ProcessGiftPayload(ctx, customPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(custom) error = %v", err)
}
if !accepted.Processed || accepted.AcceptedEnergy != 14 {
t.Fatalf("accepted = %+v, want 14 energy", accepted)
}
}
func TestGiftTabEnergyRuleDiscountsLuckyGiftOnly(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{1000, 2000, 3000, 4000, 5000, 6000})
if err := db.Model(&model.VoiceRoomRocketConfig{}).
Where("sys_origin = ?", "LIKEI").
Update("energy_rule_json", `{"giftTabRules":[{"giftTab":"LUCKY_GIFT","ratio":0.1}]}`).Error; err != nil {
t.Fatalf("update energy rule: %v", err)
}
luckyPayload := `{
"trackId":"track-lucky-ratio",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"LUCKY_GIFT"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
lucky, err := service.ProcessGiftPayload(ctx, luckyPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(lucky) error = %v", err)
}
if !lucky.Processed || lucky.AcceptedEnergy != 10 {
t.Fatalf("lucky = %+v, want 10 energy", lucky)
}
ordinaryPayload := `{
"trackId":"track-ordinary-ratio",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"12","giftCandy":100,"type":"GOLD","giftTab":"ORDINARY"},
"createTime":` + strconvFormatMillis(time.Now().Add(time.Second)) + `
}`
ordinary, err := service.ProcessGiftPayload(ctx, ordinaryPayload)
if err != nil {
t.Fatalf("ProcessGiftPayload(ordinary) error = %v", err)
}
if !ordinary.Processed || ordinary.AcceptedEnergy != 100 {
t.Fatalf("ordinary = %+v, want 100 energy", ordinary)
}
}
func TestInRoomRewardWorkerSnapshotsSelectsAndGrants(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
seedRewardConfigWithCopies(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1, 3)
roomID := int64(9001)
for _, userID := range []int64{1001, 1002, 1003, 1004} {
if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil {
t.Fatalf("sadd online: %v", err)
}
if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(time.Now().UnixMilli(), 10), time.Minute).Err(); err != nil {
t.Fatalf("set seen: %v", err)
}
}
payload := `{
"trackId":"track-launch-in-room",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed || resp.Launched || resp.LaunchNo != "" {
t.Fatalf("launch should be delayed, response = %+v", resp)
}
launched, err := service.ProcessDueLaunches(ctx, time.Now().Add(11*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches() error = %v", err)
}
if launched != 1 {
t.Fatalf("due launches = %d, want 1", launched)
}
processed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(30*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueInRoomRewards() error = %v", err)
}
if processed != 1 {
t.Fatalf("processed due = %d, want 1", processed)
}
var snapshotCount int64
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil {
t.Fatalf("count snapshot: %v", err)
}
if snapshotCount != 4 {
t.Fatalf("snapshot count = %d, want 4", snapshotCount)
}
var selectedCount int64
if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("selected = ?", true).Count(&selectedCount).Error; err != nil {
t.Fatalf("count selected: %v", err)
}
if selectedCount != 3 {
t.Fatalf("selected count = %d, want 3", selectedCount)
}
granted, err := service.ProcessPendingRewards(ctx, 20)
if err != nil {
t.Fatalf("ProcessPendingRewards() error = %v", err)
}
if granted != 5 {
t.Fatalf("granted = %d, want 5", granted)
}
var pending int64
if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("grant_status <> ?", rewardStatusSuccess).Count(&pending).Error; err != nil {
t.Fatalf("count pending: %v", err)
}
if pending != 0 {
t.Fatalf("pending rewards = %d, want 0", pending)
}
if len(gateway.goldEvents) != 5 {
t.Fatalf("gold grant count = %d, want 5", len(gateway.goldEvents))
}
}
func TestProcessPendingRewardsGrantsBadgeThroughBadgeGateway(t *testing.T) {
gateway := newFakeRocketGateway()
service, db := newTestService(t)
service.rewardGateway = gateway
ctx := context.Background()
badgeID := int64(3001)
expireDays := 14
record := model.VoiceRoomRocketRewardRecord{
ID: 8801,
LaunchID: 9901,
LaunchNo: "launch-badge",
SysOrigin: "LIKEI",
RoomID: 9001,
DayKey: "20260515",
RoundNo: 1,
Level: 1,
UserID: 1001,
RewardScene: rewardSceneTop1,
RewardConfigID: 7701,
RewardType: rewardTypeBadge,
RewardItemID: &badgeID,
RewardName: "activity badge",
RewardAmount: 1,
ExpireDays: &expireDays,
GrantEventID: "VOICE_ROOM_ROCKET:launch-badge:TOP1:1001:7701",
GrantStatus: rewardStatusPending,
PopupStatus: popupStatusUnread,
CreateTime: time.Now(),
UpdateTime: time.Now(),
}
if err := db.Create(&record).Error; err != nil {
t.Fatalf("seed reward record: %v", err)
}
granted, err := service.ProcessPendingRewards(ctx, 10)
if err != nil {
t.Fatalf("ProcessPendingRewards() error = %v", err)
}
if granted != 1 {
t.Fatalf("granted = %d, want 1", granted)
}
if len(gateway.badges) != 1 {
t.Fatalf("badge grants = %d, want 1", len(gateway.badges))
}
if gateway.badges[0] != (fakeBadgeGrant{userID: 1001, badgeID: badgeID, days: expireDays}) {
t.Fatalf("badge grant = %+v, want user 1001 badge %d days %d", gateway.badges[0], badgeID, expireDays)
}
if len(gateway.props) != 0 {
t.Fatalf("props grants = %d, want 0", len(gateway.props))
}
var latest model.VoiceRoomRocketRewardRecord
if err := db.Where("id = ?", record.ID).First(&latest).Error; err != nil {
t.Fatalf("load reward record: %v", err)
}
if latest.GrantStatus != rewardStatusSuccess {
t.Fatalf("grant status = %s, want %s", latest.GrantStatus, rewardStatusSuccess)
}
}
func TestProcessGiftPayloadDelaysLaunchWithRedis(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, _, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
payload := `{
"trackId":"track-launch-delay",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed || resp.Launched {
t.Fatalf("response = %+v, want processed delayed launch", resp)
}
var status model.VoiceRoomRocketStatus
if err := db.Where("sys_origin = ? AND room_id = ?", "LIKEI", int64(9001)).First(&status).Error; err != nil {
t.Fatalf("load status: %v", err)
}
if status.Status != statusLaunching || status.CurrentEnergy != 100 {
t.Fatalf("status = %+v, want launching full energy", status)
}
var launchCount int64
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launch before due: %v", err)
}
if launchCount != 0 {
t.Fatalf("launch count before due = %d, want 0", launchCount)
}
processed, err := service.ProcessDueLaunches(ctx, time.Now().Add(9*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches(before due) error = %v", err)
}
if processed != 0 {
t.Fatalf("launches before due = %d, want 0", processed)
}
processed, err = service.ProcessDueLaunches(ctx, time.Now().Add(11*time.Second), 10)
if err != nil {
t.Fatalf("ProcessDueLaunches(after due) error = %v", err)
}
if processed != 1 {
t.Fatalf("launches after due = %d, want 1", processed)
}
if err := db.Model(&model.VoiceRoomRocketLaunch{}).Count(&launchCount).Error; err != nil {
t.Fatalf("count launch after due: %v", err)
}
if launchCount != 1 {
t.Fatalf("launch count after due = %d, want 1", launchCount)
}
}
func TestPickInRoomRewardCanBeEmpty(t *testing.T) {
service, _ := newTestService(t)
launch := model.VoiceRoomRocketLaunch{LaunchNo: "VRR-empty"}
rewards := []model.VoiceRoomRocketRewardConfig{
{
RewardScene: rewardSceneInRoom,
RewardType: rewardTypeNone,
RewardName: "轮空",
Weight: 1,
},
}
got := service.pickInRoomRewardsForUser(launch, rewards, 1001)
if len(got) != 0 {
t.Fatalf("picked rewards = %+v, want empty", got)
}
}
func TestGrantGoldRewardUsesWalletRocketRewardOrigin(t *testing.T) {
service, _ := newTestService(t)
gateway := newFakeRocketGateway()
service.wallet = gateway
record := model.VoiceRoomRocketRewardRecord{
UserID: 1001,
SysOrigin: "LIKEI",
Level: 1,
RewardScene: rewardSceneInRoom,
RewardType: rewardTypeGold,
RewardAmount: 100,
GrantEventID: "VOICE_ROOM_ROCKET:launch-1:IN_ROOM:1001:1",
}
if err := service.grantGoldReward(context.Background(), record); err != nil {
t.Fatalf("grantGoldReward() error = %v", err)
}
walletEventID := walletGoldRewardEventID(record.GrantEventID)
cmd, ok := gateway.goldEvents[walletEventID]
if !ok {
t.Fatalf("gold wallet event was not created")
}
if len(cmd.EventID) > 50 {
t.Fatalf("wallet event id length = %d, want <= 50: %s", len(cmd.EventID), cmd.EventID)
}
if cmd.Origin != walletOriginRocketReward {
t.Fatalf("wallet origin = %q, want %q", cmd.Origin, walletOriginRocketReward)
}
if cmd.CustomizeOrigin != "" {
t.Fatalf("customize origin = %q, want empty", cmd.CustomizeOrigin)
}
if cmd.CustomizeOriginDesc != walletOriginRocketRewardDesc {
t.Fatalf("wallet origin desc = %q, want %q", cmd.CustomizeOriginDesc, walletOriginRocketRewardDesc)
}
}
func TestRewardGrantEventIDFitsWalletAssetRecord(t *testing.T) {
eventID := rewardGrantEventID("VOICE_ROOM_ROCKET_LAUNCH:20260515:9001:ROUND:1:LEVEL:6:EXTRA-LONG", rewardSceneInRoom, 1001001001001, 9009009009001)
if len(eventID) > 50 {
t.Fatalf("grant event id length = %d, want <= 50: %s", len(eventID), eventID)
}
if eventID != rewardGrantEventID("VOICE_ROOM_ROCKET_LAUNCH:20260515:9001:ROUND:1:LEVEL:6:EXTRA-LONG", rewardSceneInRoom, 1001001001001, 9009009009001) {
t.Fatalf("grant event id must be deterministic")
}
}
func TestInRoomRewardCopiesLimitSelection(t *testing.T) {
service, _ := newTestService(t)
service.cfg.VoiceRoomRocket.InRoomRewardUserLimit = 10
launch := model.VoiceRoomRocketLaunch{LaunchNo: "VRR-copies"}
audience := []roomAudienceUser{
{UserID: 1001},
{UserID: 1002},
{UserID: 1003},
{UserID: 1004},
}
rewards := []model.VoiceRoomRocketRewardConfig{
{
ID: 1,
RewardScene: rewardSceneInRoom,
RewardType: rewardTypeGold,
RewardName: "金币",
RewardAmount: 100,
RewardCopies: 2,
Weight: 1,
},
}
selected := service.selectInRoomRewardUsers(launch, audience, rewards)
if len(selected) != 2 {
t.Fatalf("selected users = %+v, want 2 users", selected)
}
picked := service.pickInRoomRewardsForUsers(launch, rewards, selected)
if len(picked) != 2 {
t.Fatalf("picked rewards = %+v, want 2 users with rewards", picked)
}
}
func TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
seedRocketConfig(t, db)
now := time.Now()
if err := db.Create(&model.VoiceRoomRocketLevelConfig{
ID: 991,
SysOrigin: "LIKEI",
Level: 1,
NeedEnergy: 100,
ShakeThresholdPercent: "95.0000",
PreviewRocketURL: "https://cdn.example.com/preview-rocket.png",
RocketIconURL: "https://cdn.example.com/rocket-icon.png",
RocketAnimationURL: "https://cdn.example.com/rocket-launch.svga",
Enabled: true,
Sort: 1,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed level config: %v", err)
}
fakeIM := &fakeRocketIM{}
service.SetIMGateway(fakeIM)
ignite := int64(1001)
launch := model.VoiceRoomRocketLaunch{
LaunchNo: "VRR-test",
SysOrigin: "LIKEI",
RoomID: 9001,
RoomAccount: "room-9001",
RoundNo: 1,
Level: 1,
IgniteUserID: &ignite,
}
if err := service.notifyLaunch(context.Background(), launch); err != nil {
t.Fatalf("notifyLaunch() error = %v", err)
}
if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "room-9001" {
t.Fatalf("im messages = %+v", fakeIM.messages)
}
body, ok := fakeIM.messages[0].body.(map[string]any)
if !ok {
t.Fatalf("im body = %#v", fakeIM.messages[0].body)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("im data = %#v", body["data"])
}
if data["previewRocketUrl"] != "https://cdn.example.com/preview-rocket.png" {
t.Fatalf("previewRocketUrl = %#v", data["previewRocketUrl"])
}
if data["rocketIconUrl"] != "https://cdn.example.com/rocket-icon.png" {
t.Fatalf("rocketIconUrl = %#v", data["rocketIconUrl"])
}
if data["rocketAnimationUrl"] != "https://cdn.example.com/rocket-launch.svga" {
t.Fatalf("rocketAnimationUrl = %#v", data["rocketAnimationUrl"])
}
count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result()
if err != nil {
t.Fatalf("xlen broadcast: %v", err)
}
if count != 1 {
t.Fatalf("broadcast stream count = %d, want 1", count)
}
}
func TestNotifyRewardRecordPublishesIMAndRedisStream(t *testing.T) {
gateway := newFakeRocketGateway()
gateway.profiles = map[int64]integration.UserProfile{
1001: {
ID: integration.Int64Value(1001),
Account: "223456",
UserAvatar: "https://cdn.example.com/avatar-1001.png",
UserNickname: "Winner",
},
}
service, _, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
fakeIM := &fakeRocketIM{}
service.SetIMGateway(fakeIM)
record := model.VoiceRoomRocketRewardRecord{
ID: 9901,
LaunchNo: "VRR-reward",
RoomID: 9001,
UserID: 1001,
Level: 2,
RewardScene: rewardSceneTop1,
RewardType: rewardTypeGold,
RewardName: "gold",
RewardAmount: 100,
}
if err := service.notifyRewardRecord(context.Background(), record); err != nil {
t.Fatalf("notifyRewardRecord() error = %v", err)
}
if len(fakeIM.messages) != 1 || fakeIM.messages[0].groupID != "9001" {
t.Fatalf("im messages = %+v", fakeIM.messages)
}
body, ok := fakeIM.messages[0].body.(map[string]any)
if !ok {
t.Fatalf("im body = %#v", fakeIM.messages[0].body)
}
if body["type"] != imTypeRewardUser {
t.Fatalf("im type = %#v, want %s", body["type"], imTypeRewardUser)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("im data = %#v", body["data"])
}
if data["userId"] != "1001" || data["rewardRecordId"] != "9901" {
t.Fatalf("im data = %#v", data)
}
if data["userNickname"] != "Winner" || data["account"] != "223456" || data["userAvatar"] != "https://cdn.example.com/avatar-1001.png" {
t.Fatalf("im user profile = %#v", data)
}
count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result()
if err != nil {
t.Fatalf("xlen broadcast: %v", err)
}
if count != 1 {
t.Fatalf("broadcast stream count = %d, want 1", count)
}
}
func TestStatusUpdatePublishesRegionIM(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, _, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
service.regionResolver = fakeRocketRegionResolver{regions: map[string]string{"LIKEI|SA": "AR"}}
regionIM := &fakeRocketRegionBroadcaster{}
service.SetRegionBroadcaster(regionIM)
ctx := context.Background()
seedRocketConfig(t, db)
seedLevelConfigs(t, db, []int64{100, 200, 300, 400, 500, 600})
payload := `{
"trackId":"track-region-status",
"sysOrigin":"LIKEI",
"sendUserId":"1001",
"roomId":"9001",
"quantity":1,
"giftConfig":{"id":"11","giftCandy":10,"type":"GOLD","giftTab":"NORMAL"},
"createTime":` + strconvFormatMillis(time.Now()) + `
}`
resp, err := service.ProcessGiftPayload(ctx, payload)
if err != nil {
t.Fatalf("ProcessGiftPayload() error = %v", err)
}
if !resp.Processed {
t.Fatalf("response = %+v, want processed", resp)
}
if len(regionIM.messages) != 1 {
t.Fatalf("region messages = %+v, want one", regionIM.messages)
}
if regionIM.messages[0].regionCode != "AR" || regionIM.messages[0].messageType != imTypeStatusUpdate {
t.Fatalf("region message = %+v", regionIM.messages[0])
}
}
func TestDayKeyUsesUTCPlus3BusinessDay(t *testing.T) {
location := resolveLocation("Asia/Riyadh")
before := time.Date(2026, 5, 13, 20, 59, 59, 0, time.UTC)
after := time.Date(2026, 5, 13, 21, 0, 0, 0, time.UTC)
if got := dayKey(before, location); got != "20260513" {
t.Fatalf("dayKey(before) = %s, want 20260513", got)
}
if got := dayKey(after, location); got != "20260514" {
t.Fatalf("dayKey(after) = %s, want 20260514", got)
}
}
func TestSaveAdminConfigForcesRiyadhTimezoneAndReturnsEnergyRule(t *testing.T) {
service, _ := newTestService(t)
ctx := context.Background()
resp, err := service.SaveAdminConfig(ctx, SaveConfigRequest{
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "UTC",
MaxLevel: 6,
RankingTopLimit: 50,
RewardRecordKeepDays: 35,
BroadcastDurationSeconds: 7,
InRoomRewardDelaySeconds: 10,
EnergyRuleJSON: json.RawMessage(`{"allowGiftIds":[11]}`),
RuleText: json.RawMessage(`{"title":"rocket"}`),
})
if err != nil {
t.Fatalf("SaveAdminConfig() error = %v", err)
}
if resp.Timezone != defaultTimezone {
t.Fatalf("timezone = %s, want %s", resp.Timezone, defaultTimezone)
}
if string(resp.EnergyRuleJSON) != `{"allowGiftIds":[11]}` {
t.Fatalf("energyRuleJson = %s", string(resp.EnergyRuleJSON))
}
}
func TestSaveRewardConfigsReplacesAndClearsSelectedLevel(t *testing.T) {
service, db := newTestService(t)
ctx := context.Background()
seedRewardConfig(t, db, 1, rewardSceneTop1, rewardTypeGold, 10)
seedRewardConfig(t, db, 1, rewardSceneIgnite, rewardTypeGold, 5)
seedRewardConfig(t, db, 2, rewardSceneTop1, rewardTypeGold, 20)
saved, err := service.SaveRewardConfigs(ctx, SaveRewardConfigsRequest{
SysOrigin: "LIKEI",
Level: 1,
Rewards: []RewardConfigPayload{
{
Level: 1,
RewardScene: rewardSceneInRoom,
RewardType: rewardTypeGold,
RewardName: "new gold",
RewardAmount: 99,
RewardCopies: 10,
Enabled: true,
Sort: 1,
},
},
})
if err != nil {
t.Fatalf("SaveRewardConfigs(replace) error = %v", err)
}
if len(saved) != 1 || saved[0].RewardAmount != 99 || saved[0].RewardCopies != 10 {
t.Fatalf("saved rewards = %+v, want one amount 99 and 10 copies", saved)
}
var level1Count int64
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 1).
Count(&level1Count).Error; err != nil {
t.Fatalf("count level 1 rewards: %v", err)
}
if level1Count != 1 {
t.Fatalf("level 1 reward count = %d, want 1", level1Count)
}
var level2Count int64
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 2).
Count(&level2Count).Error; err != nil {
t.Fatalf("count level 2 rewards: %v", err)
}
if level2Count != 1 {
t.Fatalf("level 2 reward count = %d, want 1", level2Count)
}
saved, err = service.SaveRewardConfigs(ctx, SaveRewardConfigsRequest{
SysOrigin: "LIKEI",
Level: 1,
Rewards: []RewardConfigPayload{},
})
if err != nil {
t.Fatalf("SaveRewardConfigs(clear) error = %v", err)
}
if len(saved) != 0 {
t.Fatalf("saved rewards after clear = %+v, want empty", saved)
}
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 1).
Count(&level1Count).Error; err != nil {
t.Fatalf("count cleared level 1 rewards: %v", err)
}
if level1Count != 0 {
t.Fatalf("level 1 reward count after clear = %d, want 0", level1Count)
}
if err := db.Model(&model.VoiceRoomRocketRewardConfig{}).
Where("sys_origin = ? AND level = ?", "LIKEI", 2).
Count(&level2Count).Error; err != nil {
t.Fatalf("count retained level 2 rewards: %v", err)
}
if level2Count != 1 {
t.Fatalf("level 2 reward count after clear = %d, want 1", level2Count)
}
}
func newTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.VoiceRoomRocketConfig{},
&model.VoiceRoomRocketLevelConfig{},
&model.VoiceRoomRocketRewardConfig{},
&model.VoiceRoomRocketStatus{},
&model.VoiceRoomRocketLevelRound{},
&model.VoiceRoomRocketContribution{},
&model.VoiceRoomRocketGiftEventLog{},
&model.VoiceRoomRocketLaunch{},
&model.VoiceRoomRocketRewardRecord{},
&model.VoiceRoomRocketAudienceSnapshot{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
service := NewService(config.Config{
VoiceRoomRocket: config.VoiceRoomRocketConfig{
DefaultSysOrigin: "LIKEI",
Timezone: "Asia/Riyadh",
},
}, db, nil, nil)
return service, db
}
func newTestServiceWithRedis(t *testing.T, gateway *fakeRocketGateway) (*Service, *gorm.DB, *redis.Client, *miniredis.Miniredis) {
t.Helper()
service, db := newTestService(t)
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
service.repo.Redis = rdb
service.gateways = gateway
service.roomProfiles = gateway
service.userProfiles = gateway
service.wallet = gateway
service.rewardGateway = gateway
service.cfg.VoiceRoomRocket.InRoomRewardUserLimit = 3
service.cfg.VoiceRoomRocket.InRoomRewardDueZSetKey = "voice_room:rocket:in_room_due"
return service, db, rdb, mr
}
type fakeRocketGateway struct {
goldEvents map[string]integration.GoldReceiptCommand
props []integration.GivePropsBackpackRequest
badges []fakeBadgeGrant
profiles map[int64]integration.UserProfile
}
type fakeBadgeGrant struct {
userID int64
badgeID int64
days int
}
type fakeRocketIM struct {
messages []fakeRocketIMMessage
}
type fakeRocketIMMessage struct {
groupID string
body any
}
type fakeRocketRegionResolver struct {
regions map[string]string
}
type fakeRocketRegionBroadcaster struct {
messages []fakeRocketRegionMessage
}
type fakeRocketRegionMessage struct {
sysOrigin string
regionCode string
messageType string
body any
}
func newFakeRocketGateway() *fakeRocketGateway {
return &fakeRocketGateway{goldEvents: map[string]integration.GoldReceiptCommand{}}
}
func (g *fakeRocketGateway) MapRoomProfiles(ctx context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) {
result := make(map[int64]integration.RoomProfile, len(roomIDs))
for _, roomID := range roomIDs {
result[roomID] = integration.RoomProfile{ID: integration.Int64Value(roomID), RoomAccount: strconv.FormatInt(roomID, 10), RoomName: "room"}
}
return result, nil
}
func (g *fakeRocketGateway) GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error) {
if profile, ok := g.profiles[userID]; ok {
if int64(profile.ID) == 0 {
profile.ID = integration.Int64Value(userID)
}
return profile, nil
}
return integration.UserProfile{ID: integration.Int64Value(userID), Account: strconv.FormatInt(userID, 10), UserNickname: "user", CountryCode: "SA"}, nil
}
func (g *fakeRocketGateway) ChangeGoldBalance(ctx context.Context, cmd integration.GoldReceiptCommand) error {
g.goldEvents[cmd.EventID] = cmd
return nil
}
func (g *fakeRocketGateway) ExistsGoldEvent(ctx context.Context, eventID string) (bool, error) {
_, ok := g.goldEvents[eventID]
return ok, nil
}
func (g *fakeRocketGateway) GivePropsBackpack(ctx context.Context, req integration.GivePropsBackpackRequest) error {
g.props = append(g.props, req)
return nil
}
func (g *fakeRocketGateway) GetNobleVIPAbility(ctx context.Context, sourceID int64) (integration.PropsNobleVIPAbility, error) {
return integration.PropsNobleVIPAbility{}, nil
}
func (g *fakeRocketGateway) SwitchUseProps(ctx context.Context, userID int64, propsID int64) error {
return nil
}
func (g *fakeRocketGateway) ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error {
g.badges = append(g.badges, fakeBadgeGrant{userID: userID, badgeID: badgeID, days: days})
return nil
}
func (g *fakeRocketGateway) RemoveUserProfileCacheAll(ctx context.Context, userID int64) error {
return nil
}
func (g *fakeRocketIM) SendCustomGroupMessage(ctx context.Context, groupID string, body any) error {
g.messages = append(g.messages, fakeRocketIMMessage{groupID: groupID, body: body})
return nil
}
func (r fakeRocketRegionResolver) ResolveRegionCodeByCountryCode(ctx context.Context, sysOrigin string, countryCode string) (string, error) {
return r.regions[sysOrigin+"|"+countryCode], nil
}
func (b *fakeRocketRegionBroadcaster) SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error) {
messageType := ""
if payload, ok := body.(map[string]any); ok {
messageType, _ = payload["type"].(string)
}
b.messages = append(b.messages, fakeRocketRegionMessage{
sysOrigin: sysOrigin,
regionCode: regionCode,
messageType: messageType,
body: body,
})
return "@region-" + strings.ToLower(regionCode), nil
}
func seedRocketConfig(t *testing.T, db *gorm.DB) {
t.Helper()
now := time.Now()
row := model.VoiceRoomRocketConfig{
ID: 1,
SysOrigin: "LIKEI",
Enabled: true,
Timezone: "Asia/Riyadh",
MaxLevel: 6,
RankingTopLimit: 100,
RewardRecordKeepDays: 35,
BroadcastDurationSeconds: 7,
InRoomRewardDelaySeconds: 10,
CreateTime: now,
UpdateTime: now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed config: %v", err)
}
}
func seedLevelConfigs(t *testing.T, db *gorm.DB, energy []int64) {
t.Helper()
now := time.Now()
for index, need := range energy {
row := model.VoiceRoomRocketLevelConfig{
ID: int64(100 + index),
SysOrigin: "LIKEI",
Level: index + 1,
NeedEnergy: need,
ShakeThresholdPercent: "95.0000",
Enabled: true,
Sort: index + 1,
CreateTime: now,
UpdateTime: now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed level: %v", err)
}
}
}
func seedRewardConfig(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64) {
seedRewardConfigWithCopies(t, db, level, scene, rewardType, amount, 1)
}
func seedRewardConfigWithCopies(t *testing.T, db *gorm.DB, level int, scene string, rewardType string, amount int64, copies int) {
t.Helper()
now := time.Now()
row := model.VoiceRoomRocketRewardConfig{
ID: int64(1000 + len(scene) + level + int(amount)),
SysOrigin: "LIKEI",
Level: level,
RewardScene: scene,
RewardType: rewardType,
RewardName: scene + " reward",
RewardAmount: amount,
RewardCopies: copies,
Enabled: true,
CreateTime: now,
UpdateTime: now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("seed reward: %v", err)
}
}
func strconvFormatMillis(t time.Time) string {
return strconv.FormatInt(t.UnixMilli(), 10)
}