// FILE: lixenwraith/config/watch_test.go package config import ( "fmt" "os" "path/filepath" "runtime" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Test-specific timing constants derived from production values // These accelerate test execution while maintaining timing relationships const ( // testAcceleration reduces all intervals by this factor for faster tests testAcceleration = 10 // Accelerated test timings testPollInterval = DefaultPollInterval / testAcceleration // 100ms (from 1s) testDebounce = DefaultDebounce / testAcceleration // 50ms (from 500ms) testReloadTimeout = DefaultReloadTimeout / testAcceleration // 500ms (from 5s) testShutdownTimeout = ShutdownTimeout // Keep original for safety testSpinWaitInterval = SpinWaitInterval // Keep original for CPU efficiency // Test assertion timeouts testEventuallyTimeout = testReloadTimeout // Aligns with reload timing testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation // Derived test multipliers with clear purpose testDebounceSettle = debounceSettleMultiplier * testDebounce // 150ms for debounce verification testPollWindow = 3 * testPollInterval // 300ms change detection window testStateStabilize = 4 * testDebounce // 200ms for state convergence ) // TestAutoUpdate tests automatic configuration reloading func TestAutoUpdate(t *testing.T) { // Setup tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") initialConfig := ` [server] port = 8080 host = "localhost" [features] enabled = true ` require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) // Create config with defaults type TestConfig struct { Server struct { Port int `toml:"port"` Host string `toml:"host"` } `toml:"server"` Features struct { Enabled bool `toml:"enabled"` } `toml:"features"` } defaults := &TestConfig{} defaults.Server.Port = 3000 defaults.Server.Host = "0.0.0.0" // Build config cfg, err := NewBuilder(). WithDefaults(defaults). WithFile(configPath). Build() require.NoError(t, err) // Verify initial values port, exists := cfg.Get("server.port") assert.True(t, exists) assert.Equal(t, int64(8080), port) // Enable auto-update with fast polling opts := WatchOptions{ PollInterval: testPollInterval, Debounce: testDebounce, MaxWatchers: 10, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() // Start watching changes := cfg.Watch() // Collect changes var mu sync.Mutex changedPaths := make(map[string]bool) go func() { for path := range changes { mu.Lock() changedPaths[path] = true mu.Unlock() } }() // Update config file updatedConfig := ` [server] port = 9090 host = "0.0.0.0" [features] enabled = false ` require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0644)) // Wait for changes to be detected time.Sleep(testPollWindow) // Verify new values port, _ = cfg.Get("server.port") assert.Equal(t, int64(9090), port) host, _ := cfg.Get("server.host") assert.Equal(t, "0.0.0.0", host) enabled, _ := cfg.Get("features.enabled") assert.Equal(t, false, enabled) // Check that changes were notified mu.Lock() defer mu.Unlock() expectedChanges := []string{"server.port", "server.host", "features.enabled"} for _, path := range expectedChanges { assert.True(t, changedPaths[path], "Expected change notification for %s", path) } } // TestWatchFileDeleted tests behavior when config file is deleted func TestWatchFileDeleted(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") // Create initial config require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) cfg := New() cfg.Register("test", "default") require.NoError(t, cfg.LoadFile(configPath)) // Enable watching opts := WatchOptions{ PollInterval: testPollInterval, Debounce: testDebounce, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() changes := cfg.Watch() // Delete file require.NoError(t, os.Remove(configPath)) // Wait for deletion detection select { case path := <-changes: assert.Equal(t, "file_deleted", path) case <-time.After(testEventuallyTimeout): t.Error("Timeout waiting for deletion notification") } } // TestWatchPermissionChange tests permission change detection func TestWatchPermissionChange(t *testing.T) { // Skip on Windows where permission model is different if runtime.GOOS == "windows" { t.Skip("Skipping permission test on Windows") } tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") // Create config with specific permissions require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) cfg := New() cfg.Register("test", "default") require.NoError(t, cfg.LoadFile(configPath)) // Enable watching with permission verification opts := WatchOptions{ PollInterval: testPollInterval, Debounce: testDebounce, VerifyPermissions: true, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() changes := cfg.Watch() // Change permissions to world-writable (security risk) require.NoError(t, os.Chmod(configPath, 0666)) // Wait for permission change detection select { case path := <-changes: assert.Equal(t, "permissions_changed", path) case <-time.After(testEventuallyTimeout): t.Error("Timeout waiting for permission change notification") } } // TestMaxWatchers tests watcher limit enforcement func TestMaxWatchers(t *testing.T) { cfg := New() cfg.Register("test", "value") // Create config file tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) require.NoError(t, cfg.LoadFile(configPath)) // Enable watching with low max watchers opts := WatchOptions{ PollInterval: testPollInterval, MaxWatchers: 3, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() // Create maximum allowed watchers channels := make([]<-chan string, 0, 4) for i := 0; i < 4; i++ { ch := cfg.Watch() channels = append(channels, ch) // Check if channel is open if i < 3 { // First 3 should be open select { case _, ok := <-ch: assert.True(t, ok || i < 3, "Channel %d should be open", i) default: // Channel is open and empty, expected } } else { // 4th should be closed immediately select { case _, ok := <-ch: assert.False(t, ok, "Channel 3 should be closed (max watchers exceeded)") case <-time.After(testEventuallyTimeout): t.Error("Channel 3 should be closed immediately") } } } // Verify watcher count assert.Equal(t, 3, cfg.WatcherCount()) } // TestRapidDebounce tests that rapid changes are debounced func TestRapidDebounce(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") // Create initial config require.NoError(t, os.WriteFile(configPath, []byte(`value = 1`), 0644)) cfg := New() cfg.Register("value", 0) require.NoError(t, cfg.LoadFile(configPath)) // Enable watching with longer debounce opts := WatchOptions{ PollInterval: testDebounce, Debounce: testStateStabilize, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() changes := cfg.Watch() var changeCount int var mu sync.Mutex done := make(chan bool) go func() { for { select { case <-changes: mu.Lock() changeCount++ mu.Unlock() case <-done: return } } }() // Make rapid changes for i := 2; i <= 5; i++ { content := fmt.Sprintf(`value = %d`, i) require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) time.Sleep(testDebounce) // Less than debounce period } // Wait for debounce to complete time.Sleep(2 * testStateStabilize) done <- true // Should only see one change due to debounce mu.Lock() defer mu.Unlock() assert.Equal(t, 1, changeCount, "Expected 1 change due to debounce, got %d", changeCount) // Verify final value val, _ := cfg.Get("value") assert.Equal(t, int64(5), val) } // TestWatchWithoutFile tests watching behavior when no file is configured func TestWatchWithoutFile(t *testing.T) { cfg := New() cfg.Register("test", "value") // No file loaded, watch should return closed channel ch := cfg.Watch() select { case _, ok := <-ch: assert.False(t, ok, "Channel should be closed when no file to watch") case <-time.After(10 * time.Millisecond): t.Error("Channel should be closed immediately") } assert.False(t, cfg.IsWatching()) } // TestConcurrentWatchOperations tests thread safety of watch operations func TestConcurrentWatchOperations(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") require.NoError(t, os.WriteFile(configPath, []byte(`value = 1`), 0644)) cfg := New() cfg.Register("value", 0) require.NoError(t, cfg.LoadFile(configPath)) opts := WatchOptions{ PollInterval: testDebounce, MaxWatchers: 50, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() var wg sync.WaitGroup errors := make(chan error, 100) // Start multiple watchers concurrently for i := 0; i < 20; i++ { wg.Add(1) go func(id int) { defer wg.Done() ch := cfg.Watch() if ch == nil { errors <- fmt.Errorf("watcher %d: got nil channel", id) return } // Try to receive select { case <-ch: // OK, got a change case <-time.After(2 * SpinWaitInterval): // OK, no changes yet } }(i) } // Concurrent config updates for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() content := fmt.Sprintf(`value = %d`, id+10) if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { errors <- fmt.Errorf("writer %d: %v", id, err) } }(i) } // Check IsWatching concurrently for i := 0; i < 10; i++ { wg.Add(1) go func(id int) { defer wg.Done() isWatching := false for j := 0; j < 5; j++ { // Poll a few times, double-dip wait for goroutine to start if cfg.IsWatching() { isWatching = true break } time.Sleep(2 * SpinWaitInterval) } if !isWatching { errors <- fmt.Errorf("checker %d: IsWatching returned false", id) } }(i) } wg.Wait() close(errors) // Check for errors var errs []error for err := range errors { errs = append(errs, err) } assert.Empty(t, errs, "Concurrent operations should not produce errors") } // TestReloadTimeout tests reload timeout handling func TestReloadTimeout(t *testing.T) { // This test would require mocking file operations to simulate a slow read // For now, we'll test that timeout option is respected in configuration cfg := New() cfg.Register("test", "value") tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) require.NoError(t, cfg.LoadFile(configPath)) // Very short timeout opts := WatchOptions{ PollInterval: testPollInterval, ReloadTimeout: 1 * time.Nanosecond, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() waitForWatchingState(t, cfg, true) } // TestStopAutoUpdate tests clean shutdown of watcher func TestStopAutoUpdate(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "test.toml") require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) cfg := New() cfg.Register("test", "value") require.NoError(t, cfg.LoadFile(configPath)) // Start watching cfg.AutoUpdate() waitForWatchingState(t, cfg, true, "Watcher should be active after first start") ch := cfg.Watch() // Stop watching cfg.StopAutoUpdate() // Verify stopped waitForWatchingState(t, cfg, false, "Watcher should be inactive after stop") assert.Equal(t, 0, cfg.WatcherCount()) // Channel should eventually close select { case _, ok := <-ch: assert.False(t, ok, "Channel should be closed after stop") case <-time.After(ShutdownTimeout): // OK, channel might not close immediately } // Starting again should work cfg.AutoUpdate() waitForWatchingState(t, cfg, true, "Watcher should be active after restart") cfg.StopAutoUpdate() } // BenchmarkWatchOverhead benchmarks the overhead of file watching func BenchmarkWatchOverhead(b *testing.B) { tmpDir := b.TempDir() configPath := filepath.Join(tmpDir, "bench.toml") // Create config with many values var configContent string for i := 0; i < 100; i++ { configContent += fmt.Sprintf("value%d = %d\n", i, i) } require.NoError(b, os.WriteFile(configPath, []byte(configContent), 0644)) cfg := New() for i := 0; i < 100; i++ { cfg.Register(fmt.Sprintf("value%d", i), 0) } require.NoError(b, cfg.LoadFile(configPath)) // Enable watching opts := WatchOptions{ PollInterval: testPollInterval, } cfg.AutoUpdateWithOptions(opts) defer cfg.StopAutoUpdate() // Benchmark value retrieval with watching enabled b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = cfg.Get(fmt.Sprintf("value%d", i%100)) } }