656 lines
25 KiB
Go
656 lines
25 KiB
Go
package service_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
|
roomv1 "hyapp.local/api/proto/room/v1"
|
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/roommq"
|
|
"hyapp/services/room-service/internal/integration"
|
|
roomservice "hyapp/services/room-service/internal/room/service"
|
|
"hyapp/services/room-service/internal/room/state"
|
|
"hyapp/services/room-service/internal/router"
|
|
"hyapp/services/room-service/internal/testutil/mysqltest"
|
|
)
|
|
|
|
type fixedRoomTreasureClock struct {
|
|
now time.Time
|
|
}
|
|
|
|
func (c *fixedRoomTreasureClock) Now() time.Time {
|
|
return c.now.UTC()
|
|
}
|
|
|
|
type treasureTestWallet struct {
|
|
debits []*walletv1.DebitGiftResponse
|
|
grants []*walletv1.GrantResourceGroupRequest
|
|
lastDebit *walletv1.DebitGiftRequest
|
|
debitRequests []*walletv1.DebitGiftRequest
|
|
lastBatch *walletv1.BatchDebitGiftRequest
|
|
}
|
|
|
|
func (w *treasureTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
|
w.lastDebit = req
|
|
w.debitRequests = append(w.debitRequests, req)
|
|
if len(w.debits) == 0 {
|
|
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil
|
|
}
|
|
next := w.debits[0]
|
|
w.debits = w.debits[1:]
|
|
return next, nil
|
|
}
|
|
|
|
func (w *treasureTestWallet) BatchDebitGift(_ context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) {
|
|
w.lastBatch = req
|
|
receipts := make([]*walletv1.BatchDebitGiftReceipt, 0, len(req.GetTargets()))
|
|
aggregate := &walletv1.DebitGiftResponse{}
|
|
for _, target := range req.GetTargets() {
|
|
var next *walletv1.DebitGiftResponse
|
|
if len(w.debits) == 0 {
|
|
next = &walletv1.DebitGiftResponse{BillingReceiptId: fmt.Sprintf("receipt-%d", target.GetTargetUserId()), GiftPointAdded: 1, HeatValue: 1, CoinSpent: 1, ChargeAmount: 1}
|
|
} else {
|
|
next = w.debits[0]
|
|
w.debits = w.debits[1:]
|
|
}
|
|
aggregate.BillingReceiptId = joinReceiptIDs(aggregate.GetBillingReceiptId(), next.GetBillingReceiptId())
|
|
aggregate.CoinSpent += next.GetCoinSpent()
|
|
aggregate.ChargeAmount += next.GetChargeAmount()
|
|
aggregate.GiftPointAdded += next.GetGiftPointAdded()
|
|
aggregate.HeatValue += next.GetHeatValue()
|
|
aggregate.HostPeriodDiamondAdded += next.GetHostPeriodDiamondAdded()
|
|
if aggregate.PriceVersion == "" {
|
|
aggregate.PriceVersion = next.GetPriceVersion()
|
|
}
|
|
if aggregate.GiftTypeCode == "" {
|
|
aggregate.GiftTypeCode = next.GetGiftTypeCode()
|
|
}
|
|
aggregate.BalanceAfter = next.GetBalanceAfter()
|
|
receipts = append(receipts, &walletv1.BatchDebitGiftReceipt{
|
|
TargetUserId: target.GetTargetUserId(),
|
|
CommandId: target.GetCommandId(),
|
|
Billing: next,
|
|
})
|
|
}
|
|
return &walletv1.BatchDebitGiftResponse{Aggregate: aggregate, Receipts: receipts}, nil
|
|
}
|
|
|
|
func joinReceiptIDs(left string, right string) string {
|
|
if left == "" {
|
|
return right
|
|
}
|
|
if right == "" {
|
|
return left
|
|
}
|
|
return left + "," + right
|
|
}
|
|
|
|
func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
|
|
ctx := context.Background()
|
|
repository := mysqltest.NewRepository(t)
|
|
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
|
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
|
{BillingReceiptId: "receipt-202", CoinSpent: 10, ChargeAmount: 10, GiftPointAdded: 5, HeatValue: 11, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 90},
|
|
{BillingReceiptId: "receipt-303", CoinSpent: 20, ChargeAmount: 20, GiftPointAdded: 7, HeatValue: 19, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 70},
|
|
}}
|
|
svc := newTreasureTestService(t, repository, wallet, now)
|
|
|
|
roomID := "room-gift-multi-target"
|
|
ownerID := int64(101)
|
|
firstTargetID := int64(202)
|
|
secondTargetID := int64(303)
|
|
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
|
|
joinTreasureRoom(t, ctx, svc, roomID, firstTargetID)
|
|
joinTreasureRoom(t, ctx, svc, roomID, secondTargetID)
|
|
|
|
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: &roomv1.RequestMeta{
|
|
RequestId: "req-gift-multi",
|
|
CommandId: "cmd-gift-multi",
|
|
ActorUserId: ownerID,
|
|
RoomId: roomID,
|
|
AppCode: appcode.Default,
|
|
SentAtMs: now.Now().UnixMilli(),
|
|
},
|
|
TargetType: "user",
|
|
TargetUserIds: []int64{firstTargetID, secondTargetID},
|
|
GiftId: "rose",
|
|
GiftCount: 2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send multi-target gift failed: %v", err)
|
|
}
|
|
if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" {
|
|
t.Fatalf("multi-target gift response must aggregate wallet results: %+v", resp)
|
|
}
|
|
if len(wallet.debitRequests) != 0 {
|
|
t.Fatalf("multi-target gift must use wallet BatchDebitGift, single debits=%+v", wallet.debitRequests)
|
|
}
|
|
if wallet.lastBatch == nil || len(wallet.lastBatch.GetTargets()) != 2 {
|
|
t.Fatalf("wallet batch request missing targets: %+v", wallet.lastBatch)
|
|
}
|
|
if wallet.lastBatch.GetTargets()[0].GetCommandId() != "cmd-gift-multi:target:202" || wallet.lastBatch.GetTargets()[1].GetCommandId() != "cmd-gift-multi:target:303" {
|
|
t.Fatalf("wallet batch target command ids mismatch: %+v", wallet.lastBatch.GetTargets())
|
|
}
|
|
|
|
events := roomGiftSentEvents(t, ctx, repository)
|
|
if len(events) != 2 {
|
|
t.Fatalf("multi-target gift must emit one GiftSent per target, got %d", len(events))
|
|
}
|
|
got := map[int64]*roomeventsv1.RoomGiftSent{}
|
|
for _, event := range events {
|
|
got[event.GetTargetUserId()] = event
|
|
}
|
|
if got[firstTargetID].GetBillingReceiptId() != "receipt-202" || got[firstTargetID].GetGiftValue() != 11 || got[firstTargetID].GetCommandId() != "cmd-gift-multi:target:202" {
|
|
t.Fatalf("first target GiftSent mismatch: %+v", got[firstTargetID])
|
|
}
|
|
if got[secondTargetID].GetBillingReceiptId() != "receipt-303" || got[secondTargetID].GetGiftValue() != 19 || got[secondTargetID].GetCommandId() != "cmd-gift-multi:target:303" {
|
|
t.Fatalf("second target GiftSent mismatch: %+v", got[secondTargetID])
|
|
}
|
|
}
|
|
|
|
func (w *treasureTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
|
w.grants = append(w.grants, req)
|
|
return &walletv1.ResourceGrantResponse{
|
|
Grant: &walletv1.ResourceGrant{
|
|
GrantId: fmt.Sprintf("grant-%d-%d", req.GetTargetUserId(), req.GetGroupId()),
|
|
CommandId: req.GetCommandId(),
|
|
TargetUserId: req.GetTargetUserId(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type treasureOpenScheduleRecorder struct {
|
|
schedules []integration.RoomTreasureOpenSchedule
|
|
}
|
|
|
|
func (r *treasureOpenScheduleRecorder) ScheduleRoomTreasureOpen(_ context.Context, schedule integration.RoomTreasureOpenSchedule) error {
|
|
r.schedules = append(r.schedules, schedule)
|
|
return nil
|
|
}
|
|
|
|
func TestRoomTreasureGiftOverflowAndCountdownInvalidEnergy(t *testing.T) {
|
|
ctx := context.Background()
|
|
repository := mysqltest.NewRepository(t)
|
|
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
|
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
|
{BillingReceiptId: "receipt-fill", GiftPointAdded: 150, HeatValue: 150, GiftTypeCode: "normal"},
|
|
{BillingReceiptId: "receipt-countdown", GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "normal"},
|
|
}}
|
|
svc := newTreasureTestService(t, repository, wallet, now)
|
|
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
|
|
|
roomID := "room-treasure-flow"
|
|
ownerID := int64(101)
|
|
viewerID := int64(202)
|
|
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
|
|
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
|
|
|
|
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, ownerID, "fill"),
|
|
TargetType: "user",
|
|
TargetUserId: viewerID,
|
|
GiftId: "gift-overflow",
|
|
GiftCount: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send fill gift failed: %v", err)
|
|
}
|
|
treasure := fillResp.GetTreasure()
|
|
if treasure.GetCurrentLevel() != 1 || treasure.GetCurrentProgress() != 100 || treasure.GetStatus() != state.TreasureStatusCountdown {
|
|
t.Fatalf("fill gift must cap level 1 progress and enter countdown: %+v", treasure)
|
|
}
|
|
if treasure.GetOpenAtMs() != now.Now().Add(time.Second).UnixMilli() || treasure.GetResetAtMs() != time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC).UnixMilli() {
|
|
t.Fatalf("treasure timers must use UTC business time: %+v", treasure)
|
|
}
|
|
if treasure.GetTop1UserId() != ownerID || treasure.GetIgniterUserId() != ownerID {
|
|
t.Fatalf("top1 and igniter must lock at countdown start: %+v", treasure)
|
|
}
|
|
|
|
progressEvents := treasureProgressEvents(t, ctx, repository)
|
|
if len(progressEvents) != 1 || progressEvents[0].GetAddedEnergy() != 150 || progressEvents[0].GetEffectiveAddedEnergy() != 100 || progressEvents[0].GetOverflowEnergy() != 50 {
|
|
t.Fatalf("progress event must expose effective energy and discarded overflow: %+v", progressEvents)
|
|
}
|
|
if got := countTreasureCountdownEvents(t, ctx, repository); got != 1 {
|
|
t.Fatalf("fill gift must emit one countdown event, got %d", got)
|
|
}
|
|
|
|
countdownResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, viewerID, "countdown"),
|
|
TargetType: "user",
|
|
TargetUserId: ownerID,
|
|
GiftId: "gift-during-countdown",
|
|
GiftCount: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send countdown gift failed: %v", err)
|
|
}
|
|
if countdownResp.GetTreasure().GetCurrentProgress() != 100 || countdownResp.GetTreasure().GetStatus() != state.TreasureStatusCountdown {
|
|
t.Fatalf("countdown gift must not change treasure progress: %+v", countdownResp.GetTreasure())
|
|
}
|
|
if got := len(treasureProgressEvents(t, ctx, repository)); got != 1 {
|
|
t.Fatalf("countdown invalid energy must not emit a second progress event, got %d", got)
|
|
}
|
|
if got := countTreasureCountdownEvents(t, ctx, repository); got != 1 {
|
|
t.Fatalf("countdown invalid energy must not emit another countdown event, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRoomTreasureCountdownOutboxSchedulesDelayedOpen(t *testing.T) {
|
|
ctx := context.Background()
|
|
repository := mysqltest.NewRepository(t)
|
|
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
|
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
|
{BillingReceiptId: "receipt-fill", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
|
}}
|
|
scheduler := &treasureOpenScheduleRecorder{}
|
|
svc := newTreasureTestServiceWithScheduler(t, repository, wallet, now, scheduler)
|
|
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
|
|
|
roomID := "room-treasure-schedule"
|
|
ownerID := int64(801)
|
|
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9008)
|
|
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, ownerID, "fill"),
|
|
TargetType: "user",
|
|
TargetUserId: ownerID,
|
|
GiftId: "gift-fill",
|
|
GiftCount: 1,
|
|
}); err != nil {
|
|
t.Fatalf("fill treasure failed: %v", err)
|
|
}
|
|
|
|
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
|
|
t.Fatalf("process outbox failed: %v", err)
|
|
}
|
|
if len(scheduler.schedules) != 1 {
|
|
t.Fatalf("scheduled opens=%d, want 1: %+v", len(scheduler.schedules), scheduler.schedules)
|
|
}
|
|
scheduled := scheduler.schedules[0]
|
|
if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.OpenAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.CommandID == "" {
|
|
t.Fatalf("unexpected delayed open schedule: %+v", scheduled)
|
|
}
|
|
}
|
|
|
|
func TestRoomTreasureOpenDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
|
|
ctx := context.Background()
|
|
repository := mysqltest.NewRepository(t)
|
|
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
|
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
|
{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
|
}}
|
|
svc := newTreasureTestService(t, repository, wallet, now)
|
|
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
|
|
|
roomID := "room-treasure-mq-open"
|
|
ownerID := int64(901)
|
|
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9009)
|
|
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, ownerID, "ignite"),
|
|
TargetType: "user",
|
|
TargetUserId: ownerID,
|
|
GiftId: "gift-ignite",
|
|
GiftCount: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ignite treasure failed: %v", err)
|
|
}
|
|
treasure := resp.GetTreasure()
|
|
due := roommq.RoomTreasureOpenDueMessage{
|
|
MessageType: roommq.MessageTypeRoomTreasureOpenDue,
|
|
AppCode: appcode.Default,
|
|
RoomID: roomID,
|
|
BoxID: treasure.GetBoxId(),
|
|
Level: treasure.GetCurrentLevel(),
|
|
OpenAtMS: treasure.GetOpenAtMs(),
|
|
CommandID: "cmd_room_treasure_open_" + treasure.GetBoxId(),
|
|
}
|
|
|
|
if err := svc.HandleRoomTreasureOpenDue(ctx, due); err == nil {
|
|
t.Fatal("early delayed message should ask MQ to retry")
|
|
}
|
|
now.now = now.now.Add(time.Second)
|
|
if err := svc.HandleRoomTreasureOpenDue(ctx, due); err != nil {
|
|
t.Fatalf("due delayed message should open treasure after retry: %v", err)
|
|
}
|
|
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
|
RoomId: roomID,
|
|
ViewerUserId: ownerID,
|
|
Meta: &roomv1.RequestMeta{
|
|
AppCode: appcode.Default,
|
|
RequestId: "req-check",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get treasure failed: %v", err)
|
|
}
|
|
if info.GetTreasure().GetState().GetStatus() != state.TreasureStatusIdle || info.GetTreasure().GetState().GetCurrentLevel() != 2 {
|
|
t.Fatalf("treasure should open once after early retry: %+v", info.GetTreasure().GetState())
|
|
}
|
|
if len(wallet.grants) == 0 {
|
|
t.Fatal("open should grant configured rewards after retry")
|
|
}
|
|
}
|
|
|
|
func TestRoomTreasureOpenGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) {
|
|
ctx := context.Background()
|
|
repository := mysqltest.NewRepository(t)
|
|
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
|
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
|
{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
|
}}
|
|
svc := newTreasureTestService(t, repository, wallet, now)
|
|
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
|
|
|
roomID := "room-treasure-open"
|
|
ownerID := int64(301)
|
|
igniterID := int64(302)
|
|
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9002)
|
|
joinTreasureRoom(t, ctx, svc, roomID, igniterID)
|
|
|
|
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, igniterID, "ignite"),
|
|
TargetType: "user",
|
|
TargetUserId: ownerID,
|
|
GiftId: "gift-ignite",
|
|
GiftCount: 1,
|
|
}); err != nil {
|
|
t.Fatalf("ignite treasure failed: %v", err)
|
|
}
|
|
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: treasureMeta(roomID, igniterID, "leave")}); err != nil {
|
|
t.Fatalf("igniter leave room failed: %v", err)
|
|
}
|
|
|
|
now.now = now.now.Add(1100 * time.Millisecond)
|
|
if err := svc.SweepRoomTreasureOpenings(ctx); err != nil {
|
|
t.Fatalf("sweep room treasure openings failed: %v", err)
|
|
}
|
|
|
|
if !hasTreasureGrant(wallet.grants, igniterID, 2001) || !hasTreasureGrant(wallet.grants, igniterID, 3001) {
|
|
t.Fatalf("left top1/igniter must still receive locked rewards: %+v", wallet.grants)
|
|
}
|
|
if hasTreasureGrant(wallet.grants, igniterID, 1001) {
|
|
t.Fatalf("left igniter must not receive in-room reward: %+v", wallet.grants)
|
|
}
|
|
if !hasTreasureGrant(wallet.grants, ownerID, 1001) {
|
|
t.Fatalf("remaining in-room owner must receive in-room reward: %+v", wallet.grants)
|
|
}
|
|
|
|
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
|
RoomId: roomID,
|
|
ViewerUserId: ownerID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get treasure after open failed: %v", err)
|
|
}
|
|
stateView := info.GetTreasure().GetState()
|
|
if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.TreasureStatusIdle {
|
|
t.Fatalf("opened treasure must advance to next idle level: %+v", stateView)
|
|
}
|
|
if len(stateView.GetLastRewards()) != 3 {
|
|
t.Fatalf("opened treasure must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards())
|
|
}
|
|
if got := treasureRewardUserIDs(stateView.GetLastRewards()); !slices.Equal(got, []int64{301, 302, 302}) {
|
|
t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards())
|
|
}
|
|
}
|
|
|
|
func TestRoomTreasureUTCResetCancelsCrossDayCountdown(t *testing.T) {
|
|
ctx := context.Background()
|
|
repository := mysqltest.NewRepository(t)
|
|
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 23, 59, 59, 0, time.UTC)}
|
|
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
|
{BillingReceiptId: "receipt-cross-day", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
|
{BillingReceiptId: "receipt-new-day", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
|
|
}}
|
|
svc := newTreasureTestService(t, repository, wallet, now)
|
|
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 2_000))
|
|
|
|
roomID := "room-treasure-reset"
|
|
ownerID := int64(401)
|
|
viewerID := int64(402)
|
|
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9003)
|
|
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
|
|
|
|
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, viewerID, "cross-day-fill"),
|
|
TargetType: "user",
|
|
TargetUserId: ownerID,
|
|
GiftId: "gift-cross-day",
|
|
GiftCount: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send cross-day gift failed: %v", err)
|
|
}
|
|
if fillResp.GetTreasure().GetStatus() != state.TreasureStatusCountdown || fillResp.GetTreasure().GetOpenAtMs() <= fillResp.GetTreasure().GetResetAtMs() {
|
|
t.Fatalf("fixture must create a countdown whose open time crosses UTC reset: %+v", fillResp.GetTreasure())
|
|
}
|
|
|
|
now.now = time.Date(2026, 5, 21, 0, 0, 2, 0, time.UTC)
|
|
if err := svc.SweepRoomTreasureOpenings(ctx); err != nil {
|
|
t.Fatalf("sweep cross-day treasure failed: %v", err)
|
|
}
|
|
if len(wallet.grants) != 0 {
|
|
t.Fatalf("cross-day countdown must be canceled by UTC reset, grants=%+v", wallet.grants)
|
|
}
|
|
|
|
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
|
RoomId: roomID,
|
|
ViewerUserId: ownerID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get treasure after UTC reset failed: %v", err)
|
|
}
|
|
if stateView := info.GetTreasure().GetState(); stateView.GetCurrentLevel() != 1 || stateView.GetCurrentProgress() != 0 || stateView.GetStatus() != state.TreasureStatusIdle {
|
|
t.Fatalf("query view must reset stale countdown at UTC boundary: %+v", stateView)
|
|
}
|
|
|
|
nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
|
Meta: treasureMeta(roomID, ownerID, "new-day-gift"),
|
|
TargetType: "user",
|
|
TargetUserId: viewerID,
|
|
GiftId: "gift-new-day",
|
|
GiftCount: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send new-day gift failed: %v", err)
|
|
}
|
|
if nextResp.GetTreasure().GetCurrentLevel() != 1 || nextResp.GetTreasure().GetCurrentProgress() != 10 || nextResp.GetTreasure().GetStatus() != state.TreasureStatusIdle {
|
|
t.Fatalf("first new-day gift must start a fresh level 1 treasure: %+v", nextResp.GetTreasure())
|
|
}
|
|
}
|
|
|
|
func newTreasureTestService(t *testing.T, repository *mysqltest.Repository, wallet *treasureTestWallet, clock *fixedRoomTreasureClock) *roomservice.Service {
|
|
t.Helper()
|
|
return newTreasureTestServiceWithScheduler(t, repository, wallet, clock, nil)
|
|
}
|
|
|
|
func newTreasureTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repository, wallet *treasureTestWallet, clock *fixedRoomTreasureClock, scheduler integration.RoomTreasureOpenScheduler) *roomservice.Service {
|
|
t.Helper()
|
|
|
|
return roomservice.New(roomservice.Config{
|
|
NodeID: "node-treasure-test",
|
|
LeaseTTL: 10 * time.Second,
|
|
RankLimit: 20,
|
|
SnapshotEveryN: 1,
|
|
Clock: clock,
|
|
RoomTreasureOpenScheduler: scheduler,
|
|
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
|
}
|
|
|
|
func treasureConfigForTest(threshold int64, openDelayMS int64) roomservice.RoomTreasureConfig {
|
|
levels := make([]roomservice.RoomTreasureLevelConfig, 0, 7)
|
|
for level := int32(1); level <= 7; level++ {
|
|
levels = append(levels, roomservice.RoomTreasureLevelConfig{
|
|
Level: level,
|
|
EnergyThreshold: threshold + int64(level-1)*100,
|
|
CoverURL: fmt.Sprintf("https://example.test/treasure/%d-cover.png", level),
|
|
AnimationURL: fmt.Sprintf("https://example.test/treasure/%d-idle.webp", level),
|
|
OpeningAnimationURL: fmt.Sprintf("https://example.test/treasure/%d-opening.webp", level),
|
|
OpenedImageURL: fmt.Sprintf("https://example.test/treasure/%d-open.png", level),
|
|
InRoomRewards: []roomservice.RoomTreasureRewardItem{{
|
|
RewardItemID: fmt.Sprintf("level-%d-in-room", level),
|
|
ResourceGroupID: 1000 + int64(level),
|
|
Weight: 1,
|
|
DisplayName: "In Room",
|
|
IconURL: "https://example.test/reward/in-room.png",
|
|
}},
|
|
Top1Rewards: []roomservice.RoomTreasureRewardItem{{
|
|
RewardItemID: fmt.Sprintf("level-%d-top1", level),
|
|
ResourceGroupID: 2000 + int64(level),
|
|
Weight: 1,
|
|
DisplayName: "Top 1",
|
|
IconURL: "https://example.test/reward/top1.png",
|
|
}},
|
|
IgniterRewards: []roomservice.RoomTreasureRewardItem{{
|
|
RewardItemID: fmt.Sprintf("level-%d-igniter", level),
|
|
ResourceGroupID: 3000 + int64(level),
|
|
Weight: 1,
|
|
DisplayName: "Igniter",
|
|
IconURL: "https://example.test/reward/igniter.png",
|
|
}},
|
|
})
|
|
}
|
|
return roomservice.RoomTreasureConfig{
|
|
AppCode: appcode.Default,
|
|
Enabled: true,
|
|
ConfigVersion: 1,
|
|
EnergySource: "gift_point_added",
|
|
OpenDelayMS: openDelayMS,
|
|
BroadcastEnabled: true,
|
|
BroadcastScope: "region",
|
|
RewardStackPolicy: "allow_stack",
|
|
Levels: levels,
|
|
}
|
|
}
|
|
|
|
func writeTreasureConfig(t *testing.T, ctx context.Context, repository *mysqltest.Repository, config roomservice.RoomTreasureConfig) {
|
|
t.Helper()
|
|
|
|
if err := repository.UpsertRoomTreasureConfig(ctx, config); err != nil {
|
|
t.Fatalf("upsert treasure config failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func createTreasureRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, ownerID int64, regionID int64) {
|
|
t.Helper()
|
|
|
|
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
|
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
|
SeatCount: 10,
|
|
Mode: "voice",
|
|
VisibleRegionId: regionID,
|
|
RoomName: roomID,
|
|
RoomAvatar: testRoomCoverURL,
|
|
RoomShortId: roomID,
|
|
}); err != nil {
|
|
t.Fatalf("create treasure room failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func joinTreasureRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, userID int64) {
|
|
t.Helper()
|
|
|
|
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
|
Meta: roomservice.NewRequestMeta(roomID, userID),
|
|
Role: "audience",
|
|
}); err != nil {
|
|
t.Fatalf("join treasure room failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func treasureMeta(roomID string, actorUserID int64, suffix string) *roomv1.RequestMeta {
|
|
return &roomv1.RequestMeta{
|
|
RequestId: "req-room-treasure-" + suffix,
|
|
CommandId: "cmd-room-treasure-" + suffix,
|
|
ActorUserId: actorUserID,
|
|
RoomId: roomID,
|
|
AppCode: appcode.Default,
|
|
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
|
}
|
|
}
|
|
|
|
func treasureProgressEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomTreasureProgressChanged {
|
|
t.Helper()
|
|
|
|
records, err := repository.ListPendingOutbox(ctx, 50)
|
|
if err != nil {
|
|
t.Fatalf("list pending outbox failed: %v", err)
|
|
}
|
|
events := make([]*roomeventsv1.RoomTreasureProgressChanged, 0)
|
|
for _, record := range records {
|
|
if record.EventType != "RoomTreasureProgressChanged" {
|
|
continue
|
|
}
|
|
var event roomeventsv1.RoomTreasureProgressChanged
|
|
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
|
|
t.Fatalf("decode treasure progress event failed: %v", err)
|
|
}
|
|
events = append(events, &event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
|
|
t.Helper()
|
|
|
|
records, err := repository.ListPendingOutbox(ctx, 50)
|
|
if err != nil {
|
|
t.Fatalf("list pending outbox failed: %v", err)
|
|
}
|
|
events := make([]*roomeventsv1.RoomGiftSent, 0)
|
|
for _, record := range records {
|
|
if record.EventType != "RoomGiftSent" {
|
|
continue
|
|
}
|
|
var event roomeventsv1.RoomGiftSent
|
|
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
|
|
t.Fatalf("decode gift sent event failed: %v", err)
|
|
}
|
|
events = append(events, &event)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func countTreasureCountdownEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) int {
|
|
t.Helper()
|
|
|
|
records, err := repository.ListPendingOutbox(ctx, 50)
|
|
if err != nil {
|
|
t.Fatalf("list pending outbox failed: %v", err)
|
|
}
|
|
count := 0
|
|
for _, record := range records {
|
|
if record.EventType == "RoomTreasureCountdownStarted" {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func hasTreasureGrant(grants []*walletv1.GrantResourceGroupRequest, userID int64, groupID int64) bool {
|
|
for _, grant := range grants {
|
|
if grant.GetTargetUserId() == userID && grant.GetGroupId() == groupID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func treasureRewardUserIDs(rewards []*roomv1.RoomTreasureRewardGrant) []int64 {
|
|
userIDs := make([]int64, 0, len(rewards))
|
|
for _, reward := range rewards {
|
|
userIDs = append(userIDs, reward.GetUserId())
|
|
}
|
|
slices.Sort(userIDs)
|
|
return userIDs
|
|
}
|