package config import ( "context" "os" "path/filepath" "testing" "time" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/spf13/viper" "go.uber.org/zap" "go.uber.org/zap/zaptest" ) // TestWatch tests the config hot reload watcher func TestWatch(t *testing.T) { // Reset viper viper.Reset() // Create temp config file tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "config.yaml") // Initial config initialContent := ` server: address: ":3000" read_timeout: "10s" write_timeout: "10s" shutdown_timeout: "30s" prefork: false redis: address: "localhost" port: 6379 password: "" db: 0 pool_size: 10 min_idle_conns: 5 dial_timeout: "5s" read_timeout: "3s" write_timeout: "3s" logging: level: "info" development: false app_log: filename: "logs/app.log" max_size: 100 max_backups: 30 max_age: 30 compress: true access_log: filename: "logs/access.log" max_size: 500 max_backups: 90 max_age: 90 compress: true middleware: enable_auth: true enable_rate_limiter: false rate_limiter: max: 100 expiration: "1m" storage: "memory" ` if err := os.WriteFile(configFile, []byte(initialContent), 0644); err != nil { t.Fatalf("failed to create config file: %v", err) } // Set config path os.Setenv(constants.EnvConfigPath, configFile) defer os.Unsetenv(constants.EnvConfigPath) // Load initial config cfg, err := Load() if err != nil { t.Fatalf("failed to load initial config: %v", err) } // Verify initial values if cfg.Logging.Level != "info" { t.Fatalf("expected initial logging.level info, got %s", cfg.Logging.Level) } // Create logger for testing logger := zaptest.NewLogger(t) // Start watcher in goroutine with context ctx, cancel := context.WithCancel(context.Background()) defer cancel() go Watch(ctx, logger) // Give watcher time to initialize time.Sleep(100 * time.Millisecond) // Modify config file to trigger hot reload updatedContent := ` server: address: ":8080" read_timeout: "15s" write_timeout: "15s" shutdown_timeout: "30s" prefork: false redis: address: "localhost" port: 6379 password: "" db: 0 pool_size: 20 min_idle_conns: 10 dial_timeout: "5s" read_timeout: "3s" write_timeout: "3s" logging: level: "debug" development: true app_log: filename: "logs/app.log" max_size: 100 max_backups: 30 max_age: 30 compress: true access_log: filename: "logs/access.log" max_size: 500 max_backups: 90 max_age: 90 compress: true middleware: enable_auth: false enable_rate_limiter: true rate_limiter: max: 200 expiration: "2m" storage: "redis" ` if err := os.WriteFile(configFile, []byte(updatedContent), 0644); err != nil { t.Fatalf("failed to update config file: %v", err) } // Wait for watcher to detect and process changes (spec requires detection within 5 seconds) // We use a more aggressive timeout for testing time.Sleep(2 * time.Second) // Verify config was reloaded reloadedCfg := Get() if reloadedCfg.Logging.Level != "debug" { t.Errorf("expected config hot reload, got logging.level %s instead of debug", reloadedCfg.Logging.Level) } if reloadedCfg.Server.Address != ":8080" { t.Errorf("expected config hot reload, got server.address %s instead of :8080", reloadedCfg.Server.Address) } if reloadedCfg.Redis.PoolSize != 20 { t.Errorf("expected config hot reload, got redis.pool_size %d instead of 20", reloadedCfg.Redis.PoolSize) } // Cancel context to stop watcher cancel() // Give watcher time to shut down gracefully time.Sleep(100 * time.Millisecond) } // TestWatch_InvalidConfigRejected tests that invalid config changes are rejected func TestWatch_InvalidConfigRejected(t *testing.T) { // Reset viper viper.Reset() // Create temp config file tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "config.yaml") // Initial valid config validContent := ` server: address: ":3000" read_timeout: "10s" write_timeout: "10s" shutdown_timeout: "30s" prefork: false redis: address: "localhost" port: 6379 password: "" db: 0 pool_size: 10 min_idle_conns: 5 dial_timeout: "5s" read_timeout: "3s" write_timeout: "3s" logging: level: "info" development: false app_log: filename: "logs/app.log" max_size: 100 max_backups: 30 max_age: 30 compress: true access_log: filename: "logs/access.log" max_size: 500 max_backups: 90 max_age: 90 compress: true middleware: enable_auth: true enable_rate_limiter: false rate_limiter: max: 100 expiration: "1m" storage: "memory" ` if err := os.WriteFile(configFile, []byte(validContent), 0644); err != nil { t.Fatalf("failed to create config file: %v", err) } // Set config path os.Setenv(constants.EnvConfigPath, configFile) defer os.Unsetenv(constants.EnvConfigPath) // Load initial config cfg, err := Load() if err != nil { t.Fatalf("failed to load initial config: %v", err) } initialLevel := cfg.Logging.Level if initialLevel != "info" { t.Fatalf("expected initial logging.level info, got %s", initialLevel) } // Create logger for testing logger := zaptest.NewLogger(t) // Start watcher ctx, cancel := context.WithCancel(context.Background()) defer cancel() go Watch(ctx, logger) // Give watcher time to initialize time.Sleep(100 * time.Millisecond) // Write INVALID config (malformed YAML) invalidContent := ` server: address: ":3000" invalid yaml syntax here!!! ` if err := os.WriteFile(configFile, []byte(invalidContent), 0644); err != nil { t.Fatalf("failed to write invalid config: %v", err) } // Wait for watcher to detect changes time.Sleep(2 * time.Second) // Verify config was NOT changed (should keep previous valid config) currentCfg := Get() if currentCfg.Logging.Level != initialLevel { t.Errorf("expected config to remain unchanged after invalid update, got logging.level %s instead of %s", currentCfg.Logging.Level, initialLevel) } // Restore valid config if err := os.WriteFile(configFile, []byte(validContent), 0644); err != nil { t.Fatalf("failed to restore valid config: %v", err) } time.Sleep(500 * time.Millisecond) // Now write config with validation error (timeout out of range) invalidValidationContent := ` server: address: ":3000" read_timeout: "1s" write_timeout: "10s" shutdown_timeout: "30s" redis: address: "localhost" port: 6379 db: 0 pool_size: 10 min_idle_conns: 5 logging: level: "debug" app_log: filename: "logs/app.log" max_size: 100 max_backups: 30 max_age: 30 compress: true access_log: filename: "logs/access.log" max_size: 500 max_backups: 90 max_age: 90 compress: true middleware: enable_auth: true rate_limiter: max: 100 expiration: "1m" storage: "memory" ` if err := os.WriteFile(configFile, []byte(invalidValidationContent), 0644); err != nil { t.Fatalf("failed to write config with validation error: %v", err) } // Wait for watcher to detect changes time.Sleep(2 * time.Second) // Verify config was NOT changed (validation should have failed) finalCfg := Get() if finalCfg.Logging.Level != initialLevel { t.Errorf("expected config to remain unchanged after validation error, got logging.level %s instead of %s", finalCfg.Logging.Level, initialLevel) } if finalCfg.Server.ReadTimeout == 1*time.Second { t.Error("expected config to remain unchanged, but read_timeout was updated to invalid value") } // Cancel context cancel() time.Sleep(100 * time.Millisecond) } // TestWatch_ContextCancellation tests graceful shutdown on context cancellation func TestWatch_ContextCancellation(t *testing.T) { // Reset viper viper.Reset() // Create temp config file tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "config.yaml") content := ` server: address: ":3000" read_timeout: "10s" write_timeout: "10s" shutdown_timeout: "30s" redis: address: "localhost" port: 6379 db: 0 pool_size: 10 min_idle_conns: 5 logging: level: "info" app_log: filename: "logs/app.log" max_size: 100 max_backups: 30 max_age: 30 compress: true access_log: filename: "logs/access.log" max_size: 500 max_backups: 90 max_age: 90 compress: true middleware: enable_auth: true rate_limiter: max: 100 expiration: "1m" storage: "memory" ` if err := os.WriteFile(configFile, []byte(content), 0644); err != nil { t.Fatalf("failed to create config file: %v", err) } os.Setenv(constants.EnvConfigPath, configFile) defer os.Unsetenv(constants.EnvConfigPath) // Load config _, err := Load() if err != nil { t.Fatalf("failed to load config: %v", err) } // Create logger logger := zap.NewNop() // Use no-op logger for this test // Start watcher with context ctx, cancel := context.WithCancel(context.Background()) done := make(chan bool) go func() { Watch(ctx, logger) done <- true }() // Give watcher time to start time.Sleep(100 * time.Millisecond) // Cancel context (simulate graceful shutdown) cancel() // Wait for watcher to stop (should happen quickly) select { case <-done: // Watcher stopped successfully case <-time.After(2 * time.Second): t.Error("watcher did not stop within timeout after context cancellation") } }