Files
galaxy-game/lobby/internal/config/config_test.go
T
2026-04-28 20:39:18 +02:00

420 lines
15 KiB
Go

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/game:{engine_version}", cfg.RuntimeManager.EngineImageTemplate)
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("LOBBY_ENGINE_IMAGE_TEMPLATE", "registry.example.com/galaxy/game:{engine_version}")
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, "registry.example.com/galaxy/game:{engine_version}", cfg.RuntimeManager.EngineImageTemplate)
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 TestRuntimeManagerConfigValidate(t *testing.T) {
t.Parallel()
require.NoError(t, RuntimeManagerConfig{EngineImageTemplate: "galaxy/game:{engine_version}"}.Validate())
require.ErrorContains(t,
RuntimeManagerConfig{EngineImageTemplate: ""}.Validate(),
"template must not be empty",
)
require.ErrorContains(t,
RuntimeManagerConfig{EngineImageTemplate: "galaxy/game:1.0.0"}.Validate(),
"placeholder",
)
}
func TestLoadFromEnvRejectsInvalidEngineImageTemplate(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_ENGINE_IMAGE_TEMPLATE", "galaxy/game:no-placeholder")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "LOBBY_ENGINE_IMAGE_TEMPLATE")
}
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,
engineImageTemplateEnvVar,
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))
}
}