package config import ( "testing" "time" "galaxy/postgres" "galaxy/redisconn" "github.com/stretchr/testify/require" ) const ( envRedisMasterAddr = "NOTIFICATION_REDIS_MASTER_ADDR" envRedisReplicaAddrs = "NOTIFICATION_REDIS_REPLICA_ADDRS" envRedisPassword = "NOTIFICATION_REDIS_PASSWORD" envRedisDB = "NOTIFICATION_REDIS_DB" envRedisOpTimeout = "NOTIFICATION_REDIS_OPERATION_TIMEOUT" envRedisTLSEnabled = "NOTIFICATION_REDIS_TLS_ENABLED" envRedisUsername = "NOTIFICATION_REDIS_USERNAME" envPostgresPrimaryDSN = "NOTIFICATION_POSTGRES_PRIMARY_DSN" envPostgresOpTimeout = "NOTIFICATION_POSTGRES_OPERATION_TIMEOUT" envPostgresMaxOpenConns = "NOTIFICATION_POSTGRES_MAX_OPEN_CONNS" envPostgresMaxIdleConns = "NOTIFICATION_POSTGRES_MAX_IDLE_CONNS" envPostgresConnMaxLife = "NOTIFICATION_POSTGRES_CONN_MAX_LIFETIME" ) const ( defaultPrimaryDSN = "postgres://notificationservice:notificationservice@127.0.0.1:5432/galaxy?search_path=notification&sslmode=disable" ) func setRequiredConnEnv(t *testing.T) { t.Helper() t.Setenv(envRedisMasterAddr, "127.0.0.1:6379") t.Setenv(envRedisPassword, "secret") t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN) t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") } func TestLoadFromEnvUsesDefaults(t *testing.T) { setRequiredConnEnv(t) 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.Conn.MasterAddr) require.Equal(t, "secret", cfg.Redis.Conn.Password) require.Equal(t, defaults.Redis.Conn.DB, cfg.Redis.Conn.DB) require.Equal(t, defaults.Redis.Conn.OperationTimeout, cfg.Redis.Conn.OperationTimeout) require.Equal(t, defaultPrimaryDSN, cfg.Postgres.Conn.PrimaryDSN) require.Equal(t, defaults.Streams, cfg.Streams) require.Equal(t, defaults.Retry, cfg.Retry) require.Equal(t, defaults.Retention, cfg.Retention) 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(envRedisMasterAddr, "127.0.0.1:6380") t.Setenv(envRedisReplicaAddrs, "127.0.0.1:6381,127.0.0.1:6382") t.Setenv(envRedisPassword, "topsecret") t.Setenv(envRedisDB, "3") t.Setenv(envRedisOpTimeout, "750ms") t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN) t.Setenv(envPostgresOpTimeout, "1500ms") t.Setenv(envPostgresMaxOpenConns, "32") t.Setenv(envPostgresMaxIdleConns, "8") t.Setenv(envPostgresConnMaxLife, "45m") 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(idempotencyTTLEnvVar, "48h") t.Setenv(recordRetentionEnvVar, "21d") t.Setenv(malformedIntentRetentionEnvVar, "168h") t.Setenv(cleanupIntervalEnvVar, "30m") 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") // Time package does not support `21d`; use 504h directly. t.Setenv(recordRetentionEnvVar, "504h") 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{ Conn: redisconn.Config{ MasterAddr: "127.0.0.1:6380", ReplicaAddrs: []string{"127.0.0.1:6381", "127.0.0.1:6382"}, Password: "topsecret", DB: 3, OperationTimeout: 750 * time.Millisecond, }, }, cfg.Redis) require.Equal(t, PostgresConfig{ Conn: postgres.Config{ PrimaryDSN: defaultPrimaryDSN, OperationTimeout: 1500 * time.Millisecond, MaxOpenConns: 32, MaxIdleConns: 8, ConnMaxLifetime: 45 * time.Minute, }, }, cfg.Postgres) 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, IdempotencyTTL: 48 * time.Hour, }, cfg.Retry) require.Equal(t, RetentionConfig{ RecordRetention: 504 * time.Hour, MalformedIntentRetention: 168 * time.Hour, CleanupInterval: 30 * time.Minute, }, cfg.Retention) 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 TestLoadFromEnvRejectsDeprecatedRedisVars(t *testing.T) { tests := []struct { name string envName string }{ {name: "tls enabled rejected", envName: envRedisTLSEnabled}, {name: "username rejected", envName: envRedisUsername}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { setRequiredConnEnv(t) t.Setenv(tt.envName, "true") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), tt.envName) }) } } 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: envRedisDB, envVal: "db-three"}, {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 record retention", envName: recordRetentionEnvVar, envVal: "later"}, {name: "invalid malformed intent retention", envName: malformedIntentRetentionEnvVar, envVal: "later"}, {name: "invalid cleanup interval", envName: cleanupIntervalEnvVar, envVal: "later"}, {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) { setRequiredConnEnv(t) t.Setenv(tt.envName, tt.envVal) _, err := LoadFromEnv() require.Error(t, err) }) } } func TestLoadFromEnvRejectsMissingRequiredValues(t *testing.T) { t.Run("missing redis master addr", func(t *testing.T) { t.Setenv(envRedisPassword, "secret") t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN) t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), envRedisMasterAddr) }) t.Run("missing redis password", func(t *testing.T) { t.Setenv(envRedisMasterAddr, "127.0.0.1:6379") t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN) t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), envRedisPassword) }) t.Run("missing postgres primary dsn", func(t *testing.T) { t.Setenv(envRedisMasterAddr, "127.0.0.1:6379") t.Setenv(envRedisPassword, "secret") t.Setenv(userServiceBaseURLEnvVar, "http://user-service.internal") _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), envPostgresPrimaryDSN) }) t.Run("missing user service base url", func(t *testing.T) { t.Setenv(envRedisMasterAddr, "127.0.0.1:6379") t.Setenv(envRedisPassword, "secret") t.Setenv(envPostgresPrimaryDSN, defaultPrimaryDSN) _, err := LoadFromEnv() require.Error(t, err) require.Contains(t, err.Error(), "user service base URL") }) } 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: "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) { setRequiredConnEnv(t) 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: envRedisOpTimeout, 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: "idempotency ttl", envName: idempotencyTTLEnvVar, envVal: "0s"}, {name: "record retention", envName: recordRetentionEnvVar, envVal: "0s"}, {name: "malformed intent retention", envName: malformedIntentRetentionEnvVar, envVal: "0s"}, {name: "cleanup interval", envName: cleanupIntervalEnvVar, envVal: "0s"}, {name: "user service timeout", envName: userServiceTimeoutEnvVar, envVal: "0s"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { setRequiredConnEnv(t) t.Setenv(tt.envName, tt.envVal) _, err := LoadFromEnv() require.Error(t, err) }) } }