package service import ( "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/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/state" ) func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) { now := time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC) cmd := command.SendGift{ Base: command.Base{ CommandID: "cmd-gift-fact", ActorID: 101, Room: "room-gift-fact", }, TargetType: "user", TargetUserID: 202, TargetUserIDs: []int64{202}, GiftID: "gift-rose", PoolID: "pool-lucky", GiftCount: 3, SenderCountryID: 86, SenderRegionID: 9001, SenderDisplayProfile: command.GiftDisplayProfile{ UserID: 101, Username: " sender ", Avatar: " avatar-sender ", DisplayUserID: "S101", PrettyDisplayUserID: "PS101", }, TargetDisplayProfiles: []command.GiftDisplayProfile{{ UserID: 202, Username: " receiver ", Avatar: " avatar-receiver ", DisplayUserID: "R202", PrettyDisplayUserID: "PR202", }}, RobotWalletGift: true, SyntheticLuckyGift: true, } billing := &walletv1.DebitGiftResponse{ BillingReceiptId: "receipt-target-202", CoinSpent: 300, HeatValue: 450, GiftTypeCode: "lucky", CpRelationType: "couple", GiftName: "Rose", GiftIconUrl: "https://asset/icon.png", GiftAnimationUrl: "https://asset/anim.svga", GiftEffectTypes: []string{"room_banner", "global_broadcast"}, } records, err := buildRoomGiftSentRecords("room-gift-fact", 7, now, cmd, RoomMeta{VisibleRegionID: 8008}, []giftTargetBilling{{ TargetUserID: 202, CommandID: "cmd-gift-fact:202", Billing: billing, }}, map[int64]int64{202: 999}) if err != nil { t.Fatalf("build RoomGiftSent record failed: %v", err) } if len(records) != 1 { t.Fatalf("RoomGiftSent must be split per target, got %d records", len(records)) } record := records[0] if record.EventType != "RoomGiftSent" || record.RoomID != "room-gift-fact" || record.Envelope.GetRoomVersion() != 7 { t.Fatalf("outbox envelope mismatch: %+v", record) } var event roomeventsv1.RoomGiftSent if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil { t.Fatalf("unmarshal RoomGiftSent failed: %v", err) } if event.GetSenderUserId() != 101 || event.GetTargetUserId() != 202 || event.GetGiftId() != "gift-rose" || event.GetGiftCount() != 3 { t.Fatalf("basic gift fields mismatch: %+v", &event) } if event.GetBillingReceiptId() != "receipt-target-202" || event.GetCoinSpent() != 300 || event.GetGiftValue() != 450 || event.GetTargetGiftValue() != 999 { t.Fatalf("billing fields mismatch: %+v", &event) } if event.GetVisibleRegionId() != 8008 || event.GetCountryId() != 86 || event.GetRegionId() != 9001 || event.GetCommandId() != "cmd-gift-fact:202" { t.Fatalf("routing/stat fields mismatch: %+v", &event) } if event.GetGiftTypeCode() != "lucky" || event.GetCpRelationType() != "couple" || event.GetGiftName() != "Rose" { t.Fatalf("gift config fields mismatch: %+v", &event) } if event.GetDisplayMode() != "" { t.Fatalf("single-target durable fact must keep legacy display mode empty: %+v", &event) } if !slices.Equal(event.GetGiftEffectTypes(), []string{"room_banner", "global_broadcast"}) { t.Fatalf("gift effect types mismatch: %+v", event.GetGiftEffectTypes()) } if !event.GetIsRobotGift() || !event.GetSyntheticLuckyGift() || !event.GetRealRoomRobotGift() { t.Fatalf("robot/lucky flags mismatch: %+v", &event) } if event.GetSenderName() != "sender" || event.GetReceiverNickname() != "receiver" || event.GetReceiverAvatar() != "avatar-receiver" { t.Fatalf("display profile fields mismatch: %+v", &event) } } func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing.T) { now := time.Date(2026, 6, 27, 10, 30, 0, 0, time.UTC) cmd := command.SendGift{ Base: command.Base{ CommandID: "cmd-gift-batch-facts", ActorID: 101, Room: "room-gift-batch-facts", }, TargetType: "user", TargetUserID: 202, TargetUserIDs: []int64{202, 203}, GiftID: "gift-clover", GiftCount: 1, DisplayMode: "batch", } billing := &walletv1.DebitGiftResponse{ BillingReceiptId: "receipt-target", CoinSpent: 100, HeatValue: 100, } records, err := buildRoomGiftSentRecords("room-gift-batch-facts", 9, now, cmd, RoomMeta{}, []giftTargetBilling{ {TargetUserID: 202, CommandID: "cmd-gift-batch-facts:202", Billing: billing}, {TargetUserID: 203, CommandID: "cmd-gift-batch-facts:203", Billing: billing}, }, nil) if err != nil { t.Fatalf("build batch RoomGiftSent records failed: %v", err) } if len(records) != 2 { t.Fatalf("batch gift facts must stay split per target, got %d records", len(records)) } for _, record := range records { var event roomeventsv1.RoomGiftSent if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil { t.Fatalf("unmarshal batch RoomGiftSent failed: %v", err) } if event.GetTargetUserId() <= 0 || event.GetCommandId() == "" { t.Fatalf("batch RoomGiftSent fact must keep per-target fields: %+v", &event) } if event.GetDisplayMode() != "batch" { t.Fatalf("batch RoomGiftSent fact must be marked for display suppression: %+v", &event) } } } func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T) { cmd := command.SendGift{ Base: command.Base{ CommandID: "cmd-batch-fact", ActorID: 301, Room: "room-batch-fact", }, TargetUserID: 401, TargetUserIDs: []int64{401, 402}, GiftID: "gift-star", PoolID: "pool-batch", GiftCount: 2, DisplayMode: "batch", SenderDisplayProfile: command.GiftDisplayProfile{ UserID: 301, Username: "batch sender", Avatar: "sender-avatar", DisplayUserID: "S301", PrettyDisplayUserID: "PS301", }, TargetDisplayProfiles: []command.GiftDisplayProfile{ {UserID: 401, Username: "target-one", Avatar: "avatar-one", DisplayUserID: "T401", PrettyDisplayUserID: "PT401"}, {UserID: 402, Username: "target-two", Avatar: "avatar-two", DisplayUserID: "T402", PrettyDisplayUserID: "PT402"}, }, } billing := &walletv1.DebitGiftResponse{ BillingReceiptId: "receipt-batch-root", HeatValue: 330, CoinSpent: 220, GiftTypeCode: "normal", CpRelationType: "brother", GiftName: "Star", GiftIconUrl: "icon-star", GiftAnimationUrl: "anim-star", GiftEffectTypes: []string{"room_banner"}, } targetBillings := []giftTargetBilling{ {TargetUserID: 401, CommandID: "cmd-batch-fact:401", Billing: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-401", HeatValue: 110, CoinSpent: 70}}, {TargetUserID: 402, CommandID: "cmd-batch-fact:402", Billing: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-402", HeatValue: 220, CoinSpent: 150}}, } luckyGifts := []*roomv1.LuckyGiftDrawResult{{ Enabled: true, DrawId: "draw-402", CommandId: "cmd-batch-fact:402", PoolId: "pool-batch", GiftId: "gift-star", MultiplierPpm: 2000000, EffectiveRewardCoins: 50, TargetUserId: 402, }} display := buildSendGiftBatchDisplay(cmd, billing, targetBillings, map[int64]int64{401: 1001, 402: 1002}, luckyGifts, 8888) event := roomGiftBatchSentEventFromDisplay(display, 6006) if event.GetSenderUserId() != 301 || event.GetGiftId() != "gift-star" || event.GetGiftCount() != 2 || event.GetCommandId() != "cmd-batch-fact" { t.Fatalf("batch root fields mismatch: %+v", event) } if event.GetTotalGiftValue() != 330 || event.GetTotalCoinSpent() != 220 || event.GetBillingReceiptId() != "receipt-batch-root" || event.GetRoomHeat() != 8888 { t.Fatalf("batch total fields mismatch: %+v", event) } if event.GetVisibleRegionId() != 6006 || !slices.Equal(event.GetTargetUserIds(), []int64{401, 402}) || !slices.Equal(event.GetGiftEffectTypes(), []string{"room_banner"}) { t.Fatalf("batch routing/effect fields mismatch: %+v", event) } if len(event.GetTargets()) != 2 { t.Fatalf("batch targets mismatch: %+v", event.GetTargets()) } if target := event.GetTargets()[0]; target.GetTargetUserId() != 401 || target.GetGiftValue() != 110 || target.GetTargetGiftValue() != 1001 || target.GetBillingReceiptId() != "receipt-401" { t.Fatalf("first batch target mismatch: %+v", target) } if target := event.GetTargets()[1]; target.GetTargetUserId() != 402 || target.GetGiftValue() != 220 || target.GetTargetGiftValue() != 1002 || target.GetLuckyGift().GetDrawId() != "draw-402" { t.Fatalf("second batch target mismatch: %+v", target) } } func TestRoomHeatAndRankChangedRecordsPreserveSnapshotFields(t *testing.T) { now := time.Date(2026, 6, 27, 11, 0, 0, 0, time.UTC) heatRecord, err := buildRoomHeatChangedRecord("room-heat-rank", 12, now, 77, 1007) if err != nil { t.Fatalf("build RoomHeatChanged failed: %v", err) } var heat roomeventsv1.RoomHeatChanged if err := proto.Unmarshal(heatRecord.Envelope.GetBody(), &heat); err != nil { t.Fatalf("unmarshal RoomHeatChanged failed: %v", err) } if heatRecord.EventType != "RoomHeatChanged" || heat.GetDelta() != 77 || heat.GetCurrentHeat() != 1007 { t.Fatalf("heat event mismatch: record=%+v event=%+v", heatRecord, &heat) } rankRecord, err := buildRoomRankChangedRecord("room-heat-rank", 13, now, state.RankItem{UserID: 501, Score: 700, GiftValue: 700}) if err != nil { t.Fatalf("build RoomRankChanged failed: %v", err) } var rank roomeventsv1.RoomRankChanged if err := proto.Unmarshal(rankRecord.Envelope.GetBody(), &rank); err != nil { t.Fatalf("unmarshal RoomRankChanged failed: %v", err) } if rankRecord.EventType != "RoomRankChanged" || rank.GetUserId() != 501 || rank.GetScore() != 700 || rank.GetGiftValue() != 700 { t.Fatalf("rank event mismatch: record=%+v event=%+v", rankRecord, &rank) } }