package config import ( "os" "testing" "time" "galaxy/postgres" "galaxy/redisconn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testDSN = "postgres://lobbyservice:lobbyservice@127.0.0.1:5432/galaxy?search_path=lobby&sslmode=disable" testRedisAddr = "127.0.0.1:6379" testRedisSecret = "secret" testUserBaseURL = "http://user.internal:8090" testGMBaseURL = "http://gm.internal:8091" ) func TestDefaultConfig(t *testing.T) { t.Parallel() cfg := DefaultConfig() assert.Equal(t, 30*time.Second, cfg.ShutdownTimeout) assert.Equal(t, "info", cfg.Logging.Level) assert.Equal(t, ":8094", cfg.PublicHTTP.Addr) assert.Equal(t, ":8095", cfg.InternalHTTP.Addr) assert.Equal(t, redisconn.DefaultOperationTimeout, cfg.Redis.Conn.OperationTimeout) assert.Equal(t, postgres.DefaultOperationTimeout, cfg.Postgres.Conn.OperationTimeout) assert.Equal(t, "gm:lobby_events", cfg.Redis.GMEventsStream) assert.Equal(t, "runtime:start_jobs", cfg.Redis.RuntimeStartJobsStream) assert.Equal(t, "runtime:stop_jobs", cfg.Redis.RuntimeStopJobsStream) assert.Equal(t, "runtime:job_results", cfg.Redis.RuntimeJobResultsStream) assert.Equal(t, "notification:intents", cfg.Redis.NotificationIntentsStream) assert.Equal(t, time.Second, cfg.UserService.Timeout) assert.Equal(t, 5*time.Second, cfg.GM.Timeout) assert.Equal(t, 30*time.Second, cfg.EnrollmentAutomation.Interval) assert.Equal(t, time.Hour, cfg.PendingRegistration.Interval) assert.Equal(t, "galaxy-lobby", cfg.Telemetry.ServiceName) assert.Equal(t, "none", cfg.Telemetry.TracesExporter) assert.Equal(t, "none", cfg.Telemetry.MetricsExporter) } func TestLoadFromEnvAppliesRequiredFields(t *testing.T) { clearAllEnv(t) t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr) t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret) t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN) t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL) t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL) cfg, err := LoadFromEnv() require.NoError(t, err) assert.Equal(t, testRedisAddr, cfg.Redis.Conn.MasterAddr) assert.Equal(t, testRedisSecret, cfg.Redis.Conn.Password) assert.Equal(t, testDSN, cfg.Postgres.Conn.PrimaryDSN) assert.Equal(t, testUserBaseURL, cfg.UserService.BaseURL) assert.Equal(t, testGMBaseURL, cfg.GM.BaseURL) } func TestLoadFromEnvMissingRequiredFields(t *testing.T) { clearAllEnv(t) _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), "LOBBY_REDIS_MASTER_ADDR") } func TestLoadFromEnvRejectsDeprecatedRedisVars(t *testing.T) { tests := []struct { name string envName string }{ {name: "TLS_ENABLED", envName: "LOBBY_REDIS_TLS_ENABLED"}, {name: "USERNAME", envName: "LOBBY_REDIS_USERNAME"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { clearAllEnv(t) t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr) t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret) t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN) t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL) t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL) t.Setenv(tt.envName, "anything") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), tt.envName) }) } } func TestLoadFromEnvOverrides(t *testing.T) { clearAllEnv(t) t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr) t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret) t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN) t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL) t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL) t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "12s") t.Setenv("LOBBY_LOG_LEVEL", "debug") t.Setenv("LOBBY_PUBLIC_HTTP_ADDR", "127.0.0.1:9001") t.Setenv("LOBBY_INTERNAL_HTTP_ADDR", "127.0.0.1:9002") t.Setenv("LOBBY_REDIS_DB", "5") t.Setenv("LOBBY_REDIS_OPERATION_TIMEOUT", "300ms") t.Setenv("LOBBY_GM_EVENTS_STREAM", "alt:gm_events") t.Setenv("LOBBY_NOTIFICATION_INTENTS_STREAM", "alt:intents") t.Setenv("LOBBY_ENROLLMENT_AUTOMATION_INTERVAL", "45s") t.Setenv("LOBBY_RACE_NAME_EXPIRATION_INTERVAL", "15m") t.Setenv("OTEL_SERVICE_NAME", "galaxy-lobby-test") cfg, err := LoadFromEnv() require.NoError(t, err) assert.Equal(t, 12*time.Second, cfg.ShutdownTimeout) assert.Equal(t, "debug", cfg.Logging.Level) assert.Equal(t, "127.0.0.1:9001", cfg.PublicHTTP.Addr) assert.Equal(t, "127.0.0.1:9002", cfg.InternalHTTP.Addr) assert.Equal(t, 5, cfg.Redis.Conn.DB) assert.Equal(t, 300*time.Millisecond, cfg.Redis.Conn.OperationTimeout) assert.Equal(t, "alt:gm_events", cfg.Redis.GMEventsStream) assert.Equal(t, "alt:intents", cfg.Redis.NotificationIntentsStream) assert.Equal(t, 45*time.Second, cfg.EnrollmentAutomation.Interval) assert.Equal(t, 15*time.Minute, cfg.PendingRegistration.Interval) assert.Equal(t, "galaxy-lobby-test", cfg.Telemetry.ServiceName) } func TestLoadFromEnvInvalidDuration(t *testing.T) { clearAllEnv(t) t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr) t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret) t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN) t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL) t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL) t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "not-a-duration") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), "LOBBY_SHUTDOWN_TIMEOUT") } func TestPublicHTTPConfigValidate(t *testing.T) { t.Parallel() tests := []struct { name string mutate func(*PublicHTTPConfig) wantErr string }{ {name: "ok", mutate: func(*PublicHTTPConfig) {}}, {name: "empty addr", mutate: func(cfg *PublicHTTPConfig) { cfg.Addr = "" }, wantErr: "addr must not be empty"}, {name: "malformed addr", mutate: func(cfg *PublicHTTPConfig) { cfg.Addr = "not-an-addr" }, wantErr: "must use host:port"}, {name: "zero read header", mutate: func(cfg *PublicHTTPConfig) { cfg.ReadHeaderTimeout = 0 }, wantErr: "read header timeout"}, {name: "zero read", mutate: func(cfg *PublicHTTPConfig) { cfg.ReadTimeout = 0 }, wantErr: "read timeout"}, {name: "zero idle", mutate: func(cfg *PublicHTTPConfig) { cfg.IdleTimeout = 0 }, wantErr: "idle timeout"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() cfg := DefaultConfig().PublicHTTP tt.mutate(&cfg) err := cfg.Validate() if tt.wantErr == "" { require.NoError(t, err) return } require.Error(t, err) require.Contains(t, err.Error(), tt.wantErr) }) } } func TestInternalHTTPConfigValidate(t *testing.T) { t.Parallel() cfg := DefaultConfig().InternalHTTP require.NoError(t, cfg.Validate()) cfg.Addr = "bogus" err := cfg.Validate() require.Error(t, err) require.Contains(t, err.Error(), "must use host:port") } func TestRedisConfigValidate(t *testing.T) { t.Parallel() base := DefaultConfig().Redis base.Conn.MasterAddr = testRedisAddr base.Conn.Password = testRedisSecret require.NoError(t, base.Validate()) tests := []struct { name string mutate func(*RedisConfig) wantErr string }{ {name: "empty master addr", mutate: func(cfg *RedisConfig) { cfg.Conn.MasterAddr = "" }, wantErr: "master addr"}, {name: "empty password", mutate: func(cfg *RedisConfig) { cfg.Conn.Password = "" }, wantErr: "password"}, {name: "negative db", mutate: func(cfg *RedisConfig) { cfg.Conn.DB = -1 }, wantErr: "must not be negative"}, {name: "zero op timeout", mutate: func(cfg *RedisConfig) { cfg.Conn.OperationTimeout = 0 }, wantErr: "operation timeout"}, {name: "empty gm stream", mutate: func(cfg *RedisConfig) { cfg.GMEventsStream = "" }, wantErr: "gm events stream"}, {name: "zero gm block", mutate: func(cfg *RedisConfig) { cfg.GMEventsReadBlockTimeout = 0 }, wantErr: "gm events read block timeout"}, {name: "empty start jobs", mutate: func(cfg *RedisConfig) { cfg.RuntimeStartJobsStream = "" }, wantErr: "runtime start jobs"}, {name: "empty stop jobs", mutate: func(cfg *RedisConfig) { cfg.RuntimeStopJobsStream = "" }, wantErr: "runtime stop jobs"}, {name: "empty job results", mutate: func(cfg *RedisConfig) { cfg.RuntimeJobResultsStream = "" }, wantErr: "runtime job results"}, {name: "zero job block", mutate: func(cfg *RedisConfig) { cfg.RuntimeJobResultsReadBlockTimeout = 0 }, wantErr: "runtime job results read block"}, {name: "empty intents", mutate: func(cfg *RedisConfig) { cfg.NotificationIntentsStream = "" }, wantErr: "notification intents"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() cfg := base tt.mutate(&cfg) err := cfg.Validate() require.Error(t, err) require.Contains(t, err.Error(), tt.wantErr) }) } } func TestPostgresConfigValidate(t *testing.T) { t.Parallel() base := DefaultConfig().Postgres base.Conn.PrimaryDSN = testDSN require.NoError(t, base.Validate()) bad := base bad.Conn.PrimaryDSN = "" require.ErrorContains(t, bad.Validate(), "primary DSN") } func TestUserServiceConfigValidate(t *testing.T) { t.Parallel() tests := []struct { name string cfg UserServiceConfig wantErr string }{ {name: "ok", cfg: UserServiceConfig{BaseURL: "http://x:1", Timeout: time.Second}}, {name: "empty base url", cfg: UserServiceConfig{Timeout: time.Second}, wantErr: "base url must not be empty"}, {name: "ftp scheme", cfg: UserServiceConfig{BaseURL: "ftp://x", Timeout: time.Second}, wantErr: "absolute http"}, {name: "zero timeout", cfg: UserServiceConfig{BaseURL: "http://x:1"}, wantErr: "timeout must be positive"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() err := tt.cfg.Validate() if tt.wantErr == "" { require.NoError(t, err) return } require.Error(t, err) require.Contains(t, err.Error(), tt.wantErr) }) } } func TestGMConfigValidate(t *testing.T) { t.Parallel() require.NoError(t, GMConfig{BaseURL: "https://gm:443", Timeout: time.Second}.Validate()) require.ErrorContains(t, GMConfig{Timeout: time.Second}.Validate(), "base url must not be empty") require.ErrorContains(t, GMConfig{BaseURL: "http://gm", Timeout: 0}.Validate(), "timeout must be positive") } func TestEnrollmentAutomationConfigValidate(t *testing.T) { t.Parallel() require.NoError(t, EnrollmentAutomationConfig{Interval: time.Second}.Validate()) require.ErrorContains(t, EnrollmentAutomationConfig{}.Validate(), "interval must be positive") } func TestPendingRegistrationConfigValidate(t *testing.T) { t.Parallel() require.NoError(t, PendingRegistrationConfig{Interval: time.Hour}.Validate()) require.ErrorContains(t, PendingRegistrationConfig{}.Validate(), "race name expiration interval must be positive") } func TestTelemetryConfigValidate(t *testing.T) { t.Parallel() require.NoError(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "none"}.Validate()) require.ErrorContains(t, TelemetryConfig{TracesExporter: "weird", MetricsExporter: "none"}.Validate(), "unsupported traces exporter") require.ErrorContains(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "weird"}.Validate(), "unsupported metrics exporter") require.ErrorContains(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "none", TracesProtocol: "ws"}.Validate(), "OTLP traces protocol") require.ErrorContains(t, TelemetryConfig{TracesExporter: "none", MetricsExporter: "none", MetricsProtocol: "ws"}.Validate(), "OTLP metrics protocol") } func TestConfigValidateLogLevel(t *testing.T) { t.Parallel() cfg := DefaultConfig() cfg.Redis.Conn.MasterAddr = testRedisAddr cfg.Redis.Conn.Password = testRedisSecret cfg.Postgres.Conn.PrimaryDSN = testDSN cfg.UserService.BaseURL = "http://u:1" cfg.GM.BaseURL = "http://gm:1" require.NoError(t, cfg.Validate()) cfg.Logging.Level = "bogus" err := cfg.Validate() require.Error(t, err) require.Contains(t, err.Error(), "slog level") } // clearAllEnv unsets every environment variable the config package reads so // tests can configure their expected values explicitly. func clearAllEnv(t *testing.T) { t.Helper() envVars := []string{ shutdownTimeoutEnvVar, logLevelEnvVar, publicHTTPAddrEnvVar, publicHTTPReadHeaderTimeoutEnvVar, publicHTTPReadTimeoutEnvVar, publicHTTPIdleTimeoutEnvVar, internalHTTPAddrEnvVar, internalHTTPReadHeaderTimeoutEnvVar, internalHTTPReadTimeoutEnvVar, internalHTTPIdleTimeoutEnvVar, "LOBBY_REDIS_MASTER_ADDR", "LOBBY_REDIS_REPLICA_ADDRS", "LOBBY_REDIS_PASSWORD", "LOBBY_REDIS_DB", "LOBBY_REDIS_OPERATION_TIMEOUT", "LOBBY_REDIS_TLS_ENABLED", "LOBBY_REDIS_USERNAME", "LOBBY_POSTGRES_PRIMARY_DSN", "LOBBY_POSTGRES_REPLICA_DSNS", "LOBBY_POSTGRES_OPERATION_TIMEOUT", "LOBBY_POSTGRES_MAX_OPEN_CONNS", "LOBBY_POSTGRES_MAX_IDLE_CONNS", "LOBBY_POSTGRES_CONN_MAX_LIFETIME", gmEventsStreamEnvVar, gmEventsReadBlockTimeoutEnvVar, runtimeStartJobsStreamEnvVar, runtimeJobResultsStreamEnvVar, runtimeJobResultsReadBlockTimeoutEnv, notificationIntentsStreamEnvVar, userServiceBaseURLEnvVar, userServiceTimeoutEnvVar, gmBaseURLEnvVar, gmTimeoutEnvVar, enrollmentAutomationIntervalEnvVar, raceNameDirectoryBackendEnvVar, raceNameExpirationIntervalEnvVar, otelServiceNameEnvVar, otelTracesExporterEnvVar, otelMetricsExporterEnvVar, otelExporterOTLPProtocolEnvVar, otelExporterOTLPTracesProtocolEnvVar, otelExporterOTLPMetricsProtocolEnvVar, otelStdoutTracesEnabledEnvVar, otelStdoutMetricsEnabledEnvVar, } for _, name := range envVars { // t.Setenv registers a Cleanup that restores the pre-test value. // Unsetenv after enrolling the cleanup leaves the variable unset for // the duration of the test while still restoring prior state on exit. t.Setenv(name, "") require.NoError(t, os.Unsetenv(name)) } }