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 } func (w *treasureTestWallet) DebitGift(_ context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { 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) 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, 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 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 }