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 TestBackpackGiftIncreasesRocketEnergy(t *testing.T) { service, db := newTestService(t) ctx := context.Background() seedRocketConfig(t, db) seedLevelConfigs(t, db, []int64{1000, 2000, 3000, 4000, 5000, 6000}) payloads := []struct { raw string energy int64 }{ { energy: 30, raw: `{ "trackId":"track-root-bag-gift", "sysOrigin":"LIKEI", "sendUserId":"1001", "roomId":"9001", "quantity":1, "bagGift":true, "giftConfig":{"id":"11","giftCandy":30,"type":"GOLD","giftTab":"NORMAL"}, "giftValue":{"actualAmount":30}, "createTime":` + strconvFormatMillis(time.Now()) + ` }`, }, { energy: 40, raw: `{ "trackId":"track-value-bag-gift", "sysOrigin":"LIKEI", "sendUserId":"1001", "roomId":"9001", "quantity":1, "giftConfig":{"id":"11","giftCandy":40,"type":"GOLD","giftTab":"NORMAL"}, "giftValue":{"actualAmount":40,"bag":true}, "createTime":` + strconvFormatMillis(time.Now().Add(time.Second)) + ` }`, }, } for _, payload := range payloads { processed, err := service.ProcessGiftPayload(ctx, payload.raw) if err != nil { t.Fatalf("ProcessGiftPayload(backpack) error = %v", err) } if !processed.Processed || processed.AcceptedEnergy != payload.energy { t.Fatalf("processed = %+v, want accepted energy %d", processed, payload.energy) } } var status model.VoiceRoomRocketStatus if err := db.Where("sys_origin = ? AND room_id = ?", "LIKEI", 9001). First(&status).Error; err != nil { t.Fatalf("find status: %v", err) } if status.CurrentEnergy != 70 { t.Fatalf("current energy = %d, want 70", status.CurrentEnergy) } var logs []model.VoiceRoomRocketGiftEventLog if err := db.Where("track_id IN ?", []string{"track-root-bag-gift", "track-value-bag-gift"}). Find(&logs).Error; err != nil { t.Fatalf("find logs: %v", err) } if len(logs) != 2 { t.Fatalf("logs = %d, want 2", len(logs)) } for _, log := range logs { if log.Status != eventStatusSuccess || log.AcceptedEnergy <= 0 { t.Fatalf("log = %+v, want success with accepted energy", log) } } } func TestGiftEnergyRulesAllowBackpackAndConfiguredGift(t *testing.T) { service, db := newTestService(t) ctx := context.Background() seedRocketConfig(t, db) seedLevelConfigs(t, db, []int64{1000, 2000, 3000, 4000, 5000, 6000}) 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()) + ` }` backpack, err := service.ProcessGiftPayload(ctx, backpackPayload) if err != nil { t.Fatalf("ProcessGiftPayload(backpack) error = %v", err) } if !backpack.Processed || backpack.AcceptedEnergy != 99 { t.Fatalf("backpack = %+v, want 99 energy", backpack) } 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, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil { t.Fatalf("set user room: %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 TestInRoomRewardWorkerSkipsUsersNoLongerInRoom(t *testing.T) { gateway := newFakeRocketGateway() service, db, rdb, mr := newTestServiceWithRedis(t, gateway) defer mr.Close() ctx := context.Background() now := time.Now() 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, 4) roomID := int64(9001) for _, userID := range []int64{1001, 1002} { if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { t.Fatalf("sadd active online: %v", err) } if err := rdb.Set(ctx, userRoomKey("LIKEI", userID), strconv.FormatInt(roomID, 10), time.Minute).Err(); err != nil { t.Fatalf("set active room: %v", err) } if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil { t.Fatalf("set active seen: %v", err) } } staleUsers := []int64{1003, 1004} for _, userID := range staleUsers { if err := rdb.SAdd(ctx, onlineRoomKey("LIKEI", roomID), userID).Err(); err != nil { t.Fatalf("sadd stale online: %v", err) } if err := rdb.Set(ctx, userSeenKey("LIKEI", userID), strconv.FormatInt(now.UnixMilli(), 10), time.Minute).Err(); err != nil { t.Fatalf("set stale seen: %v", err) } } if err := rdb.Set(ctx, userRoomKey("LIKEI", staleUsers[1]), strconv.FormatInt(roomID+1, 10), time.Minute).Err(); err != nil { t.Fatalf("set stale room: %v", err) } payload := `{ "trackId":"track-launch-in-room-stale", "sysOrigin":"LIKEI", "sendUserId":"1001", "roomId":"9001", "quantity":1, "giftConfig":{"id":"11","giftCandy":100,"type":"GOLD","giftTab":"NORMAL"}, "createTime":` + strconvFormatMillis(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 _, err := service.ProcessDueLaunches(ctx, now.Add(11*time.Second), 10); err != nil { t.Fatalf("ProcessDueLaunches() error = %v", err) } if _, err := service.ProcessDueInRoomRewards(ctx, now.Add(30*time.Second), 10); err != nil { t.Fatalf("ProcessDueInRoomRewards() error = %v", err) } var snapshotCount int64 if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Count(&snapshotCount).Error; err != nil { t.Fatalf("count snapshot: %v", err) } if snapshotCount != 2 { t.Fatalf("snapshot count = %d, want 2 active users only", snapshotCount) } for _, userID := range staleUsers { var userSnapshotCount int64 if err := db.Model(&model.VoiceRoomRocketAudienceSnapshot{}).Where("user_id = ?", userID).Count(&userSnapshotCount).Error; err != nil { t.Fatalf("count stale snapshot: %v", err) } if userSnapshotCount != 0 { t.Fatalf("stale user %d snapshot count = %d, want 0", userID, userSnapshotCount) } var rewardCount int64 if err := db.Model(&model.VoiceRoomRocketRewardRecord{}).Where("user_id = ? AND reward_scene = ?", userID, rewardSceneInRoom).Count(&rewardCount).Error; err != nil { t.Fatalf("count stale reward: %v", err) } if rewardCount != 0 { t.Fatalf("stale user %d in-room reward count = %d, want 0", userID, rewardCount) } } } 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 TestPublishRewardMessageTrimsBroadcastStreamByConfiguredMaxLen(t *testing.T) { service, _, rdb, mr := newTestServiceWithRedis(t, newFakeRocketGateway()) defer mr.Close() service.cfg.VoiceRoomRocket.BroadcastStreamMaxLen = 2 for idx := 1; idx <= 3; idx++ { service.publishRewardMessage(context.Background(), 9001, "launch-"+strconv.Itoa(idx), map[string]any{ "type": imTypeRewardPopup, "data": map[string]any{ "roomId": "9001", }, }) } count, err := rdb.XLen(context.Background(), "voice_room:rocket:broadcast").Result() if err != nil { t.Fatalf("xlen broadcast: %v", err) } if count != 2 { t.Fatalf("broadcast stream count = %d, want 2", 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.BroadcastStreamKey = "voice_room:rocket:broadcast" service.cfg.VoiceRoomRocket.BroadcastStreamMaxLen = 100 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) }