package config import ( "os" "path/filepath" "strings" "sync" "testing" "github.com/fsnotify/fsnotify" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- ConfigReloader Tests --- func TestNewConfigReloader(t *testing.T) { cfg := validTestConfig() r, err := NewConfigReloader(cfg, "test") require.NoError(t, err) require.NotNil(t, r) assert.Equal(t, cfg, r.Config()) r.Stop() } func TestConfigReloader_ConfigThreadSafe(t *testing.T) { cfg := validTestConfig() r, err := NewConfigReloader(cfg, "test") require.NoError(t, err) // Concurrent reads should be safe var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func() { defer wg.Done() c := r.Config() assert.NotNil(t, c) }() } wg.Wait() r.Stop() } func TestConfigReloader_OnChangeCallback(t *testing.T) { cfg := validTestConfig() r, err := NewConfigReloader(cfg, "test") require.NoError(t, err) // Register callback — verify it's registered callbackCount := 0 r.OnChange(func(old, new *Config) { callbackCount++ }) assert.Len(t, r.onChange, 1) r.Stop() } func TestConfigReloader_ReloadableFieldsList(t *testing.T) { // Verify ReloadableFields contains expected entries expectedFields := []string{ "log.level", "log.format", "worker.concurrency", "worker.redis_block_timeout_s", "worker.redis_sweep_interval_s", "server.cors.allowed_origins", } assert.ElementsMatch(t, expectedFields, ReloadableFields) } func TestConfigReloader_ApplyReloadableFields(t *testing.T) { oldCfg := validTestConfig() oldCfg.Log.Level = "debug" oldCfg.Worker.Concurrency = 4 oldCfg.Database.DSN = "postgres://user@original-host:5432/db?sslmode=disable" // immutable field newCfg := validTestConfig() newCfg.Log.Level = "info" newCfg.Worker.Concurrency = 8 newCfg.Database.DSN = "postgres://user@changed-host:5432/db?sslmode=disable" // should NOT be applied r := &ConfigReloader{cfg: oldCfg} r.applyReloadableFields(newCfg) // Reloadable fields should be updated assert.Equal(t, "info", r.cfg.Log.Level) assert.Equal(t, 8, r.cfg.Worker.Concurrency) // Immutable fields should NOT be updated assert.Equal(t, "postgres://user@original-host:5432/db?sslmode=disable", r.cfg.Database.DSN) } func TestConfigReloader_Stop(t *testing.T) { cfg := validTestConfig() r, err := NewConfigReloader(cfg, "test") require.NoError(t, err) r.Stop() // Stop should close stopCh — calling Stop again should not panic r.Stop() } func TestConfigReloader_HandleConfigChange_InvalidConfig(t *testing.T) { cfg := validTestConfig() r := &ConfigReloader{ v: viper.New(), cfg: cfg, } // Simulate a config change event with an event name event := fsnotify.Event{Name: "config.yaml", Op: fsnotify.Write} r.handleConfigChange(event) // Since viper.Unmarshal with a fresh viper returns zero-valued config, // validation should fail and old config should be preserved assert.Equal(t, "info", r.cfg.Log.Level) // unchanged } // --- LoadWithEnv Tests --- func TestLoadWithEnv_ValidConfig(t *testing.T) { // Create a temporary config file tmpDir := t.TempDir() configContent := ` server: host: "0.0.0.0" port: 3000 mode: "debug" database: dsn: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable" redis: dsn: "redis://localhost:6379" jwt: secret: "test-secret-key-min-32-chars!!" expiry_hours: 72 log: level: "debug" format: "json" worker: concurrency: 4 ` err := os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(configContent), 0644) require.NoError(t, err) // Load config from temp directory v := viper.New() v.SetConfigName("config") v.SetConfigType("yaml") v.AddConfigPath(tmpDir) require.NoError(t, v.ReadInConfig()) cfg, err := LoadWithEnv("") // LoadWithEnv uses global viper, so this test verifies the function exists // and doesn't panic. The actual file loading depends on test environment. if err != nil { t.Skip("LoadWithEnv requires config file in ./configs/ — skipping in CI") } assert.NotNil(t, cfg) } func TestLoadWithEnv_EnvironmentOverlay(t *testing.T) { // Verify that LoadWithEnv function exists and handles env parameter // Actual file-based test would need config files present t.Skip("Environment overlay requires config.{env}.yaml files — skipping in CI") } func TestLoadWithEnv_DotEnvFile(t *testing.T) { key, value, ok := parseDotEnvLine("GOCHAT_LOG_LEVEL=debug # log level") require.True(t, ok) assert.Equal(t, "GOCHAT_LOG_LEVEL", key) assert.Equal(t, "debug", value) _, _, ok = parseDotEnvLine("# Comment line should be ignored") assert.False(t, ok) } func TestLoadDotEnv_QuotedValues(t *testing.T) { _, value, ok := parseDotEnvLine(`GOCHAT_JWT_SECRET="my-super-secret-key-with-quotes"`) require.True(t, ok) assert.Equal(t, "my-super-secret-key-with-quotes", value) _, value, ok = parseDotEnvLine("GOCHAT_REDIS_DSN='redis://special-host:6379'") require.True(t, ok) assert.Equal(t, "redis://special-host:6379", value) } // --- Env Binding Tests --- func TestEnvBindingsComplete(t *testing.T) { // Verify env prefix and key replacer work correctly v := viper.New() v.SetEnvPrefix("GOCHAT") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() // GOCHAT_DATABASE_DSN → viper key "database.dsn" via env prefix + replacer // The replacer replaces . with _ in viper keys when looking up env vars. // So viper key "database.dsn" → env key "DATABASE_DSN" → with prefix "GOCHAT_DATABASE_DSN" lowerKey := strings.ToLower(strings.ReplaceAll("database.dsn", ".", "_")) assert.Equal(t, "database_dsn", lowerKey) } // --- Defaults Tests --- func TestSetDefaults(t *testing.T) { v := viper.New() setDefaults(v) assert.Equal(t, "0.0.0.0", v.GetString("server.host")) assert.Equal(t, 3000, v.GetInt("server.port")) assert.Equal(t, "debug", v.GetString("server.mode")) assert.Equal(t, 10, v.GetInt("database.max_idle_conns")) assert.Equal(t, 100, v.GetInt("database.max_open_conns")) assert.Equal(t, 50, v.GetInt("redis.pool_size")) assert.Equal(t, "debug", v.GetString("log.level")) assert.Equal(t, "json", v.GetString("log.format")) assert.Equal(t, 4, v.GetInt("worker.concurrency")) } func TestApplyZeroDefaults(t *testing.T) { cfg := Config{} // all zeros applyZeroDefaults(&cfg) assert.Equal(t, 4, cfg.Worker.Concurrency) // Non-zero values should not be overwritten cfg2 := Config{ Worker: WorkerConfig{Concurrency: 16}, } applyZeroDefaults(&cfg2) assert.Equal(t, 16, cfg2.Worker.Concurrency) } // --- Helper --- func validTestConfig() *Config { return &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168}, Log: LogConfig{Level: "info", Format: "json"}, Worker: WorkerConfig{Concurrency: 4}, OAuth: OAuthConfig{}, } }