e5.0.0 Tests added, bug fixes.

This commit is contained in:
2025-07-19 19:05:17 -04:00
parent 5a58db6108
commit e9b55063ff
19 changed files with 2143 additions and 339 deletions

View File

@ -9,10 +9,14 @@ import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAutoUpdate tests automatic configuration reloading
func TestAutoUpdate(t *testing.T) {
// Create temporary config file
// Setup
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
@ -24,10 +28,7 @@ host = "localhost"
[features]
enabled = true
`
if err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil {
t.Fatal("Failed to write initial config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644))
// Create config with defaults
type TestConfig struct {
@ -49,14 +50,12 @@ enabled = true
WithDefaults(defaults).
WithFile(configPath).
Build()
if err != nil {
t.Fatal("Failed to build config:", err)
}
require.NoError(t, err)
// Verify initial values
if port, _ := cfg.Get("server.port"); port.(int64) != 8080 {
t.Errorf("Expected port 8080, got %d", port)
}
port, exists := cfg.Get("server.port")
assert.True(t, exists)
assert.Equal(t, int64(8080), port)
// Enable auto-update with fast polling
opts := WatchOptions{
@ -91,26 +90,20 @@ host = "0.0.0.0"
[features]
enabled = false
`
if err := os.WriteFile(configPath, []byte(updatedConfig), 0644); err != nil {
t.Fatal("Failed to update config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0644))
// Wait for changes to be detected
time.Sleep(300 * time.Millisecond)
// Verify new values
if port, _ := cfg.Get("server.port"); port.(int64) != 9090 {
t.Errorf("Expected port 9090 after update, got %d", port)
}
port, _ = cfg.Get("server.port")
assert.Equal(t, int64(9090), port)
if host, _ := cfg.Get("server.host"); host.(string) != "0.0.0.0" {
t.Errorf("Expected host 0.0.0.0 after update, got %s", host)
}
host, _ := cfg.Get("server.host")
assert.Equal(t, "0.0.0.0", host)
if enabled, _ := cfg.Get("features.enabled"); enabled.(bool) != false {
t.Errorf("Expected features.enabled to be false after update")
}
enabled, _ := cfg.Get("features.enabled")
assert.Equal(t, false, enabled)
// Check that changes were notified
mu.Lock()
@ -118,27 +111,22 @@ enabled = false
expectedChanges := []string{"server.port", "server.host", "features.enabled"}
for _, path := range expectedChanges {
if !changedPaths[path] {
t.Errorf("Expected change notification for %s", path)
}
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
if err := os.WriteFile(configPath, []byte(`test = "value"`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644))
cfg := New()
cfg.Register("test", "default")
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
require.NoError(t, cfg.LoadFile(configPath))
// Enable watching
opts := WatchOptions{
@ -151,21 +139,18 @@ func TestWatchFileDeleted(t *testing.T) {
changes := cfg.Watch()
// Delete file
if err := os.Remove(configPath); err != nil {
t.Fatal("Failed to delete config:", err)
}
require.NoError(t, os.Remove(configPath))
// Wait for deletion detection
select {
case path := <-changes:
if path != "file_deleted" {
t.Errorf("Expected file_deleted, got %s", path)
}
assert.Equal(t, "file_deleted", path)
case <-time.After(500 * time.Millisecond):
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" {
@ -176,16 +161,11 @@ func TestWatchPermissionChange(t *testing.T) {
configPath := filepath.Join(tmpDir, "test.toml")
// Create config with specific permissions
if err := os.WriteFile(configPath, []byte(`test = "value"`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644))
cfg := New()
cfg.Register("test", "default")
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
require.NoError(t, cfg.LoadFile(configPath))
// Enable watching with permission verification
opts := WatchOptions{
@ -199,21 +179,18 @@ func TestWatchPermissionChange(t *testing.T) {
changes := cfg.Watch()
// Change permissions to world-writable (security risk)
if err := os.Chmod(configPath, 0666); err != nil {
t.Fatal("Failed to change permissions:", err)
}
require.NoError(t, os.Chmod(configPath, 0666))
// Wait for permission change detection
select {
case path := <-changes:
if path != "permissions_changed" {
t.Errorf("Expected permissions_changed, got %s", path)
}
assert.Equal(t, "permissions_changed", path)
case <-time.After(500 * time.Millisecond):
t.Error("Timeout waiting for permission change notification")
}
}
// TestMaxWatchers tests watcher limit enforcement
func TestMaxWatchers(t *testing.T) {
cfg := New()
cfg.Register("test", "value")
@ -221,13 +198,8 @@ func TestMaxWatchers(t *testing.T) {
// Create config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
if err := os.WriteFile(configPath, []byte(`test = "value"`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644))
require.NoError(t, cfg.LoadFile(configPath))
// Enable watching with low max watchers
opts := WatchOptions{
@ -244,45 +216,40 @@ func TestMaxWatchers(t *testing.T) {
channels = append(channels, ch)
// Check if channel is open
select {
case _, ok := <-ch:
if !ok && i < 3 {
t.Errorf("Channel %d should be open", i)
} else if ok && i == 3 {
t.Error("Channel 3 should be closed (max watchers exceeded)")
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
}
default:
// Channel is open and empty, expected for first 3
if i == 3 {
// Try to receive with timeout to verify it's closed
select {
case _, ok := <-ch:
if ok {
t.Error("Channel 3 should be closed")
}
case <-time.After(10 * time.Millisecond):
t.Error("Channel 3 should be closed immediately")
}
} 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(10 * time.Millisecond):
t.Error("Channel 3 should be closed immediately")
}
}
}
// Verify watcher count
assert.Equal(t, 3, cfg.WatcherCount())
}
// TestDebounce tests that rapid changes are debounced
func TestDebounce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
// Create initial config
if err := os.WriteFile(configPath, []byte(`value = 1`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(`value = 1`), 0644))
cfg := New()
cfg.Register("value", 0)
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
require.NoError(t, cfg.LoadFile(configPath))
// Enable watching with longer debounce
opts := WatchOptions{
@ -293,39 +260,211 @@ func TestDebounce(t *testing.T) {
defer cfg.StopAutoUpdate()
changes := cfg.Watch()
changeCount := 0
var changeCount int
var mu sync.Mutex
done := make(chan bool)
go func() {
for range changes {
changeCount++
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)
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
require.NoError(t, os.WriteFile(configPath, []byte(content), 0644))
time.Sleep(50 * time.Millisecond) // Less than debounce period
}
// Wait for debounce to complete
time.Sleep(300 * time.Millisecond)
done <- true
// Should only see one change due to debounce
if changeCount != 1 {
t.Errorf("Expected 1 change due to debounce, got %d", changeCount)
}
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")
if val.(int64) != 5 {
t.Errorf("Expected final value 5, got %d", val)
}
assert.Equal(t, int64(5), val)
}
// Benchmark file watching overhead
// 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: 50 * time.Millisecond,
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(10 * time.Millisecond):
// 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(10 * time.Millisecond)
}
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: 100 * time.Millisecond,
ReloadTimeout: 1 * time.Nanosecond, // Extremely short
}
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(100 * time.Millisecond):
// 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")
@ -335,19 +474,13 @@ func BenchmarkWatchOverhead(b *testing.B) {
for i := 0; i < 100; i++ {
configContent += fmt.Sprintf("value%d = %d\n", i, i)
}
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
b.Fatal("Failed to write config:", err)
}
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)
}
if err := cfg.LoadFile(configPath); err != nil {
b.Fatal("Failed to load config:", err)
}
require.NoError(b, cfg.LoadFile(configPath))
// Enable watching
opts := WatchOptions{
@ -361,4 +494,11 @@ func BenchmarkWatchOverhead(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = cfg.Get(fmt.Sprintf("value%d", i%100))
}
}
// helper function to wait for watcher state, preventing race conditions of goroutine start and test check
func waitForWatchingState(t *testing.T, cfg *Config, expected bool, msgAndArgs ...any) {
require.Eventually(t, func() bool {
return cfg.IsWatching() == expected
}, 200*time.Millisecond, 10*time.Millisecond, msgAndArgs...)
}