package service_test import ( "context" "sync" "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/xerr" "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" ) func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 10, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ BillingReceiptId: "receipt-target-heat", CoinSpent: 1000, ChargeAmount: 1000, HeatValue: 117, GiftTypeCode: "normal", }}} directIM := newRecordingRoomDirectIMPublisher() svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-value", directIM) roomID := "room-user-gift-value" ownerID := int64(61001) targetID := int64(61002) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, targetID, "target-heat-up"), SeatNo: 2, }); err != nil { t.Fatalf("target mic up failed: %v", err) } giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "target-heat-gift"), TargetType: "user", TargetUserId: targetID, GiftId: "gift-target-heat", GiftCount: 1, }) if err != nil { t.Fatalf("send gift failed: %v", err) } if user := onlineUserByID(giftResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 117 { t.Fatalf("target online user gift_value mismatch: %+v", user) } if seat := seatByNo(giftResp.GetRoom(), 2); seat == nil || seat.GetUserId() != targetID || seat.GetGiftValue() != 117 { t.Fatalf("target seat gift_value mismatch after gift: %+v", seat) } giftEvents := roomGiftSentEvents(t, ctx, repository) if len(giftEvents) != 1 || giftEvents[0].GetTargetGiftValue() != 117 { t.Fatalf("RoomGiftSent must carry target cumulative gift value: %+v", giftEvents) } changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{ Meta: rocketMeta(roomID, targetID, "target-heat-change"), TargetUserId: targetID, SeatNo: 5, }) if err != nil { t.Fatalf("change mic failed: %v", err) } if oldSeat := seatByNo(changeResp.GetRoom(), 2); oldSeat == nil || oldSeat.GetUserId() != 0 || oldSeat.GetGiftValue() != 0 { t.Fatalf("old seat must be empty after change: %+v", oldSeat) } if newSeat := seatByNo(changeResp.GetRoom(), 5); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 117 { t.Fatalf("gift value must follow user to new seat: %+v", newSeat) } waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomMicChanged": 2}) if changed := firstDirectMicChangedEventByAction(t, directIM, "change"); changed == nil || changed.GetTargetGiftValue() != 117 { t.Fatalf("direct RoomMicChanged/change must carry target gift value: %+v", changed) } if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed != nil { t.Fatalf("RoomMicChanged/change must not be written to durable outbox: %+v", changed) } if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: rocketMeta(roomID, targetID, "target-heat-leave")}); err != nil { t.Fatalf("leave room failed: %v", err) } joinRocketRoom(t, ctx, svc, roomID, targetID) reupResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, targetID, "target-heat-reup"), SeatNo: 1, }) if err != nil { t.Fatalf("target mic re-up failed: %v", err) } if user := onlineUserByID(reupResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 { t.Fatalf("gift value must reset after leave and rejoin: %+v", user) } if seat := seatByNo(reupResp.GetRoom(), 1); seat == nil || seat.GetUserId() != targetID || seat.GetGiftValue() != 0 { t.Fatalf("rejoined user seat gift_value must be zero: %+v", seat) } } func TestRobotGiftIsDisplayOnlyWithoutRoomPersistenceOrWalletDebit(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{ robotGiftConfigForTest("robot-display-gift", 100, "normal"), }} robotDisplay := newRecordingRobotDisplayPublisher() svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-boundary", robotDisplay) roomID := "robot-room-outbox-boundary" senderID := int64(63001) targetID := int64(63002) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) beforeMain := outboxEventCounts(t, ctx, repository) trackedTables := []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries"} beforeRows := roomTableRows(t, repository, roomID, trackedTables) resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ Meta: rocketMeta(roomID, senderID, "robot-display-only"), TargetUserID: targetID, GiftID: "robot-display-gift", GiftCount: 1, RobotUserIDs: []int64{senderID, targetID}, RealRoomHeat: true, }) if err != nil { t.Fatalf("robot send gift failed: %v", err) } if resp.GetRoomHeat() != 0 || len(resp.GetGiftRank()) != 0 { t.Fatalf("robot display must not change room heat or rank: %+v", resp) } if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 { t.Fatalf("robot display must not change target gift value: %+v", user) } if len(wallet.robotDebitRequests) != 0 || len(wallet.debitRequests) != 0 || wallet.lastBatch != nil { t.Fatalf("robot display must not call any wallet debit: robot=%d single=%d batch=%v", len(wallet.robotDebitRequests), len(wallet.debitRequests), wallet.lastBatch) } if len(wallet.robotCatalogRequests) != 1 || !wallet.robotCatalogRequests[0].GetActiveOnly() || !wallet.robotCatalogRequests[0].GetFilterRegion() { t.Fatalf("robot display must only read the active regional gift catalog once: %+v", wallet.robotCatalogRequests) } assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows) afterMain := outboxEventCounts(t, ctx, repository) if delta := afterMain["RoomGiftSent"] - beforeMain["RoomGiftSent"]; delta != 0 { t.Fatalf("robot gift must not create main gift outbox, delta=%d counts=%+v", delta, afterMain) } if delta := afterMain["RoomHeatChanged"] - beforeMain["RoomHeatChanged"]; delta != 0 { t.Fatalf("robot gift must not create main heat outbox, delta=%d counts=%+v", delta, afterMain) } if delta := afterMain["RoomRankChanged"] - beforeMain["RoomRankChanged"]; delta != 0 { t.Fatalf("robot gift must not create main rank outbox, delta=%d counts=%+v", delta, afterMain) } waitForRobotDisplayCounts(t, robotDisplay, map[string]int{ "RoomGiftSent": 1, }) giftEvents := robotDisplay.roomGiftSentEvents(t) if len(giftEvents) != 1 { t.Fatalf("robot display must publish exactly one gift event: %+v", giftEvents) } if event := giftEvents[0]; event.GetGiftValue() != 0 || event.GetTargetGiftValue() != 0 || event.GetCoinSpent() != 100 { t.Fatalf("robot display must preserve visual price but expose zero business contribution: %+v", event) } if event := giftEvents[0]; event.GetGiftIconUrl() == "" || event.GetGiftAnimationUrl() == "" { t.Fatalf("robot display must keep catalog visual assets: %+v", event) } if counts := robotDisplay.counts(); counts["RoomHeatChanged"] != 0 || counts["RoomRankChanged"] != 0 { t.Fatalf("robot display must not publish fake room state changes: %+v", counts) } } func TestRobotGiftMissingRoomReturnsNotFoundWithoutCatalogOrDebit(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 15, 0, 0, time.UTC)} wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{ robotGiftConfigForTest("missing-room-gift", 100, "normal"), }} display := newRecordingRobotDisplayPublisher() svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-missing-room", display) _, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ Meta: rocketMeta("missing-robot-room", 65001, "robot-missing-room"), TargetUserID: 65002, GiftID: "missing-room-gift", GiftCount: 1, RobotUserIDs: []int64{65001, 65002}, }) if xerr.CodeOf(err) != xerr.NotFound { t.Fatalf("missing robot room must return not found, got %v", err) } if len(wallet.robotCatalogRequests) != 0 || len(wallet.robotDebitRequests) != 0 || len(display.envelopes) != 0 { t.Fatalf("missing room must stop before catalog, debit and IM: catalog=%d debits=%d events=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests), len(display.envelopes)) } } func TestRobotGiftLostOwnershipStopsBeforeCatalogDebitAndIM(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 20, 0, 0, time.UTC)} wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{ robotGiftConfigForTest("lost-owner-gift", 100, "normal"), }} display := newRecordingRobotDisplayPublisher() directory := router.NewMemoryDirectory() svc := roomservice.New(roomservice.Config{ NodeID: "node-robot-old-owner", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 100, Clock: now, RobotDisplayPublisher: display, }, directory, repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "robot-room-lost-owner" senderID := int64(65101) targetID := int64(65102) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) key := roomservice.RuntimeRoomKey(appcode.Default, roomID) directory.ForceExpire(key) if lease, err := directory.EnsureOwner(ctx, key, "node-robot-new-owner", now.Now(), 10*time.Second); err != nil || lease.NodeID != "node-robot-new-owner" { t.Fatalf("move robot room ownership failed: lease=%+v err=%v", lease, err) } beforeRows := roomTableRows(t, repository, roomID, []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries"}) _, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ Meta: rocketMeta(roomID, senderID, "robot-lost-owner"), TargetUserID: targetID, GiftID: "lost-owner-gift", GiftCount: 1, RobotUserIDs: []int64{senderID, targetID}, }) if xerr.CodeOf(err) != xerr.Conflict { t.Fatalf("lost robot owner must return conflict, got %v", err) } if len(wallet.robotCatalogRequests) != 0 || len(wallet.robotDebitRequests) != 0 || len(display.envelopes) != 0 { t.Fatalf("lost owner must stop before catalog, debit and IM: catalog=%d debits=%d events=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests), len(display.envelopes)) } assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows) } func TestRobotGiftDownsamplesDisplayAndKeepsRoomStateUnchanged(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)} wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{ robotGiftConfigForTest("robot-sampler-gift", 1, "normal"), }} robotDisplay := newRecordingRobotDisplayPublisher() svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-sampler", robotDisplay) roomID := "robot-room-outbox-sampler" senderID := int64(63101) targetID := int64(63102) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) beforeMain := outboxEventCounts(t, ctx, repository) firstResp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ Meta: rocketMeta(roomID, senderID, "robot-sampler-first"), TargetUserID: targetID, GiftID: "robot-sampler-gift", GiftCount: 1, RobotUserIDs: []int64{senderID, targetID}, RealRoomHeat: true, }) if err != nil { t.Fatalf("first robot send gift failed: %v", err) } secondResp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ Meta: rocketMeta(roomID, senderID, "robot-sampler-second"), TargetUserID: targetID, GiftID: "robot-sampler-gift", GiftCount: 1, RobotUserIDs: []int64{senderID, targetID}, RealRoomHeat: true, }) if err != nil { t.Fatalf("second robot send gift failed: %v", err) } if firstResp.GetRoomHeat() != 0 || secondResp.GetRoomHeat() != 0 { t.Fatalf("robot display must keep room heat unchanged: first=%d second=%d", firstResp.GetRoomHeat(), secondResp.GetRoomHeat()) } if len(secondResp.GetGiftRank()) != 0 { t.Fatalf("robot display must keep room rank unchanged: %+v", secondResp.GetGiftRank()) } if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 { t.Fatalf("robot display must keep target gift value unchanged: %+v", user) } if len(wallet.robotDebitRequests) != 0 || len(wallet.robotCatalogRequests) != 1 { t.Fatalf("two sampled displays must use one cached catalog read and zero debits: catalog=%d debits=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests)) } afterMain := outboxEventCounts(t, ctx, repository) for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} { if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 { t.Fatalf("robot sampler must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain) } } waitForRobotDisplayCounts(t, robotDisplay, map[string]int{ "RoomGiftSent": 1, }) } func TestHumanRoomRobotGiftIsDisplayOnlyWithoutContributionOrRocket(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)} wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{ robotGiftConfigForTest("real-room-robot-gift", 100, "super_lucky"), }} leaderboard := &recordingRoomGiftLeaderboard{} robotDisplay := newRecordingRobotDisplayPublisher() svc := roomservice.New(roomservice.Config{ NodeID: "node-real-room-robot-display", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 100, Clock: now, RoomGiftLeaderboard: leaderboard, RobotDisplayPublisher: robotDisplay, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000)) roomID := "real-room-robot-outbox" senderID := int64(64001) targetID := int64(64002) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, targetID, "real-room-robot-target-up"), SeatNo: 1, }); err != nil { t.Fatalf("target mic up failed: %v", err) } beforeMain := outboxEventCounts(t, ctx, repository) trackedTables := []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries", "room_user_gift_stats", "room_user_contribution_period_stats"} beforeRows := roomTableRows(t, repository, roomID, trackedTables) resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{ Meta: rocketMeta(roomID, senderID, "real-room-robot-gift"), TargetUserID: targetID, GiftID: "real-room-robot-gift", GiftCount: 1, PoolID: "human_robot_super_lucky_display", RobotUserIDs: []int64{senderID, targetID}, SyntheticMultiplierPPM: 20_000_000, RealRoomHeat: true, }) if err != nil { t.Fatalf("real room robot gift failed: %v", err) } if resp.GetRoomHeat() != 0 { t.Fatalf("human-room robot display must not update room heat, got %d", resp.GetRoomHeat()) } if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 { t.Fatalf("human-room robot display must not update target gift value: %+v", user) } if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 0 { t.Fatalf("human-room robot display must not update mic gift value: %+v", seat) } if len(resp.GetGiftRank()) != 0 { t.Fatalf("human-room robot display must not update room gift rank: %+v", resp.GetGiftRank()) } if rocket := resp.GetRocket(); rocket != nil && (rocket.GetCurrentFuel() != 0 || len(rocket.GetPendingLaunches()) != 0) { t.Fatalf("human-room robot display must not fuel or ignite room rocket: %+v", rocket) } if len(leaderboard.increments) != 0 { t.Fatalf("human-room robot display must not update cross-room contribution: %+v", leaderboard.increments) } if lucky := resp.GetLuckyGift(); lucky == nil || lucky.GetMultiplierPpm() != 20_000_000 || lucky.GetEffectiveRewardCoins() != 2_000 { t.Fatalf("human-room robot lucky display must derive reward from configured multiplier: %+v", lucky) } if len(wallet.robotDebitRequests) != 0 || len(wallet.robotCatalogRequests) != 1 { t.Fatalf("human-room robot display must use one catalog read and zero debits: catalog=%d debits=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests)) } assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows) afterMain := outboxEventCounts(t, ctx, repository) for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} { if delta := afterMain[eventType] - beforeMain[eventType]; delta != 0 { t.Fatalf("real room robot gift must not write %s to main outbox, delta=%d counts=%+v", eventType, delta, afterMain) } } waitForRobotDisplayCounts(t, robotDisplay, map[string]int{ "RoomGiftSent": 1, "RoomRobotLuckyGiftDrawn": 1, }) if counts := robotDisplay.counts(); counts["RoomHeatChanged"] != 0 || counts["RoomRankChanged"] != 0 || counts["RoomRocketFuelChanged"] != 0 || counts["RoomRocketIgnited"] != 0 { t.Fatalf("human-room robot display must not publish fake room state changes: %+v", counts) } } func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 10, 30, 0, 0, time.UTC)} wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ BillingReceiptId: "receipt-replay-target-heat", CoinSpent: 1500, ChargeAmount: 1500, HeatValue: 367, GiftTypeCode: "lucky", }}} svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-replay-writer") roomID := "room-user-gift-replay" ownerID := int64(62001) targetID := int64(62002) createRocketRoom(t, ctx, svc, roomID, ownerID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, targetID, "replay-up"), SeatNo: 2, }); err != nil { t.Fatalf("target mic up failed: %v", err) } if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: rocketMeta(roomID, ownerID, "replay-gift"), TargetType: "user", TargetUserId: targetID, GiftId: "gift-replay-target-heat", GiftCount: 1, }); err != nil { t.Fatalf("send gift failed: %v", err) } if _, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{ Meta: rocketMeta(roomID, targetID, "replay-change"), TargetUserId: targetID, SeatNo: 4, }); err != nil { t.Fatalf("change mic failed: %v", err) } recoveredSvc := newUserGiftValueTestService(repository, &rocketTestWallet{}, now, "node-user-gift-replay-reader") resp, err := recoveredSvc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ Meta: rocketMeta(roomID, ownerID, "replay-snapshot"), RoomId: roomID, ViewerUserId: ownerID, }) if err != nil { t.Fatalf("recover room snapshot failed: %v", err) } if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 367 { t.Fatalf("recovered online user gift_value mismatch: %+v", user) } if oldSeat := seatByNo(resp.GetRoom(), 2); oldSeat == nil || oldSeat.GetUserId() != 0 || oldSeat.GetGiftValue() != 0 { t.Fatalf("recovered old seat must stay empty: %+v", oldSeat) } if newSeat := seatByNo(resp.GetRoom(), 4); newSeat == nil || newSeat.GetUserId() != targetID || newSeat.GetGiftValue() != 367 { t.Fatalf("recovered seat must keep target gift value after change: %+v", newSeat) } } func robotGiftConfigForTest(giftID string, coinPrice int64, giftType string) *walletv1.GiftConfig { return &walletv1.GiftConfig{ AppCode: appcode.Default, GiftId: giftID, Status: "active", Name: giftID, PriceVersion: "robot-display-v1", CoinPrice: coinPrice, HeatValue: coinPrice, GiftTypeCode: giftType, EffectTypes: []string{"center_flight"}, RegionIds: []int64{0}, Resource: &walletv1.Resource{ResourceType: "gift", Status: "active", PreviewUrl: "https://cdn.example/" + giftID + ".png", AnimationUrl: "https://cdn.example/" + giftID + ".svga"}, } } func roomTableRows(t *testing.T, repository *mysqltest.Repository, roomID string, tables []string) map[string]int64 { t.Helper() rows := make(map[string]int64, len(tables)) for _, table := range tables { rows[table] = repository.CountRowsByRoom(table, roomID) } return rows } func assertRoomTableRowsUnchanged(t *testing.T, repository *mysqltest.Repository, roomID string, before map[string]int64) { t.Helper() for table, want := range before { if got := repository.CountRowsByRoom(table, roomID); got != want { t.Fatalf("robot display must not write %s: before=%d after=%d", table, want, got) } } } func outboxEventCounts(t *testing.T, ctx context.Context, repository *mysqltest.Repository) map[string]int { t.Helper() records, err := repository.ListPendingOutbox(ctx, 200) if err != nil { t.Fatalf("list pending outbox failed: %v", err) } counts := make(map[string]int, len(records)) for _, record := range records { counts[record.EventType]++ } return counts } type recordingRobotDisplayPublisher struct { mu sync.Mutex envelopes []*roomeventsv1.EventEnvelope wake chan struct{} } func newRecordingRobotDisplayPublisher() *recordingRobotDisplayPublisher { return &recordingRobotDisplayPublisher{wake: make(chan struct{}, 32)} } func (p *recordingRobotDisplayPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error { p.mu.Lock() p.envelopes = append(p.envelopes, proto.Clone(envelope).(*roomeventsv1.EventEnvelope)) p.mu.Unlock() select { case p.wake <- struct{}{}: default: } return nil } func (p *recordingRobotDisplayPublisher) counts() map[string]int { p.mu.Lock() defer p.mu.Unlock() counts := make(map[string]int, len(p.envelopes)) for _, envelope := range p.envelopes { counts[envelope.GetEventType()]++ } return counts } func (p *recordingRobotDisplayPublisher) roomGiftSentEvents(t *testing.T) []*roomeventsv1.RoomGiftSent { t.Helper() p.mu.Lock() defer p.mu.Unlock() events := make([]*roomeventsv1.RoomGiftSent, 0, len(p.envelopes)) for _, envelope := range p.envelopes { if envelope.GetEventType() != "RoomGiftSent" { continue } var event roomeventsv1.RoomGiftSent if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil { t.Fatalf("decode robot RoomGiftSent failed: %v", err) } events = append(events, &event) } return events } func waitForRobotDisplayCounts(t *testing.T, publisher *recordingRobotDisplayPublisher, want map[string]int) map[string]int { t.Helper() deadline := time.NewTimer(2 * time.Second) defer deadline.Stop() for { counts := publisher.counts() matched := true for eventType, wantCount := range want { if counts[eventType] < wantCount { matched = false break } } if matched { return counts } select { case <-publisher.wake: case <-deadline.C: t.Fatalf("robot display IM events mismatch: want at least %+v got %+v", want, counts) } } } func newUserGiftValueTestService(repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, nodeID string, robotDisplayPublisher ...integration.OutboxPublisher) *roomservice.Service { var publisher integration.OutboxPublisher if len(robotDisplayPublisher) > 0 { publisher = robotDisplayPublisher[0] } return roomservice.New(roomservice.Config{ NodeID: nodeID, LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 100, Clock: clock, RobotDisplayPublisher: publisher, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) } func firstMicChangedEventByAction(t *testing.T, ctx context.Context, repository *mysqltest.Repository, action string) *roomeventsv1.RoomMicChanged { t.Helper() records, err := repository.ListPendingOutbox(ctx, 100) if err != nil { t.Fatalf("list pending outbox failed: %v", err) } for _, record := range records { if record.EventType != "RoomMicChanged" { continue } var event roomeventsv1.RoomMicChanged if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil { t.Fatalf("decode mic changed event failed: %v", err) } if event.GetAction() == action { return &event } } return nil } func firstDirectMicChangedEventByAction(t *testing.T, publisher *recordingRoomDirectIMPublisher, action string) *roomeventsv1.RoomMicChanged { t.Helper() for _, record := range publisher.recordsByType("RoomMicChanged") { var event roomeventsv1.RoomMicChanged if err := proto.Unmarshal(record.GetBody(), &event); err != nil { t.Fatalf("decode direct mic changed event failed: %v", err) } if event.GetAction() == action { return &event } } return nil }