package config import ( "testing" "time" "github.com/stretchr/testify/require" ) func TestLoadFromEnvUsesDefaults(t *testing.T) { t.Setenv(redisAddrEnvVar, "127.0.0.1:6379") t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") cfg, err := LoadFromEnv() require.NoError(t, err) defaults := DefaultConfig() require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout) require.Equal(t, defaults.Logging, cfg.Logging) require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP) require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr) require.Equal(t, defaults.Redis.DB, cfg.Redis.DB) require.Equal(t, defaults.Redis.OperationTimeout, cfg.Redis.OperationTimeout) require.Equal(t, defaults.Streams, cfg.Streams) require.Equal(t, defaults.Retry, cfg.Retry) require.Equal(t, UserServiceConfig{ BaseURL: "http://user-service.internal", Timeout: defaults.UserService.Timeout, }, cfg.UserService) require.Equal(t, defaults.AdminRouting, cfg.AdminRouting) require.Equal(t, defaults.Telemetry, cfg.Telemetry) } func TestLoadFromEnvAppliesOverrides(t *testing.T) { t.Setenv(shutdownTimeoutEnvVar, "9s") t.Setenv(logLevelEnvVar, "debug") t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18092") t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s") t.Setenv(internalHTTPReadTimeoutEnvVar, "11s") t.Setenv(internalHTTPIdleTimeoutEnvVar, "61s") t.Setenv(redisAddrEnvVar, "127.0.0.1:6380") t.Setenv(redisUsernameEnvVar, "alice") t.Setenv(redisPasswordEnvVar, "secret") t.Setenv(redisDBEnvVar, "3") t.Setenv(redisTLSEnabledEnvVar, "true") t.Setenv(redisOperationTimeoutEnvVar, "750ms") t.Setenv(intentsStreamEnvVar, "notification:test_intents") t.Setenv(intentsReadBlockTimeoutEnvVar, "3500ms") t.Setenv(gatewayClientEventsStreamEnvVar, "gateway:test_client-events") t.Setenv(gatewayClientEventsStreamMaxEnvVar, "2048") t.Setenv(mailDeliveryCommandsStreamEnvVar, "mail:test_delivery_commands") t.Setenv(pushRetryMaxAttemptsEnvVar, "5") t.Setenv(emailRetryMaxAttemptsEnvVar, "9") t.Setenv(routeLeaseTTLEnvVar, "7s") t.Setenv(routeBackoffMinEnvVar, "2s") t.Setenv(routeBackoffMaxEnvVar, "7m") t.Setenv(deadLetterTTLEnvVar, "120h") t.Setenv(recordTTLEnvVar, "240h") t.Setenv(idempotencyTTLEnvVar, "48h") t.Setenv(userServiceBaseURLEnvVar, "https://user-service.internal/api/") t.Setenv(userServiceTimeoutEnvVar, "1500ms") t.Setenv(adminEmailsGeoReviewRecommendedEnvVar, "First@example.com, second@example.com, first@example.com") t.Setenv(adminEmailsGameGenerationFailedEnvVar, "ops@example.com") t.Setenv(adminEmailsLobbyRuntimePausedAfterEnvVar, "pause@example.com, PAUSE@example.com") t.Setenv(adminEmailsLobbyApplicationSubmittedEnvVar, "owner@example.com, OWNER@example.com") t.Setenv(otelServiceNameEnvVar, "custom-notification") t.Setenv(otelTracesExporterEnvVar, "otlp") t.Setenv(otelMetricsExporterEnvVar, "otlp") t.Setenv(otelExporterOTLPProtocolEnvVar, "grpc") t.Setenv(otelStdoutTracesEnabledEnvVar, "true") t.Setenv(otelStdoutMetricsEnabledEnvVar, "true") cfg, err := LoadFromEnv() require.NoError(t, err) require.Equal(t, 9*time.Second, cfg.ShutdownTimeout) require.Equal(t, "debug", cfg.Logging.Level) require.Equal(t, InternalHTTPConfig{ Addr: "127.0.0.1:18092", ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 11 * time.Second, IdleTimeout: 61 * time.Second, }, cfg.InternalHTTP) require.Equal(t, RedisConfig{ Addr: "127.0.0.1:6380", Username: "alice", Password: "secret", DB: 3, TLSEnabled: true, OperationTimeout: 750 * time.Millisecond, }, cfg.Redis) require.Equal(t, StreamsConfig{ Intents: "notification:test_intents", GatewayClientEvents: "gateway:test_client-events", GatewayClientEventsStreamMaxLen: 2048, MailDeliveryCommands: "mail:test_delivery_commands", }, cfg.Streams) require.Equal(t, 3500*time.Millisecond, cfg.IntentsReadBlockTimeout) require.Equal(t, RetryConfig{ PushMaxAttempts: 5, EmailMaxAttempts: 9, RouteLeaseTTL: 7 * time.Second, RouteBackoffMin: 2 * time.Second, RouteBackoffMax: 7 * time.Minute, DeadLetterTTL: 120 * time.Hour, RecordTTL: 240 * time.Hour, IdempotencyTTL: 48 * time.Hour, }, cfg.Retry) require.Equal(t, UserServiceConfig{ BaseURL: "https://user-service.internal/api", Timeout: 1500 * time.Millisecond, }, cfg.UserService) require.Equal(t, AdminRoutingConfig{ GeoReviewRecommended: []string{"first@example.com", "second@example.com"}, GameGenerationFailed: []string{"ops@example.com"}, LobbyRuntimePausedAfterStart: []string{"pause@example.com"}, LobbyApplicationSubmitted: []string{"owner@example.com"}, }, cfg.AdminRouting) require.Equal(t, TelemetryConfig{ ServiceName: "custom-notification", TracesExporter: "otlp", MetricsExporter: "otlp", TracesProtocol: "grpc", MetricsProtocol: "grpc", StdoutTracesEnabled: true, StdoutMetricsEnabled: true, }, cfg.Telemetry) } func TestLoadFromEnvRejectsInvalidValues(t *testing.T) { tests := []struct { name string envName string envVal string }{ {name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"}, {name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"}, {name: "invalid redis db", envName: redisDBEnvVar, envVal: "db-three"}, {name: "invalid redis tls", envName: redisTLSEnabledEnvVar, envVal: "sometimes"}, {name: "invalid push retries", envName: pushRetryMaxAttemptsEnvVar, envVal: "many"}, {name: "invalid email retries", envName: emailRetryMaxAttemptsEnvVar, envVal: "several"}, {name: "invalid gateway client events stream max len", envName: gatewayClientEventsStreamMaxEnvVar, envVal: "many"}, {name: "invalid user service timeout", envName: userServiceTimeoutEnvVar, envVal: "soon"}, {name: "invalid intents read block timeout", envName: intentsReadBlockTimeoutEnvVar, envVal: "later"}, {name: "invalid route lease ttl", envName: routeLeaseTTLEnvVar, envVal: "eventually"}, {name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "stdout"}, {name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"}, {name: "invalid stdout traces", envName: otelStdoutTracesEnabledEnvVar, envVal: "sometimes"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Setenv(redisAddrEnvVar, "127.0.0.1:6379") t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") t.Setenv(tt.envName, tt.envVal) _, err := LoadFromEnv() require.Error(t, err) }) } } func TestLoadFromEnvRejectsMissingRequiredValues(t *testing.T) { t.Run("missing redis addr", func(t *testing.T) { t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), redisAddrEnvVar) }) t.Run("missing user service base url", func(t *testing.T) { t.Setenv(redisAddrEnvVar, "127.0.0.1:6379") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), userServiceBaseURLEnvVar) }) } func TestLoadFromEnvRejectsInvalidConfiguration(t *testing.T) { tests := []struct { name string envName string envVal string want string }{ {name: "invalid internal http addr", envName: internalHTTPAddrEnvVar, envVal: "127.0.0.1", want: "internal HTTP addr"}, {name: "invalid redis addr", envName: redisAddrEnvVar, envVal: "127.0.0.1", want: "redis addr"}, {name: "relative user service url", envName: userServiceBaseURLEnvVar, envVal: "/internal/users", want: "absolute http(s) URL"}, {name: "invalid admin email", envName: adminEmailsGeoReviewRecommendedEnvVar, envVal: "broken-email", want: "invalid email address"}, {name: "blank admin email slot", envName: adminEmailsGameGenerationFailedEnvVar, envVal: "ops@example.com, , second@example.com", want: "must not be empty"}, {name: "invalid public application admin email", envName: adminEmailsLobbyApplicationSubmittedEnvVar, envVal: "Owner ", want: "must not include a display name"}, {name: "nonpositive gateway client events stream max len", envName: gatewayClientEventsStreamMaxEnvVar, envVal: "0", want: "must be positive"}, {name: "backoff min above max", envName: routeBackoffMinEnvVar, envVal: "10m", want: "must not exceed"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Setenv(redisAddrEnvVar, "127.0.0.1:6379") t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") t.Setenv(routeBackoffMaxEnvVar, "5m") t.Setenv(tt.envName, tt.envVal) _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), tt.want) }) } } func TestLoadFromEnvRejectsNonPositiveValues(t *testing.T) { tests := []struct { name string envName string envVal string }{ {name: "shutdown timeout", envName: shutdownTimeoutEnvVar, envVal: "0s"}, {name: "read header timeout", envName: internalHTTPReadHeaderTimeoutEnvVar, envVal: "0s"}, {name: "read timeout", envName: internalHTTPReadTimeoutEnvVar, envVal: "0s"}, {name: "idle timeout", envName: internalHTTPIdleTimeoutEnvVar, envVal: "0s"}, {name: "redis timeout", envName: redisOperationTimeoutEnvVar, envVal: "0s"}, {name: "intents read block timeout", envName: intentsReadBlockTimeoutEnvVar, envVal: "0s"}, {name: "push retries", envName: pushRetryMaxAttemptsEnvVar, envVal: "0"}, {name: "email retries", envName: emailRetryMaxAttemptsEnvVar, envVal: "0"}, {name: "gateway client events stream max len", envName: gatewayClientEventsStreamMaxEnvVar, envVal: "0"}, {name: "route lease ttl", envName: routeLeaseTTLEnvVar, envVal: "0s"}, {name: "route backoff min", envName: routeBackoffMinEnvVar, envVal: "0s"}, {name: "route backoff max", envName: routeBackoffMaxEnvVar, envVal: "0s"}, {name: "dead letter ttl", envName: deadLetterTTLEnvVar, envVal: "0s"}, {name: "record ttl", envName: recordTTLEnvVar, envVal: "0s"}, {name: "idempotency ttl", envName: idempotencyTTLEnvVar, envVal: "0s"}, {name: "user service timeout", envName: userServiceTimeoutEnvVar, envVal: "0s"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Setenv(redisAddrEnvVar, "127.0.0.1:6379") t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") t.Setenv(tt.envName, tt.envVal) _, err := LoadFromEnv() require.Error(t, err) }) } }