2026-05-09 18:01:08 +08:00

512 lines
17 KiB
Go

package voiceroomredpacket
import (
"context"
"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 TestSplitRandomAmountsKeepsTotalAndMinimum(t *testing.T) {
amounts, err := splitRandomAmounts(15000, 50, 1)
if err != nil {
t.Fatalf("splitRandomAmounts() error = %v", err)
}
if len(amounts) != 50 {
t.Fatalf("len(amounts) = %d, want 50", len(amounts))
}
if got := sumAmounts(amounts); got != 15000 {
t.Fatalf("sumAmounts() = %d, want 15000", got)
}
for _, amount := range amounts {
if amount <= 0 {
t.Fatalf("amount = %d, want > 0", amount)
}
}
}
func TestSendAndClaimImmediatePacket(t *testing.T) {
service, wallet, _ := newVoiceRoomRedPacketTestService(t)
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
claimer := AuthUser{UserID: 1002, SysOrigin: "LIKEI"}
if _, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}); err != nil {
t.Fatalf("sender Heartbeat() error = %v", err)
}
if _, err := service.Heartbeat(ctx, claimer, PresenceRequest{RoomID: 9001}); err != nil {
t.Fatalf("claimer Heartbeat() error = %v", err)
}
sendResp, err := service.Send(ctx, sender, SendRequest{
RequestID: "req-send-1",
RoomID: 9001,
PacketMode: packetModeImmediate,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if sendResp.PacketNo == "" || sendResp.Status != packetStatusActive {
t.Fatalf("Send() response = %+v", sendResp)
}
claimResp, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo})
if err != nil {
t.Fatalf("Claim() error = %v", err)
}
if claimResp.Amount <= 0 || claimResp.RemainCount != 4 {
t.Fatalf("Claim() response = %+v", claimResp)
}
if wallet.balance[1001] != 85000 {
t.Fatalf("sender balance = %d, want 85000", wallet.balance[1001])
}
if wallet.balance[1002] != 100000+claimResp.Amount {
t.Fatalf("claimer balance = %d, want %d", wallet.balance[1002], 100000+claimResp.Amount)
}
repeated, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo})
if err != nil {
t.Fatalf("repeat Claim() error = %v", err)
}
if repeated.Amount != claimResp.Amount || repeated.RemainCount != claimResp.RemainCount {
t.Fatalf("repeat Claim() = %+v, want original %+v", repeated, claimResp)
}
assertWalletCommandsFitWalletSchema(t, wallet.calls)
if wallet.calls[0].CustomizeOriginDesc != walletOriginSendDesc {
t.Fatalf("send wallet desc = %q, want %q", wallet.calls[0].CustomizeOriginDesc, walletOriginSendDesc)
}
if wallet.calls[1].CustomizeOriginDesc != walletOriginClaimDesc {
t.Fatalf("claim wallet desc = %q, want %q", wallet.calls[1].CustomizeOriginDesc, walletOriginClaimDesc)
}
claimRecords, err := service.ListClaimRecords(ctx, sender, sendResp.PacketNo)
if err != nil {
t.Fatalf("ListClaimRecords() error = %v", err)
}
if claimRecords.PacketNo != sendResp.PacketNo ||
claimRecords.TotalCount != 5 ||
claimRecords.ClaimedCount != 1 ||
claimRecords.ClaimedAmount != claimResp.Amount {
t.Fatalf("ListClaimRecords() = %+v, claimResp = %+v", claimRecords, claimResp)
}
if len(claimRecords.Records) != 1 {
t.Fatalf("records len = %d, want 1", len(claimRecords.Records))
}
record := claimRecords.Records[0]
if record.UserID != claimer.UserID ||
record.Account != "1002" ||
record.UserNickname != "claimer" ||
record.UserAvatar != "claimer.png" ||
record.Amount != claimResp.Amount ||
record.ClaimTime == "" {
t.Fatalf("claim record = %+v, claimResp = %+v", record, claimResp)
}
}
func TestDelayedPacketRejectsEarlyClaim(t *testing.T) {
service, _, _ := newVoiceRoomRedPacketTestService(t)
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
claimer := AuthUser{UserID: 1002, SysOrigin: "LIKEI"}
_, _ = service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001})
_, _ = service.Heartbeat(ctx, claimer, PresenceRequest{RoomID: 9001})
sendResp, err := service.Send(ctx, sender, SendRequest{
RequestID: "req-delayed-1",
RoomID: 9001,
PacketMode: packetModeDelayed,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if _, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo}); err == nil {
t.Fatalf("Claim() error = nil, want not started")
}
}
func TestDelayedPacketSendsRegionIMBroadcastOnly(t *testing.T) {
service, _, _ := newVoiceRoomRedPacketTestService(t)
fakeRegionIM := &fakeRegionIMGateway{}
service.regionIM = fakeRegionIM
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
if _, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}); err != nil {
t.Fatalf("Heartbeat() error = %v", err)
}
sendResp, err := service.Send(ctx, sender, SendRequest{
RequestID: "req-delayed-im-1",
RoomID: 9001,
PacketMode: packetModeDelayed,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(fakeRegionIM.sent) != 1 {
t.Fatalf("sent messages = %+v, want one region message", fakeRegionIM.sent)
}
if fakeRegionIM.sent[0].sysOrigin != "LIKEI" || fakeRegionIM.sent[0].regionCode != "AR" {
t.Fatalf("sent target = %+v, want LIKEI/AR", fakeRegionIM.sent[0])
}
body, ok := fakeRegionIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeRegionIM.sent[0].body)
}
if body["type"] != "ROOM_RED_PACKET" {
t.Fatalf("sent type = %v, want ROOM_RED_PACKET", body["type"])
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["roomId"] != "9001" || data["packetId"] != sendResp.PacketNo || data["regionCode"] != "AR" {
t.Fatalf("sent data = %+v", data)
}
}
func TestResolveUserRegionPrefersJavaRegionResolver(t *testing.T) {
service, _, _ := newVoiceRoomRedPacketTestService(t)
service.SetRegionResolver(fakeRegionResolver{
regions: map[string]string{"LIKEI|SA": "中东区"},
})
fakeRegionIM := &fakeRegionIMGateway{}
service.regionIM = fakeRegionIM
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
presence, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001})
if err != nil {
t.Fatalf("Heartbeat() error = %v", err)
}
if presence.RegionCode != "中东区" {
t.Fatalf("presence region = %q, want Java region", presence.RegionCode)
}
_, err = service.Send(ctx, sender, SendRequest{
RequestID: "req-delayed-java-region",
RoomID: 9001,
PacketMode: packetModeDelayed,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(fakeRegionIM.sent) != 1 || fakeRegionIM.sent[0].regionCode != "中东区" {
t.Fatalf("sent region messages = %+v, want one Java-region message", fakeRegionIM.sent)
}
}
func TestImmediatePacketSendsRoomIMMessage(t *testing.T) {
service, _, _ := newVoiceRoomRedPacketTestService(t)
fakeIM := &fakeIMGateway{}
service.im = fakeIM
service.roomProfiles = fakeRoomProfileGateway{
profiles: map[int64]integration.RoomProfile{
9001: {
ID: integration.Int64Value(9001),
RoomAccount: "900100",
},
},
}
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
if _, err := service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001}); err != nil {
t.Fatalf("Heartbeat() error = %v", err)
}
sendResp, err := service.Send(ctx, sender, SendRequest{
RequestID: "req-immediate-im-1",
RoomID: 9001,
PacketMode: packetModeImmediate,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if len(fakeIM.sent) != 1 {
t.Fatalf("sent messages = %+v, want one room message", fakeIM.sent)
}
if fakeIM.sent[0].groupID != "900100" {
t.Fatalf("sent group = %s, want 900100", fakeIM.sent[0].groupID)
}
body, ok := fakeIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
}
if body["type"] != "ROOM_RED_PACKET" {
t.Fatalf("sent type = %v, want ROOM_RED_PACKET", body["type"])
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["roomId"] != "9001" || data["roomAccount"] != "900100" ||
data["packetId"] != sendResp.PacketNo || data["packetMode"] != packetModeImmediate {
t.Fatalf("sent data = %+v", data)
}
}
func TestProcessExpiredRefundsUnclaimedSlices(t *testing.T) {
service, wallet, db := newVoiceRoomRedPacketTestService(t)
ctx := context.Background()
sender := AuthUser{UserID: 1001, SysOrigin: "LIKEI"}
claimer := AuthUser{UserID: 1002, SysOrigin: "LIKEI"}
_, _ = service.Heartbeat(ctx, sender, PresenceRequest{RoomID: 9001})
_, _ = service.Heartbeat(ctx, claimer, PresenceRequest{RoomID: 9001})
sendResp, err := service.Send(ctx, sender, SendRequest{
RequestID: "req-refund-1",
RoomID: 9001,
PacketMode: packetModeImmediate,
TotalAmount: 15000,
TotalCount: 5,
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
claimResp, err := service.Claim(ctx, claimer, ClaimRequest{PacketNo: sendResp.PacketNo})
if err != nil {
t.Fatalf("Claim() error = %v", err)
}
if err := db.Model(&model.VoiceRoomRedPacket{}).
Where("packet_no = ?", sendResp.PacketNo).
Update("expire_time", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatalf("force expire: %v", err)
}
result, err := service.ProcessExpired(ctx, 10)
if err != nil {
t.Fatalf("ProcessExpired() error = %v", err)
}
if result.Success != 1 || result.Failed != 0 {
t.Fatalf("ProcessExpired() = %+v", result)
}
wantSenderBalance := int64(100000 - 15000 + (15000 - claimResp.Amount))
if wallet.balance[1001] != wantSenderBalance {
t.Fatalf("sender balance = %d, want %d", wallet.balance[1001], wantSenderBalance)
}
var packet model.VoiceRoomRedPacket
if err := db.Where("packet_no = ?", sendResp.PacketNo).First(&packet).Error; err != nil {
t.Fatalf("load packet: %v", err)
}
if packet.Status != packetStatusRefunded || packet.RefundAmount != 15000-claimResp.Amount {
t.Fatalf("packet after refund = %+v", packet)
}
assertWalletCommandsFitWalletSchema(t, wallet.calls)
if wallet.calls[len(wallet.calls)-1].CustomizeOriginDesc != walletOriginRefundDesc {
t.Fatalf("refund wallet desc = %q, want %q", wallet.calls[len(wallet.calls)-1].CustomizeOriginDesc, walletOriginRefundDesc)
}
}
func TestWalletEventIDFitsJavaWalletSchema(t *testing.T) {
packetNo := buildPacketNo(time.Date(2026, 5, 8, 10, 18, 53, 0, time.UTC), 2052649421790744577)
cases := map[string]string{
"send": walletEventID(walletOriginSend, "LIKEI", packetNo, 0),
"claim": walletEventID(walletOriginClaim, "LIKEI", packetNo, 2052643780674195456),
"refund": walletEventID(walletOriginRefund, "LIKEI", packetNo, 0),
}
for name, eventID := range cases {
if len(eventID) > 50 {
t.Fatalf("%s wallet event ID length = %d, want <= 50: %s", name, len(eventID), eventID)
}
}
}
func newVoiceRoomRedPacketTestService(t *testing.T) (*Service, *fakeWalletGateway, *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.UserBaseInfo{},
&model.VoiceRoomRedPacketConfig{},
&model.VoiceRoomRegionCountry{},
&model.VoiceRoomRegionIMGroup{},
&model.RoomLiveBroadcastVIP{},
&model.VoiceRoomRedPacket{},
&model.VoiceRoomRedPacketSlice{},
&model.VoiceRoomRedPacketClaim{},
&model.VoiceRoomRedPacketDailyCounter{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
now := time.Now()
if err := db.Create(&[]model.UserBaseInfo{
{ID: 1001, Account: "1001", UserNickname: "sender", UserAvatar: "sender.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
{ID: 1002, Account: "1002", UserNickname: "claimer", UserAvatar: "claimer.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
}).Error; err != nil {
t.Fatalf("seed users: %v", err)
}
if err := db.Create(&model.VoiceRoomRegionCountry{
ID: 1,
SysOrigin: "LIKEI",
RegionCode: "AR",
CountryCode: "SA",
Enabled: true,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed region: %v", err)
}
mr := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
wallet := &fakeWalletGateway{balance: map[int64]int64{1001: 100000, 1002: 100000}, events: map[string]struct{}{}}
service := NewService(config.Config{
VoiceRoomRedPacket: config.VoiceRoomRedPacketConfig{
PresenceTTLSeconds: 60,
RefundScanIntervalSeconds: 10,
RefundBatchSize: 100,
NotifyStreamKey: "test:voice_room:red_packet:notify",
ExpireZSetKey: "test:voice_room:red_packet:expire",
},
}, db, redisClient, wallet, nil)
if _, err := service.SaveAdminConfig(context.Background(), SaveConfigRequest{
SysOrigin: "LIKEI",
Enabled: true,
DailySendLimit: 10,
DelaySeconds: 300,
RefundAfterSeconds: 7200,
Timezone: "Asia/Riyadh",
AmountOptions: []int64{15000, 30000, 70000, 100000},
CountOptions: []int{5, 10, 20, 50},
}); err != nil {
t.Fatalf("SaveAdminConfig() error = %v", err)
}
return service, wallet, db
}
type fakeRoomProfileGateway struct {
profiles map[int64]integration.RoomProfile
}
func (g fakeRoomProfileGateway) MapRoomProfiles(_ context.Context, roomIDs []int64) (map[int64]integration.RoomProfile, error) {
result := make(map[int64]integration.RoomProfile, len(roomIDs))
for _, roomID := range roomIDs {
if profile, ok := g.profiles[roomID]; ok {
result[roomID] = profile
}
}
return result, nil
}
type fakeIMCreateRecord struct {
groupID string
groupName string
}
type fakeIMSendRecord struct {
groupID string
body any
}
type fakeIMGateway struct {
created []fakeIMCreateRecord
sent []fakeIMSendRecord
}
func (g *fakeIMGateway) CreateAVChatRoom(_ context.Context, groupID string, groupName string) (string, error) {
g.created = append(g.created, fakeIMCreateRecord{groupID: groupID, groupName: groupName})
return groupID, nil
}
func (g *fakeIMGateway) SendCustomGroupMessage(_ context.Context, groupID string, body any) error {
g.sent = append(g.sent, fakeIMSendRecord{groupID: groupID, body: body})
return nil
}
type fakeRegionIMSendRecord struct {
sysOrigin string
regionCode string
body any
}
type fakeRegionIMGateway struct {
sent []fakeRegionIMSendRecord
}
func (g *fakeRegionIMGateway) SendRegionCustomMessage(_ context.Context, sysOrigin string, regionCode string, body any) (string, error) {
g.sent = append(g.sent, fakeRegionIMSendRecord{sysOrigin: sysOrigin, regionCode: regionCode, body: body})
return "@region-" + strings.ToLower(regionCode), nil
}
type fakeRegionResolver struct {
regions map[string]string
err error
}
func (g fakeRegionResolver) ResolveRegionCodeByCountryCode(_ context.Context, sysOrigin string, countryCode string) (string, error) {
if g.err != nil {
return "", g.err
}
key := strings.ToUpper(strings.TrimSpace(sysOrigin)) + "|" + strings.ToUpper(strings.TrimSpace(countryCode))
return strings.TrimSpace(g.regions[key]), nil
}
type fakeWalletGateway struct {
balance map[int64]int64
events map[string]struct{}
calls []integration.GoldReceiptCommand
}
func (g *fakeWalletGateway) ExistsGoldEvent(_ context.Context, eventID string) (bool, error) {
_, ok := g.events[eventID]
return ok, nil
}
func (g *fakeWalletGateway) ChangeGoldBalance(_ context.Context, cmd integration.GoldReceiptCommand) error {
if _, exists := g.events[cmd.EventID]; exists {
return nil
}
g.calls = append(g.calls, cmd)
switch cmd.ReceiptType {
case walletReceiptExpenditure:
g.balance[cmd.UserID] -= cmd.Amount.DollarAmount
case walletReceiptIncome:
g.balance[cmd.UserID] += cmd.Amount.DollarAmount
}
g.events[cmd.EventID] = struct{}{}
return nil
}
func (g *fakeWalletGateway) MapGoldBalance(_ context.Context, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs))
for _, userID := range userIDs {
result[userID] = g.balance[userID]
}
return result, nil
}
func assertWalletCommandsFitWalletSchema(t *testing.T, commands []integration.GoldReceiptCommand) {
t.Helper()
if len(commands) == 0 {
t.Fatalf("wallet commands are empty")
}
for _, command := range commands {
if len(command.EventID) > 50 {
t.Fatalf("wallet event ID length = %d, want <= 50: %+v", len(command.EventID), command)
}
if len(command.CustomizeOrigin) > 50 {
t.Fatalf("wallet event type length = %d, want <= 50: %+v", len(command.CustomizeOrigin), command)
}
if len(command.CustomizeOriginDesc) > 150 {
t.Fatalf("wallet event desc length = %d, want <= 150: %+v", len(command.CustomizeOriginDesc), command)
}
if command.OpUserType != "APP" && command.OpUserType != "BACK" {
t.Fatalf("wallet op user type = %q, want APP or BACK: %+v", command.OpUserType, command)
}
}
}