package service_test import ( "context" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/services/room-service/internal/integration" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" ) type recordingRoomGiftLeaderboard struct { increments []roomservice.RoomGiftLeaderboardIncrement } func (s *recordingRoomGiftLeaderboard) IncrementRoomGift(_ context.Context, input roomservice.RoomGiftLeaderboardIncrement) error { s.increments = append(s.increments, input) return nil } func (s *recordingRoomGiftLeaderboard) ListRoomGiftLeaderboard(context.Context, roomservice.RoomGiftLeaderboardQuery) (roomservice.RoomGiftLeaderboardPage, error) { return roomservice.RoomGiftLeaderboardPage{}, nil } func TestSendGiftWritesRoomGiftLeaderboardWithHeatValue(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ BillingReceiptId: "receipt-gift-leaderboard", CoinSpent: 100, ChargeAmount: 100, GiftPointAdded: 100, HeatValue: 10, GiftTypeCode: "lucky", }}} leaderboard := &recordingRoomGiftLeaderboard{} now := &fixedRoomRocketClock{now: time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)} svc := roomservice.New(roomservice.Config{ NodeID: "node-gift-leaderboard-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, Clock: now, RoomGiftLeaderboard: leaderboard, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-gift-leaderboard" senderID := int64(18001) targetID := int64(18002) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-gift-leaderboard", CommandId: "cmd-gift-leaderboard", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default, SentAtMs: now.Now().UnixMilli(), }, TargetType: "user", TargetUserId: targetID, GiftId: "gift-leaderboard", GiftCount: 1, }); err != nil { t.Fatalf("send gift failed: %v", err) } if len(leaderboard.increments) != 1 { t.Fatalf("expected one leaderboard increment, got %+v", leaderboard.increments) } if leaderboard.increments[0].CoinSpent != 10 { t.Fatalf("leaderboard must use heat value after global ratio, got %+v", leaderboard.increments[0]) } rankResp, err := svc.ListRoomContributionRank(ctx, &roomv1.ListRoomContributionRankRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, RoomId: roomID, Period: "day", Limit: 10, ViewerUserId: senderID, }) if err != nil { t.Fatalf("list room contribution rank failed: %v", err) } if len(rankResp.GetItems()) != 1 || rankResp.GetItems()[0].GetUserId() != senderID || rankResp.GetItems()[0].GetGiftValue() != 10 { t.Fatalf("room contribution rank must use committed gift heat value: %+v", rankResp.GetItems()) } if rankResp.GetPeriod() != "day" || rankResp.GetStartAtMs() <= 0 || rankResp.GetEndAtMs() <= rankResp.GetStartAtMs() { t.Fatalf("room contribution rank period metadata mismatch: %+v", rankResp) } } func TestAdminRoomContributionUsesCurrentUTCWeek(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{ { BillingReceiptId: "receipt-week-one", CoinSpent: 100, ChargeAmount: 100, HeatValue: 10, GiftTypeCode: "normal", }, { BillingReceiptId: "receipt-week-two", CoinSpent: 70, ChargeAmount: 70, HeatValue: 7, GiftTypeCode: "normal", }, }} now := &fixedRoomRocketClock{now: time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC)} svc := roomservice.New(roomservice.Config{ NodeID: "node-admin-weekly-contribution-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, Clock: now, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-admin-weekly-contribution" senderID := int64(18101) targetID := int64(18102) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) sendGiftForWeeklyContribution(t, ctx, svc, roomID, senderID, targetID, "cmd-week-one") firstWeek, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, Page: 1, PageSize: 20, SortBy: "room_contribution", SortDirection: "desc", }) if err != nil { t.Fatalf("list first week rooms failed: %v", err) } if got := adminRoomHeatByID(firstWeek.GetRooms(), roomID); got != 10 { t.Fatalf("first week contribution mismatch: got %d want 10", got) } now.now = time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) cutoverBuffer, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, Page: 1, PageSize: 20, SortBy: "room_contribution", SortDirection: "desc", }) if err != nil { t.Fatalf("list cutover buffer rooms failed: %v", err) } if got := adminRoomHeatByID(cutoverBuffer.GetRooms(), roomID); got != 10 { t.Fatalf("monday 00:00 UTC must keep previous contribution until UTC+1 cutover: got %d want 10", got) } now.now = time.Date(2026, 6, 8, 1, 0, 0, 0, time.UTC) nextWeekBeforeGift, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, Page: 1, PageSize: 20, SortBy: "room_contribution", SortDirection: "desc", }) if err != nil { t.Fatalf("list next contribution week rooms failed: %v", err) } if got := adminRoomHeatByID(nextWeekBeforeGift.GetRooms(), roomID); got != 0 { t.Fatalf("next UTC+1 week must clear contribution before new gifts: got %d want 0", got) } resp := sendGiftForWeeklyContribution(t, ctx, svc, roomID, senderID, targetID, "cmd-week-two") if resp.GetRoomHeat() != 17 { t.Fatalf("room heat must remain cumulative Room Cell state: got %d want 17", resp.GetRoomHeat()) } nextWeekAfterGift, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, Page: 1, PageSize: 20, SortBy: "room_contribution", SortDirection: "desc", }) if err != nil { t.Fatalf("list next week rooms after gift failed: %v", err) } if got := adminRoomHeatByID(nextWeekAfterGift.GetRooms(), roomID); got != 7 { t.Fatalf("next UTC+1 week contribution must restart from new gifts: got %d want 7", got) } } func TestRoomContributionRankWeekUsesNaturalUTCWeek(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ BillingReceiptId: "receipt-rank-week-boundary", CoinSpent: 100, ChargeAmount: 100, HeatValue: 10, GiftTypeCode: "normal", }}} now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 0, 30, 0, 0, time.UTC)} svc := roomservice.New(roomservice.Config{ NodeID: "node-room-rank-week-boundary-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, Clock: now, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-rank-week-boundary" senderID := int64(18201) targetID := int64(18202) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) sendGiftForWeeklyContribution(t, ctx, svc, roomID, senderID, targetID, "cmd-rank-week-boundary") now.now = time.Date(2026, 6, 8, 1, 0, 0, 0, time.UTC) rankResp, err := svc.ListRoomContributionRank(ctx, &roomv1.ListRoomContributionRankRequest{ Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, RoomId: roomID, Period: "week", Limit: 10, ViewerUserId: senderID, }) if err != nil { t.Fatalf("list natural week contribution rank failed: %v", err) } wantStart := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC).UnixMilli() if rankResp.GetStartAtMs() != wantStart { t.Fatalf("week contribution rank must start at natural UTC Monday 00:00: got %d want %d", rankResp.GetStartAtMs(), wantStart) } if len(rankResp.GetItems()) != 1 || rankResp.GetItems()[0].GetUserId() != senderID || rankResp.GetItems()[0].GetGiftValue() != 10 { t.Fatalf("week contribution rank must keep 00:30 gift in the same natural week after 01:00: %+v", rankResp.GetItems()) } } func sendGiftForWeeklyContribution(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, senderID int64, targetID int64, commandID string) *roomv1.SendGiftResponse { t.Helper() resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-" + commandID, CommandId: commandID, ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UTC().UnixMilli(), }, TargetType: "user", TargetUserId: targetID, GiftId: "gift-weekly-contribution", GiftCount: 1, }) if err != nil { t.Fatalf("send gift %s failed: %v", commandID, err) } return resp } func adminRoomHeatByID(items []*roomv1.AdminRoomListItem, roomID string) int64 { for _, item := range items { if item.GetRoomId() == roomID { return item.GetHeat() } } return -1 }