2026-05-14 13:46:53 +08:00

472 lines
15 KiB
Go

package voiceroomrocket
import (
"context"
"strconv"
"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 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 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)
seedRewardConfig(t, db, 1, rewardSceneInRoom, rewardTypeGold, 1)
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.Launched || resp.LaunchNo == "" {
t.Fatalf("launch response = %+v", resp)
}
processed, err := service.ProcessDueInRoomRewards(ctx, time.Now().Add(20*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 TestNotifyLaunchPublishesIMAndRedisStream(t *testing.T) {
gateway := newFakeRocketGateway()
service, db, rdb, mr := newTestServiceWithRedis(t, gateway)
defer mr.Close()
seedRocketConfig(t, db)
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)
}
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 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 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 []int64
}
type fakeRocketIM struct {
messages []fakeRocketIMMessage
}
type fakeRocketIMMessage struct {
groupID 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) {
return integration.UserProfile{ID: integration.Int64Value(userID), Account: strconv.FormatInt(userID, 10), UserNickname: "user"}, 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, badgeID)
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 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) {
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,
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)
}