Files
gochat/backend/internal/config/reloader_test.go
T

284 lines
8.1 KiB
Go

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",
"rate_limit.enabled",
"rate_limit.requests_per_minute",
"rate_limit.window_seconds",
"captain.enabled",
"captain.llm_model",
"captain.max_tokens",
"captain.temperature",
"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.RateLimit.RequestsPerMinute = 100
oldCfg.Captain.Enabled = false
oldCfg.Worker.Concurrency = 4
oldCfg.Database.Host = "original-host" // immutable field
newCfg := validTestConfig()
newCfg.Log.Level = "info"
newCfg.RateLimit.RequestsPerMinute = 200
newCfg.Captain.Enabled = true
newCfg.Worker.Concurrency = 8
newCfg.Database.Host = "changed-host" // 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, 200, r.cfg.RateLimit.RequestsPerMinute)
assert.Equal(t, true, r.cfg.Captain.Enabled)
assert.Equal(t, 8, r.cfg.Worker.Concurrency)
// Immutable fields should NOT be updated
assert.Equal(t, "original-host", r.cfg.Database.Host)
}
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:
host: "localhost"
port: 5432
user: "gochat"
password: "secret"
dbname: "gochat_db"
sslmode: "disable"
redis:
url: "redis://localhost:6379"
jwt:
secret: "test-secret-key-min-32-chars!!"
expiry_hours: 72
log:
level: "debug"
format: "json"
rate_limit:
enabled: true
requests_per_minute: 100
window_seconds: 60
worker:
concurrency: 4
captain:
enabled: false
`
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_RATE_LIMIT_REQUESTS_PER_MINUTE=50 # requests per minute")
require.True(t, ok)
assert.Equal(t, "GOCHAT_RATE_LIMIT_REQUESTS_PER_MINUTE", key)
assert.Equal(t, "50", 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_URL='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_HOST → viper key "database.host" via env prefix + replacer
// The replacer replaces . with _ in viper keys when looking up env vars.
// So viper key "database.host" → env key "DATABASE_HOST" → with prefix "GOCHAT_DATABASE_HOST"
lowerKey := strings.ToLower(strings.ReplaceAll("database.host", ".", "_"))
assert.Equal(t, "database_host", 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, "localhost", v.GetString("database.host"))
assert.Equal(t, 5432, v.GetInt("database.port"))
assert.Equal(t, "disable", v.GetString("database.sslmode"))
assert.Equal(t, "localhost", v.GetString("redis.host"))
assert.Equal(t, 6379, v.GetInt("redis.port"))
assert.Equal(t, "debug", v.GetString("log.level"))
assert.Equal(t, "json", v.GetString("log.format"))
assert.Equal(t, true, v.GetBool("rate_limit.enabled"))
assert.Equal(t, 100, v.GetInt("rate_limit.requests_per_minute"))
assert.Equal(t, 60, v.GetInt("rate_limit.window_seconds"))
assert.Equal(t, 4, v.GetInt("worker.concurrency"))
}
func TestApplyZeroDefaults(t *testing.T) {
cfg := Config{} // all zeros
applyZeroDefaults(&cfg)
assert.Equal(t, 100, cfg.RateLimit.RequestsPerMinute)
assert.Equal(t, 60, cfg.RateLimit.WindowSeconds)
assert.Equal(t, 4, cfg.Worker.Concurrency)
// Non-zero values should not be overwritten
cfg2 := Config{
RateLimit: RateLimitConfig{RequestsPerMinute: 200, WindowSeconds: 30},
Worker: WorkerConfig{Concurrency: 16},
}
applyZeroDefaults(&cfg2)
assert.Equal(t, 200, cfg2.RateLimit.RequestsPerMinute)
assert.Equal(t, 30, cfg2.RateLimit.WindowSeconds)
assert.Equal(t, 16, cfg2.Worker.Concurrency)
}
// --- Helper ---
func validTestConfig() *Config {
return &Config{
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"},
Database: DatabaseConfig{Host: "localhost", Port: 5432, User: "gochat", Password: "secret", Name: "gochat_db", DBName: "gochat_db"},
Redis: RedisConfig{URL: "redis://localhost:6379"},
JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168},
Log: LogConfig{Level: "info", Format: "json"},
Captain: CaptainConfig{Enabled: false},
Worker: WorkerConfig{Concurrency: 4},
OAuth: OAuthConfig{},
RateLimit: RateLimitConfig{Enabled: true, RequestsPerMinute: 100, WindowSeconds: 60},
}
}