package integration import ( "bytes" "context" "encoding/json" "io" "net/http" "testing" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/tencentim" "hyapp/services/room-service/internal/room/outbox" ) func TestRoomEventFromEnvelopeSkipsNonIMEvents(t *testing.T) { record, err := outbox.Build("room-noop", "RoomCreated", 1, time.Now(), &roomeventsv1.RoomCreated{ OwnerUserId: 1, SeatCount: 4, Mode: "social", }) if err != nil { t.Fatalf("Build RoomCreated envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomCreated should be a no-op success: %v", err) } if publish { t.Fatalf("RoomCreated must not publish Tencent IM message: %+v", event) } } func TestRoomUserJoinedCarriesEntryVehicleSnapshot(t *testing.T) { record, err := outbox.Build("room-vehicle", "RoomUserJoined", 9, time.Now(), &roomeventsv1.RoomUserJoined{ UserId: 42, Role: "audience", VisibleRegionId: 86, Nickname: "Join Nina", Avatar: "https://cdn.example.com/nina.png", DisplayUserId: "100042", PrettyDisplayUserId: "888042", EntryVehicle: &roomeventsv1.RoomEntryVehicleSnapshot{ ResourceId: 7001, ResourceCode: "vehicle_gold", Name: "Gold Vehicle", AssetUrl: "https://cdn.example.com/vehicle.png", PreviewUrl: "https://cdn.example.com/vehicle-preview.png", AnimationUrl: "https://cdn.example.com/vehicle.svga", MetadataJson: `{"speed":"fast"}`, EntitlementId: "ent-vehicle", ExpiresAtMs: 1999999999999, }, }) if err != nil { t.Fatalf("Build RoomUserJoined envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomUserJoined should decode: %v", err) } if !publish { t.Fatal("RoomUserJoined must publish Tencent IM message") } if event.EntryVehicle == nil || event.EntryVehicle.ResourceID != 7001 || event.EntryVehicle.AnimationURL != "https://cdn.example.com/vehicle.svga" { t.Fatalf("entry vehicle snapshot mismatch: %+v", event.EntryVehicle) } if event.Attributes["nickname"] != "Join Nina" || event.Attributes["avatar"] != "https://cdn.example.com/nina.png" || event.Attributes["display_user_id"] != "100042" || event.Attributes["pretty_display_user_id"] != "888042" { t.Fatalf("joined display profile attributes mismatch: %+v", event.Attributes) } } func TestRoomBackgroundChangedPublishesDedicatedIMEvent(t *testing.T) { record, err := outbox.Build("room-bg", "RoomBackgroundChanged", 7, time.Now(), &roomeventsv1.RoomBackgroundChanged{ ActorUserId: 42, BackgroundId: 101, RoomBackgroundUrl: "https://cdn.example.com/bg.png", }) if err != nil { t.Fatalf("Build RoomBackgroundChanged envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomBackgroundChanged should decode: %v", err) } if !publish { t.Fatal("RoomBackgroundChanged must publish Tencent IM message") } if event.EventType != "room_background_changed" || event.Attributes["room_background_url"] != "https://cdn.example.com/bg.png" || event.Attributes["background_id"] != "101" { t.Fatalf("background IM event mismatch: %+v", event) } } func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { record, err := outbox.Build("room-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{ SenderUserId: 1001, TargetUserId: 1002, GiftId: "gift_rose", GiftCount: 1, GiftValue: 117, TargetGiftValue: 367, SenderName: "Robot Sender", SenderAvatar: "https://cdn.example/sender.png", SenderDisplayUserId: "100001", ReceiverNickname: "Robot Receiver", ReceiverAvatar: "https://cdn.example/receiver.png", ReceiverDisplayUserId: "100002", }) if err != nil { t.Fatalf("Build RoomGiftSent envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomGiftSent should decode: %v", err) } if !publish { t.Fatal("RoomGiftSent must publish Tencent IM message") } if event.EventType != "room_gift_sent" || event.GiftValue != 117 || event.Attributes["target_gift_value"] != "367" { t.Fatalf("gift IM event target gift value mismatch: %+v", event) } if event.Attributes["sender_name"] != "Robot Sender" || event.Attributes["sender_avatar"] != "https://cdn.example/sender.png" || event.Attributes["sender_display_user_id"] != "100001" { t.Fatalf("gift IM event sender display fields mismatch: %+v", event.Attributes) } if event.Attributes["receiver_nickname"] != "Robot Receiver" || event.Attributes["receiver_avatar"] != "https://cdn.example/receiver.png" || event.Attributes["receiver_display_user_id"] != "100002" { t.Fatalf("gift IM event receiver display fields mismatch: %+v", event.Attributes) } } func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) { record, err := outbox.Build("room-gift-batch-fact", "RoomGiftSent", 9, time.Now(), &roomeventsv1.RoomGiftSent{ SenderUserId: 1001, TargetUserId: 1002, GiftId: "gift_clover", GiftCount: 1, GiftValue: 100, TargetGiftValue: 900, DisplayMode: "batch", }) if err != nil { t.Fatalf("Build batch RoomGiftSent fact failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("batch RoomGiftSent fact should decode without publishing: %v", err) } if publish { t.Fatalf("batch RoomGiftSent fact must not publish single-target IM, event=%+v", event) } } func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) { record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{ SenderUserId: 1001, SenderName: "Batch Sender", GiftId: "gift_clover", GiftCount: 2, TotalGiftValue: 600, TotalCoinSpent: 600, BillingReceiptId: "receipt-batch", RoomHeat: 6600, PoolId: "super_lucky", GiftEffectTypes: []string{"room"}, TargetUserIds: []int64{1002, 1003}, CommandId: "cmd-batch", Targets: []*roomeventsv1.RoomGiftBatchTarget{ { TargetUserId: 1002, ReceiverNickname: "Receiver One", GiftValue: 300, TargetGiftValue: 1300, CommandId: "cmd-batch:target:1002", LuckyGift: &roomeventsv1.RoomGiftBatchLuckyResult{ Enabled: true, DrawId: "draw-1", CommandId: "cmd-batch:target:1002", PoolId: "super_lucky", GiftId: "gift_clover", MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", TargetUserId: 1002, }, }, {TargetUserId: 1003, ReceiverNickname: "Receiver Two", GiftValue: 300, TargetGiftValue: 900, CommandId: "cmd-batch:target:1003"}, }, }) if err != nil { t.Fatalf("Build RoomGiftBatchSent envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomGiftBatchSent should decode: %v", err) } if !publish || event.EventType != "room_gift_batch_sent" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 600 || event.RoomHeat != 6600 { t.Fatalf("batch gift IM event mismatch: publish=%t event=%+v", publish, event) } if event.Attributes["target_user_ids"] != `[1002,1003]` || event.Attributes["target_count"] != "2" { t.Fatalf("batch gift IM target attributes mismatch: %+v", event.Attributes) } if event.Attributes["gift_id"] != "gift_clover" || event.Attributes["gift_count"] != "2" || event.Attributes["command_id"] != "cmd-batch" { t.Fatalf("batch gift IM gift attributes mismatch: %+v", event.Attributes) } if _, exists := event.Attributes["targets"]; exists { t.Fatalf("room batch IM must not include bulky targets: %+v", event.Attributes) } if _, exists := event.Attributes["lucky_gifts"]; exists { t.Fatalf("room batch IM must not include bulky lucky_gifts: %+v", event.Attributes) } if _, exists := event.Attributes["coin_balance_after"]; exists { t.Fatalf("room IM must not expose sender coin balance: %+v", event.Attributes) } payload, err := json.Marshal(event) if err != nil { t.Fatalf("marshal batch gift IM event failed: %v", err) } if len(payload) > 4096 { t.Fatalf("batch gift IM payload should stay small, bytes=%d payload=%s", len(payload), payload) } } func TestRoomHeatAndRankChangedPublishDisplayIMEvents(t *testing.T) { heatRecord, err := outbox.Build("room-robot-heat", "RoomHeatChanged", 9, time.Now(), &roomeventsv1.RoomHeatChanged{ Delta: 120, CurrentHeat: 880, }) if err != nil { t.Fatalf("Build RoomHeatChanged envelope failed: %v", err) } heatEvent, publish, err := roomEventFromEnvelope(heatRecord.Envelope) if err != nil { t.Fatalf("RoomHeatChanged should decode: %v", err) } if !publish || heatEvent.EventType != "room_heat_changed" || heatEvent.RoomHeat != 880 || heatEvent.Attributes["delta"] != "120" { t.Fatalf("heat IM event mismatch: publish=%t event=%+v", publish, heatEvent) } rankRecord, err := outbox.Build("room-robot-rank", "RoomRankChanged", 10, time.Now(), &roomeventsv1.RoomRankChanged{ UserId: 1002, Score: 360, GiftValue: 360, }) if err != nil { t.Fatalf("Build RoomRankChanged envelope failed: %v", err) } rankEvent, publish, err := roomEventFromEnvelope(rankRecord.Envelope) if err != nil { t.Fatalf("RoomRankChanged should decode: %v", err) } if !publish || rankEvent.EventType != "room_rank_changed" || rankEvent.TargetUserID != 1002 || rankEvent.GiftValue != 360 || rankEvent.Attributes["score"] != "360" { t.Fatalf("rank IM event mismatch: publish=%t event=%+v", publish, rankEvent) } } func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) { record, err := outbox.Build("room-robot-lucky", "RoomRobotLuckyGiftDrawn", 11, time.Now(), &roomeventsv1.RoomRobotLuckyGiftDrawn{ DrawId: "robot_lucky_draw_1", CommandId: "cmd_robot_lucky_1", SenderUserId: 1001, TargetUserId: 1002, GiftId: "gift_lucky_1", GiftCount: 1, PoolId: "robot_lucky_display", MultiplierPpm: 5000000, BaseRewardCoins: 500, EffectiveRewardCoins: 500, CreatedAtMs: 1778000000000, VisibleRegionId: 86, IsRobot: true, Synthetic: true, }) if err != nil { t.Fatalf("Build RoomRobotLuckyGiftDrawn envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomRobotLuckyGiftDrawn should decode: %v", err) } if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != 1001 || event.TargetUserID != 1002 { t.Fatalf("robot lucky gift IM event mismatch: publish=%t event=%+v", publish, event) } if event.Attributes["draw_id"] != "robot_lucky_draw_1" || event.Attributes["gift_id"] != "gift_lucky_1" || event.Attributes["pool_id"] != "robot_lucky_display" || event.Attributes["effective_reward_coins"] != "500" || event.Attributes["reward_status"] != "granted" { t.Fatalf("robot lucky gift IM attributes mismatch: %+v", event.Attributes) } } func TestRoomMicChangedCarriesTargetGiftValueInIMAttributes(t *testing.T) { record, err := outbox.Build("room-mic-value", "RoomMicChanged", 9, time.Now(), &roomeventsv1.RoomMicChanged{ ActorUserId: 1001, TargetUserId: 1002, FromSeat: 2, ToSeat: 5, Action: "change", TargetGiftValue: 367, TargetNickname: "Mic Alice", TargetAvatar: "https://cdn.example.test/alice.png", TargetDisplayUserId: "200002", TargetPrettyDisplayUserId: "VIP200002", }) if err != nil { t.Fatalf("Build RoomMicChanged envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomMicChanged should decode: %v", err) } if !publish { t.Fatal("RoomMicChanged must publish Tencent IM message") } if event.EventType != "room_mic_changed" || event.SeatNo != 5 || event.Attributes["target_gift_value"] != "367" { t.Fatalf("mic IM event target gift value mismatch: %+v", event) } if event.Attributes["target_nickname"] != "Mic Alice" || event.Attributes["target_avatar"] != "https://cdn.example.test/alice.png" || event.Attributes["target_display_user_id"] != "200002" || event.Attributes["target_pretty_display_user_id"] != "VIP200002" { t.Fatalf("mic IM target display snapshot mismatch: %+v", event.Attributes) } if _, exists := event.Attributes["nickname"]; exists { t.Fatalf("admin mic change must not expose target snapshot as actor nickname: %+v", event.Attributes) } } func TestRoomMicChangedSelfEventCarriesLegacyDisplayAliases(t *testing.T) { record, err := outbox.Build("room-mic-self", "RoomMicChanged", 10, time.Now(), &roomeventsv1.RoomMicChanged{ ActorUserId: 1002, TargetUserId: 1002, ToSeat: 3, Action: "up", TargetNickname: "Mic Alice", TargetAvatar: "https://cdn.example.test/alice.png", TargetDisplayUserId: "200002", TargetPrettyDisplayUserId: "VIP200002", }) if err != nil { t.Fatalf("Build RoomMicChanged envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomMicChanged should decode: %v", err) } if !publish { t.Fatal("RoomMicChanged must publish Tencent IM message") } if event.Attributes["nickname"] != "Mic Alice" || event.Attributes["avatar"] != "https://cdn.example.test/alice.png" || event.Attributes["display_user_id"] != "200002" || event.Attributes["pretty_display_user_id"] != "VIP200002" { t.Fatalf("self mic IM display aliases mismatch: %+v", event.Attributes) } } func TestRoomPasswordChangedSkipsRoomGroupIM(t *testing.T) { record, err := outbox.Build("room-lock", "RoomPasswordChanged", 8, time.Now(), &roomeventsv1.RoomPasswordChanged{ ActorUserId: 42, Locked: true, VisibleRegionId: 86, }) if err != nil { t.Fatalf("Build RoomPasswordChanged envelope failed: %v", err) } event, publish, err := roomEventFromEnvelope(record.Envelope) if err != nil { t.Fatalf("RoomPasswordChanged should decode: %v", err) } if publish { t.Fatalf("RoomPasswordChanged must use activity region broadcast, not room group IM: %+v", event) } } func TestTencentIMPublisherRemovesGroupMemberBeforeKickMessage(t *testing.T) { paths := make([]string, 0, 2) bodies := make([]map[string]any, 0, 2) client, err := tencentim.NewRESTClient(tencentim.RESTConfig{ SDKAppID: 1400000000, SecretKey: "secret", AdminIdentifier: "administrator", AdminUserSigTTL: time.Hour, HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { paths = append(paths, request.URL.Path) var body map[string]any if err := json.NewDecoder(request.Body).Decode(&body); err != nil { t.Fatalf("decode request body failed: %v", err) } bodies = append(bodies, body) return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"OK","ErrorCode":0,"ErrorInfo":""}`))), Header: make(http.Header), }, nil })}, }) if err != nil { t.Fatalf("NewRESTClient failed: %v", err) } record, err := outbox.Build("room-kick", "RoomUserKicked", 3, time.Now(), &roomeventsv1.RoomUserKicked{ ActorUserId: 1, TargetUserId: 2, }) if err != nil { t.Fatalf("Build RoomUserKicked envelope failed: %v", err) } publisher := NewTencentIMPublisher(client) if err := publisher.PublishOutboxEvent(context.Background(), record.Envelope); err != nil { t.Fatalf("PublishOutboxEvent failed: %v", err) } if len(paths) != 2 || paths[0] != "/v4/group_open_http_svc/delete_group_member" || paths[1] != "/v4/group_open_http_svc/send_group_msg" { t.Fatalf("kick outbox should delete member before system message, paths=%+v", paths) } if bodies[0]["GroupId"] != "room-kick" || bodies[1]["GroupId"] != "room-kick" { t.Fatalf("kick outbox must use original room group id, bodies=%+v", bodies) } } func TestTencentIMPublisherEnsuresRoomGroupAndRetriesWhenGroupMissing(t *testing.T) { paths := make([]string, 0, 3) bodies := make([]map[string]any, 0, 3) client, err := tencentim.NewRESTClient(tencentim.RESTConfig{ SDKAppID: 1400000000, SecretKey: "secret", AdminIdentifier: "administrator", AdminUserSigTTL: time.Hour, HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { paths = append(paths, request.URL.Path) var body map[string]any if err := json.NewDecoder(request.Body).Decode(&body); err != nil { t.Fatalf("decode request body failed: %v", err) } bodies = append(bodies, body) if len(paths) == 1 { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"FAIL","ErrorCode":10015,"ErrorInfo":"bytes_group_id invalid"}`))), Header: make(http.Header), }, nil } return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"OK","ErrorCode":0,"ErrorInfo":""}`))), Header: make(http.Header), }, nil })}, }) if err != nil { t.Fatalf("NewRESTClient failed: %v", err) } publisher := NewTencentIMPublisher(client) err = publisher.PublishRoomEvent(context.Background(), tencentim.RoomEvent{ EventID: "evt-retry", RoomID: "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca", EventType: "room_mic_changed", }) if err != nil { t.Fatalf("PublishRoomEvent should retry after ensuring group: %v", err) } wantPaths := []string{ "/v4/group_open_http_svc/send_group_msg", "/v4/group_open_http_svc/create_group", "/v4/group_open_http_svc/send_group_msg", } if len(paths) != len(wantPaths) { t.Fatalf("path count mismatch: paths=%+v bodies=%+v", paths, bodies) } for index, want := range wantPaths { if paths[index] != want { t.Fatalf("path[%d] mismatch: got %s want %s", index, paths[index], want) } } for index, body := range bodies { if body["GroupId"] != "lalu_2963d975-316e-444d-ad5c-f82fc36c28ca" { t.Fatalf("request %d must preserve original room group id: %+v", index, body) } } } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }