Files
junhong_cmp_fiber/pkg/config/watcher_test.go
2025-11-11 15:53:01 +08:00

423 lines
9.2 KiB
Go

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")
}
}