// Package config 验证 room-service 本地 YAML 配置契约。 package config import ( "testing" "time" ) // TestLoad 验证 room-service 的本地 config.yaml 能被正常读取。 func TestLoad(t *testing.T) { // 读取真实本地配置,确保端口和关键下游地址没有漂移。 cfg, err := Load("../../configs/config.yaml") if err != nil { t.Fatalf("Load failed: %v", err) } // room-service 业务只走 gRPC,HTTP health 仅服务 CLB 探活,端口必须保持 13xxx 约束。 if cfg.GRPCAddr != ":13001" { t.Fatalf("unexpected grpc addr: %s", cfg.GRPCAddr) } if cfg.HealthHTTPAddr != ":13101" { t.Fatalf("unexpected health http addr: %s", cfg.HealthHTTPAddr) } if cfg.AdvertiseAddr != "127.0.0.1:13001" || cfg.NodeRegistryTTL != 30*time.Second || cfg.NodeRegistryHeartbeatInterval != 10*time.Second || cfg.OwnerForwardTimeout != 2*time.Second { t.Fatalf("unexpected owner routing config: advertise=%s ttl=%s heartbeat=%s timeout=%s", cfg.AdvertiseAddr, cfg.NodeRegistryTTL, cfg.NodeRegistryHeartbeatInterval, cfg.OwnerForwardTimeout) } // lease TTL 是故障接管抖动窗口的基础参数。 if cfg.LeaseTTL != 10*time.Second { t.Fatalf("unexpected lease ttl: %s", cfg.LeaseTTL) } if cfg.RoomListCacheRefreshInterval != 5*time.Minute { t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval) } // SendGift 主链路依赖 wallet-service,默认地址必须与本地编排一致。 if cfg.WalletServiceAddr != "127.0.0.1:13004" { t.Fatalf("unexpected wallet addr: %s", cfg.WalletServiceAddr) } if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.SecretKey == "" || cfg.TencentIM.AdminIdentifier != "administrator" { t.Fatalf("local room-service must use test Tencent IM account: %+v", cfg.TencentIM) } if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute { t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker) } if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != 200*time.Millisecond || cfg.RobotOutboxWorker.BatchSize != 100 || cfg.RobotOutboxWorker.Concurrency != 1 || cfg.RobotOutboxWorker.PublishTimeout != 15*time.Second || cfg.RobotOutboxWorker.RetryStrategy != "exponential_backoff" || cfg.RobotOutboxWorker.MaxRetryCount != 10 || cfg.RobotOutboxWorker.InitialBackoff != 5*time.Second || cfg.RobotOutboxWorker.MaxBackoff != 5*time.Minute || cfg.RobotOutboxWorker.StaleDiscardAfter != 60*time.Second || cfg.RobotOutboxWorker.CleanupInterval != 30*time.Second || cfg.RobotOutboxWorker.CleanupActiveAfter != 5*time.Minute || cfg.RobotOutboxWorker.CleanupTerminalAfter != time.Hour || cfg.RobotOutboxWorker.CleanupBatchSize != 50000 { t.Fatalf("unexpected robot outbox worker config: %+v", cfg.RobotOutboxWorker) } // 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。 if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RobotRoomOutbox.Enabled || !cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled { t.Fatalf("local config must enable room MQ fanout and IM bridge: %+v", cfg.RocketMQ) } if cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerGroup != "hyapp-test-room-robot-im-bridge" { t.Fatalf("local robot IM bridge group mismatch: %s", cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerGroup) } } // TestLoadTencentExample 验证线上腾讯云 IM 模板能覆盖本地默认配置。 func TestLoadTencentExample(t *testing.T) { cfg, err := Load("../../configs/config.tencent.example.yaml") if err != nil { t.Fatalf("Load tencent example failed: %v", err) } if !cfg.TencentIM.Enabled { t.Fatalf("tencent example should enable Tencent IM bridge") } if cfg.TencentIM.SDKAppID == 0 || cfg.TencentIM.SecretKey == "" { t.Fatalf("sdk_app_id and secret_key templates must be present") } if cfg.TencentIM.AdminIdentifier == "" { t.Fatalf("admin_identifier template must be present") } if cfg.TencentIM.Endpoint == "" || cfg.TencentIM.GroupType == "" { t.Fatalf("endpoint and group_type templates must be present") } if cfg.TencentIM.RequestTimeout != 5*time.Second { t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout) } if cfg.RoomListCacheRefreshInterval != 5*time.Minute { t.Fatalf("unexpected room list cache refresh interval: %s", cfg.RoomListCacheRefreshInterval) } if cfg.AdvertiseAddr == "" || cfg.AdvertiseAddr == "room-service.internal:13001" { t.Fatalf("tencent example must use per-instance advertise_addr, got %q", cfg.AdvertiseAddr) } if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 { t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) } if !cfg.RobotOutboxWorker.Enabled || cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.RobotOutboxWorker.PollInterval != 200*time.Millisecond || cfg.RobotOutboxWorker.BatchSize != 100 || cfg.RobotOutboxWorker.Concurrency != 1 || cfg.RobotOutboxWorker.PublishTimeout != 15*time.Second || cfg.RobotOutboxWorker.StaleDiscardAfter != 60*time.Second || cfg.RobotOutboxWorker.CleanupActiveAfter != 5*time.Minute || cfg.RobotOutboxWorker.CleanupTerminalAfter != time.Hour { t.Fatalf("tencent example must configure robot outbox worker: %+v", cfg.RobotOutboxWorker) } if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RobotRoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled { t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ) } if cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerGroup != "hyapp-room-robot-im-bridge" { t.Fatalf("prod robot IM bridge group mismatch: %s", cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerGroup) } } func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) { cfg, err := Load("../../configs/config.docker.yaml") if err != nil { t.Fatalf("Load docker config failed: %v", err) } if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ { t.Fatalf("docker config should publish room outbox through MQ, got %q", cfg.OutboxWorker.PublishMode) } if cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second { t.Fatalf("docker config must run room outbox with 16 workers and a longer claim window: %+v", cfg.OutboxWorker) } if !cfg.RocketMQ.RoomOutbox.Enabled { t.Fatalf("docker config must enable room outbox MQ") } if !cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled { t.Fatalf("docker mq room outbox must start Tencent IM bridge consumer") } if cfg.RobotOutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RobotRoomOutbox.Enabled || !cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerEnabled { t.Fatalf("docker config must start robot outbox MQ and IM bridge: worker=%+v mq=%+v", cfg.RobotOutboxWorker, cfg.RocketMQ.RobotRoomOutbox) } if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.AdminIdentifier != "administrator" || cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerGroup != "hyapp-test-room-robot-im-bridge" { t.Fatalf("docker config must use test IM account and robot group: im=%+v group=%s", cfg.TencentIM, cfg.RocketMQ.RobotRoomOutbox.TencentIMConsumerGroup) } if !cfg.TencentIM.Enabled { t.Fatalf("Tencent IM bridge consumer requires tencent_im.enabled") } } func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) { cfg := Default() cfg.OutboxWorker.RetryStrategy = "exponential" if _, err := Normalize(cfg); err == nil { t.Fatal("Normalize should reject unsupported outbox retry_strategy") } } func TestNormalizeRejectsUnknownOutboxPublishMode(t *testing.T) { cfg := Default() cfg.OutboxWorker.PublishMode = "random" if _, err := Normalize(cfg); err == nil { t.Fatal("Normalize should reject unsupported outbox publish_mode") } } func TestNormalizeRejectsMQPublishModeWithoutRoomOutboxMQ(t *testing.T) { cfg := Default() cfg.OutboxWorker.PublishMode = OutboxPublishModeMQ if _, err := Normalize(cfg); err == nil { t.Fatal("Normalize should reject mq publish_mode without rocketmq.room_outbox.enabled") } } func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) { cfg := Default() cfg.OutboxWorker.PollInterval = 0 cfg.OutboxWorker.BatchSize = 0 cfg.OutboxWorker.Concurrency = 0 cfg.OutboxWorker.PublishTimeout = 0 cfg.OutboxWorker.RetryStrategy = "" cfg.OutboxWorker.MaxRetryCount = 0 cfg.OutboxWorker.InitialBackoff = 0 cfg.OutboxWorker.MaxBackoff = 0 normalized, err := Normalize(cfg) if err != nil { t.Fatalf("Normalize failed: %v", err) } if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.Concurrency != 1 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute { t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker) } }