2026-06-27 18:22:14 +08:00

500 lines
18 KiB
Go

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)
}