package service_test import ( "context" "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/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", }}} svc := newUserGiftValueTestService(repository, wallet, now, "node-user-gift-value") 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) } if changed := firstMicChangedEventByAction(t, ctx, repository, "change"); changed == nil || changed.GetTargetGiftValue() != 117 { t.Fatalf("RoomMicChanged/change must carry target gift value: %+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 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 newUserGiftValueTestService(repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, nodeID string) *roomservice.Service { return roomservice.New(roomservice.Config{ NodeID: nodeID, LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 100, Clock: clock, }, 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 }