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) } } }