v0.2.1 remove testify dep
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-29 01:11:44 -04:00
1 parent e08467bf38
commit 2a47be454d
17 files changed
+845 -851

No files matched your search

+1 -2
View File
@@ -2,8 +2,7 @@
Thread-safe configuration from TOML files, environment variables, command-line Thread-safe configuration from TOML files, environment variables, command-line
arguments and registered defaults. Requires Go 1.27.1. The only runtime dependency arguments and registered defaults. Requires Go 1.27.1. The only runtime dependency
is [lixenwraith/toml](https://github.com/lixenwraith/toml), pinned to merged commit is [lixenwraith/toml](https://github.com/lixenwraith/toml).
`b59d068ff6a4`. Testify and its dependencies are used only by tests.
```go ```go
package main package main
+72 -58
View File
@@ -3,12 +3,8 @@ package config
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestBuilder tests the builder pattern // TestBuilder tests the builder pattern
@@ -29,18 +25,22 @@ func TestBuilder(t *testing.T) {
WithEnvPrefix("TEST_"). WithEnvPrefix("TEST_").
Build() Build()
require.NoError(t, err) mustNoError(t, err)
assert.NotNil(t, cfg) if got := cfg; got == nil {
t.Errorf("got %v, want non-nil", got)
}
val, exists := cfg.Get("host") val, exists := cfg.Get("host")
assert.True(t, exists) if !exists {
assert.Equal(t, "localhost", val) t.Errorf("exists should be true")
}
checkEqual(t, val, "localhost")
}) })
t.Run("BuilderWithAllOptions", func(t *testing.T) { t.Run("BuilderWithAllOptions", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.toml") configFile := filepath.Join(tmpDir, "test.toml")
os.WriteFile(configFile, []byte(`host = "filehost"`), 0644) writeTestFile(t, configFile, []byte(`host = "filehost"`), 0644)
type Config struct { type Config struct {
Host string `toml:"hostname"` Host string `toml:"hostname"`
@@ -69,11 +69,11 @@ func TestBuilder(t *testing.T) {
WithEnvWhitelist("server.hostname"). WithEnvWhitelist("server.hostname").
Build() Build()
require.NoError(t, err) mustNoError(t, err)
// CLI should take precedence // CLI should take precedence
val, _ := cfg.Get("server.hostname") val, _ := cfg.Get("server.hostname")
assert.Equal(t, "clihost", val) checkEqual(t, val, "clihost")
}) })
t.Run("BuilderWithTarget", func(t *testing.T) { t.Run("BuilderWithTarget", func(t *testing.T) {
@@ -96,18 +96,24 @@ func TestBuilder(t *testing.T) {
WithTarget(target). WithTarget(target).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
// Verify paths were registered // Verify paths were registered
paths := cfg.GetRegisteredPaths() paths := cfg.GetRegisteredPaths()
assert.True(t, paths["db.host"]) if !paths["db.host"] {
assert.True(t, paths["db.port"]) t.Errorf("paths[\"db.host\"] should be true")
assert.True(t, paths["cache.ttl"]) }
if !paths["db.port"] {
t.Errorf("paths[\"db.port\"] should be true")
}
if !paths["cache.ttl"] {
t.Errorf("paths[\"cache.ttl\"] should be true")
}
// Test AsStruct // Test AsStruct
result, err := cfg.AsStruct() result, err := cfg.AsStruct()
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, target, result) checkEqual(t, result, target)
}) })
t.Run("BuilderWithValidator", func(t *testing.T) { t.Run("BuilderWithValidator", func(t *testing.T) {
@@ -145,9 +151,13 @@ func TestBuilder(t *testing.T) {
WithValidator(validator). WithValidator(validator).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
assert.NotNil(t, cfg) if got := cfg; got == nil {
assert.True(t, validatorCalled) t.Errorf("got %v, want non-nil", got)
}
if !validatorCalled {
t.Errorf("validatorCalled should be true")
}
// Invalid case // Invalid case
validatorCalled = false validatorCalled = false
@@ -156,10 +166,13 @@ func TestBuilder(t *testing.T) {
WithValidator(validator). WithValidator(validator).
Build() Build()
assert.Nil(t, cfg2) if got := cfg2; got != nil {
assert.Error(t, err) t.Errorf("got %v, want nil", got)
assert.Contains(t, err.Error(), "configuration validation failed") }
assert.True(t, validatorCalled) checkErrorContains(t, err, "configuration validation failed")
if !validatorCalled {
t.Errorf("validatorCalled should be true")
}
}) })
t.Run("BuilderErrorAccumulation", func(t *testing.T) { t.Run("BuilderErrorAccumulation", func(t *testing.T) {
@@ -169,29 +182,29 @@ func TestBuilder(t *testing.T) {
WithDefaults(struct{}{}). WithDefaults(struct{}{}).
Build() Build()
assert.Error(t, err) checkErrorContains(t, err, "unsupported tag name")
assert.Contains(t, err.Error(), "unsupported tag name")
// Invalid target // Invalid target
_, err = NewBuilder(). _, err = NewBuilder().
WithTarget("not-a-pointer"). WithTarget("not-a-pointer").
Build() Build()
assert.Error(t, err) checkErrorContains(t, err, "requires non-nil pointer to struct")
assert.Contains(t, err.Error(), "requires non-nil pointer to struct")
}) })
t.Run("MustBuildPanic", func(t *testing.T) { t.Run("MustBuildPanic", func(t *testing.T) {
// Should not panic with valid config // Should not panic with valid config
assert.NotPanics(t, func() { {
cfg := NewBuilder(). cfg := NewBuilder().
WithDefaults(struct{ Port int }{Port: 8080}). WithDefaults(struct{ Port int }{Port: 8080}).
MustBuild() MustBuild()
assert.NotNil(t, cfg) if got := cfg; got == nil {
}) t.Errorf("got %v, want non-nil", got)
}
}
// Should panic with error // Should panic with error
assert.Panics(t, func() { mustPanic(t, func() {
NewBuilder(). NewBuilder().
WithTagName("invalid"). WithTagName("invalid").
MustBuild() MustBuild()
@@ -205,7 +218,7 @@ func TestFileDiscovery(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Use .toml extension for TOML content // Use .toml extension for TOML content
configFile := filepath.Join(tmpDir, "custom.toml") configFile := filepath.Join(tmpDir, "custom.toml")
os.WriteFile(configFile, []byte(`test = "value"`), 0644) writeTestFile(t, configFile, []byte(`test = "value"`), 0644)
opts := DefaultDiscoveryOptions("myapp") opts := DefaultDiscoveryOptions("myapp")
@@ -217,20 +230,19 @@ func TestFileDiscovery(t *testing.T) {
WithFileDiscovery(opts). WithFileDiscovery(opts).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
// Verify file was loaded // Verify file was loaded
val, _ := cfg.Get("test") val, _ := cfg.Get("test")
assert.Equal(t, "value", val) checkEqual(t, val, "value")
}) })
t.Run("DiscoveryWithEnvVar", func(t *testing.T) { t.Run("DiscoveryWithEnvVar", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "env.toml") configFile := filepath.Join(tmpDir, "env.toml")
os.WriteFile(configFile, []byte(`test = "envvalue"`), 0644) writeTestFile(t, configFile, []byte(`test = "envvalue"`), 0644)
os.Setenv("MYAPP_CONFIG", configFile) t.Setenv("MYAPP_CONFIG", configFile)
defer os.Unsetenv("MYAPP_CONFIG")
opts := DefaultDiscoveryOptions("myapp") opts := DefaultDiscoveryOptions("myapp")
@@ -241,18 +253,17 @@ func TestFileDiscovery(t *testing.T) {
WithFileDiscovery(opts). WithFileDiscovery(opts).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
val, _ := cfg.Get("test") val, _ := cfg.Get("test")
assert.Equal(t, "envvalue", val) checkEqual(t, val, "envvalue")
}) })
t.Run("DiscoveryInCurrentDir", func(t *testing.T) { t.Run("DiscoveryInCurrentDir", func(t *testing.T) {
// Create config in current directory // Discovery runs in an isolated directory and never writes into the checkout.
cwd, _ := os.Getwd() t.Chdir(t.TempDir())
configFile := filepath.Join(cwd, "myapp.toml") configFile := "myapp.toml"
os.WriteFile(configFile, []byte(`test = "cwdvalue"`), 0644) writeTestFile(t, configFile, []byte(`test = "cwdvalue"`), 0644)
defer os.Remove(configFile)
opts := FileDiscoveryOptions{ opts := FileDiscoveryOptions{
Name: "myapp", Name: "myapp",
@@ -267,10 +278,10 @@ func TestFileDiscovery(t *testing.T) {
WithFileDiscovery(opts). WithFileDiscovery(opts).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
val, _ := cfg.Get("test") val, _ := cfg.Get("test")
assert.Equal(t, "cwdvalue", val) checkEqual(t, val, "cwdvalue")
}) })
t.Run("DiscoveryPrecedence", func(t *testing.T) { t.Run("DiscoveryPrecedence", func(t *testing.T) {
@@ -279,12 +290,11 @@ func TestFileDiscovery(t *testing.T) {
// Create multiple config files // Create multiple config files
cliFile := filepath.Join(tmpDir, "cli.toml") cliFile := filepath.Join(tmpDir, "cli.toml")
envFile := filepath.Join(tmpDir, "env.toml") envFile := filepath.Join(tmpDir, "env.toml")
os.WriteFile(cliFile, []byte(`test = "clifile"`), 0644) writeTestFile(t, cliFile, []byte(`test = "clifile"`), 0644)
os.WriteFile(envFile, []byte(`test = "envfile"`), 0644) writeTestFile(t, envFile, []byte(`test = "envfile"`), 0644)
// CLI should take precedence over env // CLI should take precedence over env
os.Setenv("MYAPP_CONFIG", envFile) t.Setenv("MYAPP_CONFIG", envFile)
defer os.Unsetenv("MYAPP_CONFIG")
opts := DefaultDiscoveryOptions("myapp") opts := DefaultDiscoveryOptions("myapp")
@@ -296,10 +306,10 @@ func TestFileDiscovery(t *testing.T) {
WithFileDiscovery(opts). WithFileDiscovery(opts).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
val, _ := cfg.Get("test") val, _ := cfg.Get("test")
assert.Equal(t, "clifile", val) checkEqual(t, val, "clifile")
}) })
} }
@@ -323,7 +333,7 @@ func TestBuilderWithTypedValidator(t *testing.T) {
WithTypedValidator(validator). WithTypedValidator(validator).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
}) })
// Case 2: Invalid configuration // Case 2: Invalid configuration
@@ -341,8 +351,10 @@ func TestBuilderWithTypedValidator(t *testing.T) {
WithTypedValidator(validator). WithTypedValidator(validator).
Build() Build()
require.Error(t, err) if err == nil {
assert.Contains(t, err.Error(), "typed configuration validation failed: port too low") t.Fatalf("expected an error")
}
checkErrorContains(t, err, "typed configuration validation failed: port too low")
}) })
// Case 3: Mismatched validator signature // Case 3: Mismatched validator signature
@@ -357,7 +369,9 @@ func TestBuilderWithTypedValidator(t *testing.T) {
WithTypedValidator(validator). WithTypedValidator(validator).
Build() Build()
require.Error(t, err) if err == nil {
assert.Contains(t, err.Error(), "typed validator signature") t.Fatalf("expected an error")
}
checkErrorContains(t, err, "typed validator signature")
}) })
} }
+181 -111
View File
@@ -5,23 +5,23 @@ import (
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestConfigCreation tests various config creation patterns // TestConfigCreation tests various config creation patterns
func TestConfigCreation(t *testing.T) { func TestConfigCreation(t *testing.T) {
t.Run("NewWithDefaultOptions", func(t *testing.T) { t.Run("NewWithDefaultOptions", func(t *testing.T) {
cfg := New() cfg := New()
require.NotNil(t, cfg) if got := cfg; got == nil {
assert.NotNil(t, cfg.items) t.Fatalf("got %v, want non-nil", got)
assert.Equal(t, []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault}, cfg.options.Sources) }
if got := cfg.items; got == nil {
t.Errorf("got %v, want non-nil", got)
}
checkEqual(t, cfg.options.Sources, []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault})
}) })
t.Run("NewWithCustomOptions", func(t *testing.T) { t.Run("NewWithCustomOptions", func(t *testing.T) {
@@ -30,9 +30,11 @@ func TestConfigCreation(t *testing.T) {
EnvPrefix: "MYAPP_", EnvPrefix: "MYAPP_",
} }
cfg := NewWithOptions(opts) cfg := NewWithOptions(opts)
require.NotNil(t, cfg) if got := cfg; got == nil {
assert.Equal(t, opts.Sources, cfg.options.Sources) t.Fatalf("got %v, want non-nil", got)
assert.Equal(t, "MYAPP_", cfg.options.EnvPrefix) }
checkEqual(t, cfg.options.Sources, opts.Sources)
checkEqual(t, cfg.options.EnvPrefix, "MYAPP_")
}) })
} }
@@ -61,13 +63,16 @@ func TestPathRegistration(t *testing.T) {
cfg := New() cfg := New()
err := cfg.Register(tt.path, tt.defaultVal) err := cfg.Register(tt.path, tt.defaultVal)
if tt.expectError { if tt.expectError {
assert.Error(t, err) checkErrorContains(t, err, tt.errorMsg)
assert.Contains(t, err.Error(), tt.errorMsg)
} else { } else {
assert.NoError(t, err) if err != nil {
t.Errorf("unexpected error: %v", err)
}
val, exists := cfg.Get(tt.path) val, exists := cfg.Get(tt.path)
assert.True(t, exists) if !exists {
assert.Equal(t, tt.defaultVal, val) t.Errorf("exists should be true")
}
checkEqual(t, val, tt.defaultVal)
} }
}) })
} }
@@ -106,45 +111,68 @@ func TestComplexStructRegistration(t *testing.T) {
t.Run("TOMLTags", func(t *testing.T) { t.Run("TOMLTags", func(t *testing.T) {
cfg := New() cfg := New()
err := cfg.RegisterStruct("", defaultConfig) err := cfg.RegisterStruct("", defaultConfig)
require.NoError(t, err) mustNoError(t, err)
// Verify paths registered with TOML tags // Verify paths registered with TOML tags
paths := cfg.GetRegisteredPaths("") paths := cfg.GetRegisteredPaths("")
assert.True(t, paths["name"]) if !paths["name"] {
assert.True(t, paths["db.host"]) t.Errorf("paths[\"name\"] should be true")
assert.True(t, paths["db.port"]) }
assert.True(t, paths["db.max_connections"]) if !paths["db.host"] {
assert.True(t, paths["db.timeout"]) t.Errorf("paths[\"db.host\"] should be true")
assert.True(t, paths["db.debug"]) }
assert.True(t, paths["tags"]) if !paths["db.port"] {
assert.True(t, paths["metadata"]) t.Errorf("paths[\"db.port\"] should be true")
}
if !paths["db.max_connections"] {
t.Errorf("paths[\"db.max_connections\"] should be true")
}
if !paths["db.timeout"] {
t.Errorf("paths[\"db.timeout\"] should be true")
}
if !paths["db.debug"] {
t.Errorf("paths[\"db.debug\"] should be true")
}
if !paths["tags"] {
t.Errorf("paths[\"tags\"] should be true")
}
if !paths["metadata"] {
t.Errorf("paths[\"metadata\"] should be true")
}
// Verify default values // Verify default values
val, _ := cfg.Get("db.timeout") val, _ := cfg.Get("db.timeout")
assert.Equal(t, 30*time.Second, val) checkEqual(t, val, 30*time.Second)
}) })
t.Run("RejectJSONTags", func(t *testing.T) { t.Run("RejectJSONTags", func(t *testing.T) {
cfg := New() cfg := New()
require.Error(t, cfg.RegisterStructWithTags("", defaultConfig, "json")) if err := cfg.RegisterStructWithTags("", defaultConfig, "json"); err == nil {
require.Empty(t, cfg.GetRegisteredPaths()) t.Fatalf("expected an error")
}
if got := len(cfg.GetRegisteredPaths()); got != 0 {
t.Fatalf("length = %d, want %d", got, 0)
}
}) })
t.Run("UnsupportedTag", func(t *testing.T) { t.Run("UnsupportedTag", func(t *testing.T) {
cfg := New() cfg := New()
err := cfg.RegisterStructWithTags("", defaultConfig, "xml") err := cfg.RegisterStructWithTags("", defaultConfig, "xml")
assert.Error(t, err) checkErrorContains(t, err, "unsupported tag name")
assert.Contains(t, err.Error(), "unsupported tag name")
}) })
t.Run("WithPrefix", func(t *testing.T) { t.Run("WithPrefix", func(t *testing.T) {
cfg := New() cfg := New()
err := cfg.RegisterStruct("server", defaultConfig) err := cfg.RegisterStruct("server", defaultConfig)
require.NoError(t, err) mustNoError(t, err)
paths := cfg.GetRegisteredPaths("server.") paths := cfg.GetRegisteredPaths("server.")
assert.True(t, paths["server.name"]) if !paths["server.name"] {
assert.True(t, paths["server.db.host"]) t.Errorf("paths[\"server.name\"] should be true")
}
if !paths["server.db.host"] {
t.Errorf("paths[\"server.db.host\"] should be true")
}
}) })
} }
@@ -160,24 +188,24 @@ func TestSourcePrecedence(t *testing.T) {
// Default precedence: CLI > Env > File > Default // Default precedence: CLI > Env > File > Default
val, _ := cfg.Get("test.value") val, _ := cfg.Get("test.value")
assert.Equal(t, "from-cli", val) checkEqual(t, val, "from-cli")
// Remove CLI value // Remove CLI value
cfg.ResetSource(SourceCLI) cfg.ResetSource(SourceCLI)
val, _ = cfg.Get("test.value") val, _ = cfg.Get("test.value")
assert.Equal(t, "from-env", val) checkEqual(t, val, "from-env")
// Change precedence // Change precedence
cfg.SetLoadOptions(LoadOptions{ cfg.SetLoadOptions(LoadOptions{
Sources: []Source{SourceFile, SourceEnv, SourceCLI, SourceDefault}, Sources: []Source{SourceFile, SourceEnv, SourceCLI, SourceDefault},
}) })
val, _ = cfg.Get("test.value") val, _ = cfg.Get("test.value")
assert.Equal(t, "from-file", val) checkEqual(t, val, "from-file")
// Test GetSources // Test GetSources
sources := cfg.GetSources("test.value") sources := cfg.GetSources("test.value")
assert.Equal(t, "from-file", sources[SourceFile]) checkEqual(t, sources[SourceFile], "from-file")
assert.Equal(t, "from-env", sources[SourceEnv]) checkEqual(t, sources[SourceEnv], "from-env")
} }
// TestSetPrecedence tests runtime precedence switching // TestSetPrecedence tests runtime precedence switching
@@ -193,18 +221,18 @@ func TestSetPrecedence(t *testing.T) {
// Default precedence: CLI > Env > File > Default // Default precedence: CLI > Env > File > Default
val, _ := cfg.Get("test.value") val, _ := cfg.Get("test.value")
assert.Equal(t, "from-cli", val) checkEqual(t, val, "from-cli")
// Switch to File > CLI > Env > Default // Switch to File > CLI > Env > Default
err := cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault) err := cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault)
require.NoError(t, err) mustNoError(t, err)
val, _ = cfg.Get("test.value") val, _ = cfg.Get("test.value")
assert.Equal(t, "from-file", val) checkEqual(t, val, "from-file")
// Verify precedence was updated // Verify precedence was updated
precedence := cfg.GetPrecedence() precedence := cfg.GetPrecedence()
assert.Equal(t, []Source{SourceFile, SourceCLI, SourceEnv, SourceDefault}, precedence) checkEqual(t, precedence, []Source{SourceFile, SourceCLI, SourceEnv, SourceDefault})
}) })
t.Run("NoPrecedenceChangeOptimization", func(t *testing.T) { t.Run("NoPrecedenceChangeOptimization", func(t *testing.T) {
@@ -215,15 +243,15 @@ func TestSetPrecedence(t *testing.T) {
// Set same precedence // Set same precedence
initialPrecedence := cfg.GetPrecedence() initialPrecedence := cfg.GetPrecedence()
err := cfg.SetPrecedence(initialPrecedence...) err := cfg.SetPrecedence(initialPrecedence...)
require.NoError(t, err) mustNoError(t, err)
// Should be no-op, verify by checking version // Should be no-op, verify by checking version
version1 := cfg.version.Load() version1 := cfg.version.Load()
err = cfg.SetPrecedence(initialPrecedence...) err = cfg.SetPrecedence(initialPrecedence...)
require.NoError(t, err) mustNoError(t, err)
version2 := cfg.version.Load() version2 := cfg.version.Load()
assert.Equal(t, version1, version2, "Version should not change on no-op") checkEqual(t, version2, version1, "Version should not change on no-op")
}) })
t.Run("AutoAddDefaultSource", func(t *testing.T) { t.Run("AutoAddDefaultSource", func(t *testing.T) {
@@ -231,11 +259,11 @@ func TestSetPrecedence(t *testing.T) {
// Set precedence without SourceDefault // Set precedence without SourceDefault
err := cfg.SetPrecedence(SourceCLI, SourceFile, SourceEnv) err := cfg.SetPrecedence(SourceCLI, SourceFile, SourceEnv)
require.NoError(t, err) mustNoError(t, err)
// SourceDefault should be auto-appended // SourceDefault should be auto-appended
precedence := cfg.GetPrecedence() precedence := cfg.GetPrecedence()
assert.Equal(t, []Source{SourceCLI, SourceFile, SourceEnv, SourceDefault}, precedence) checkEqual(t, precedence, []Source{SourceCLI, SourceFile, SourceEnv, SourceDefault})
}) })
t.Run("InvalidSourceError", func(t *testing.T) { t.Run("InvalidSourceError", func(t *testing.T) {
@@ -243,18 +271,17 @@ func TestSetPrecedence(t *testing.T) {
// Try to set invalid source // Try to set invalid source
err := cfg.SetPrecedence("invalid", SourceFile) err := cfg.SetPrecedence("invalid", SourceFile)
assert.Error(t, err) checkErrorContains(t, err, "invalid source")
assert.Contains(t, err.Error(), "invalid source")
// Precedence should remain unchanged // Precedence should remain unchanged
precedence := cfg.GetPrecedence() precedence := cfg.GetPrecedence()
assert.Equal(t, []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault}, precedence) checkEqual(t, precedence, []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault})
}) })
t.Run("PrecedenceChangeNotifications", func(t *testing.T) { t.Run("PrecedenceChangeNotifications", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.toml") configFile := filepath.Join(tmpDir, "test.toml")
os.WriteFile(configFile, []byte(`value = "from-file"`), 0644) writeTestFile(t, configFile, []byte(`value = "from-file"`), 0644)
cfg := New() cfg := New()
cfg.Register("value", "default") cfg.Register("value", "default")
@@ -273,19 +300,16 @@ func TestSetPrecedence(t *testing.T) {
changes := cfg.Watch() changes := cfg.Watch()
// Change precedence - should trigger notification // Change precedence - should trigger notification
go func() { mustNoError(t, cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault))
time.Sleep(50 * time.Millisecond) // Let watcher start
cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault)
}()
// Wait for precedence change notification // Wait for precedence change notification
select { select {
case change := <-changes: case change := <-changes:
assert.Equal(t, "precedence:value", change) checkEqual(t, change, "precedence:value")
// Verify value changed // Verify value changed
val, _ := cfg.Get("value") val, _ := cfg.Get("value")
assert.Equal(t, "from-file", val) checkEqual(t, val, "from-file")
case <-time.After(500 * time.Millisecond): case <-time.After(500 * time.Millisecond):
t.Error("Timeout waiting for precedence change notification") t.Error("Timeout waiting for precedence change notification")
} }
@@ -305,16 +329,16 @@ func TestSetPrecedence(t *testing.T) {
cfg.SetPrecedence(SourceEnv, SourceFile, SourceDefault) cfg.SetPrecedence(SourceEnv, SourceFile, SourceDefault)
for _, path := range paths { for _, path := range paths {
val, _ := cfg.Get(path) val, _ := cfg.Get(path)
assert.Equal(t, "env-"+path, val) checkEqual(t, val, "env-"+path)
} }
// Switch: File wins // Switch: File wins
err := cfg.SetPrecedence(SourceFile, SourceEnv, SourceDefault) err := cfg.SetPrecedence(SourceFile, SourceEnv, SourceDefault)
require.NoError(t, err) mustNoError(t, err)
for _, path := range paths { for _, path := range paths {
val, _ := cfg.Get(path) val, _ := cfg.Get(path)
assert.Equal(t, "file-"+path, val) checkEqual(t, val, "file-"+path)
} }
}) })
@@ -371,7 +395,9 @@ func TestSetPrecedence(t *testing.T) {
for err := range errors { for err := range errors {
errs = append(errs, err) errs = append(errs, err)
} }
assert.Empty(t, errs, "Concurrent precedence changes should not produce errors") if got := len(errs); got != 0 {
t.Errorf("length = %d, want %d: %s", got, 0, "Concurrent precedence changes should not produce errors")
}
}) })
} }
@@ -381,7 +407,7 @@ func TestPrecedenceWithAutoUpdate(t *testing.T) {
configFile := filepath.Join(tmpDir, "test.toml") configFile := filepath.Join(tmpDir, "test.toml")
// Initial file content // Initial file content
os.WriteFile(configFile, []byte(` writeTestFile(t, configFile, []byte(`
server = "file-server-1" server = "file-server-1"
port = 8080 port = 8080
`), 0644) `), 0644)
@@ -396,7 +422,7 @@ func TestPrecedenceWithAutoUpdate(t *testing.T) {
// CLI wins initially // CLI wins initially
val, _ := cfg.Get("server") val, _ := cfg.Get("server")
assert.Equal(t, "cli-server", val) checkEqual(t, val, "cli-server")
// Enable auto-update // Enable auto-update
opts := WatchOptions{ opts := WatchOptions{
@@ -408,37 +434,40 @@ func TestPrecedenceWithAutoUpdate(t *testing.T) {
// Switch precedence to File > CLI // Switch precedence to File > CLI
err := cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault) err := cfg.SetPrecedence(SourceFile, SourceCLI, SourceEnv, SourceDefault)
require.NoError(t, err) mustNoError(t, err)
// File should now win // File should now win
val, _ = cfg.Get("server") val, _ = cfg.Get("server")
assert.Equal(t, "file-server-1", val) checkEqual(t, val, "file-server-1")
changes := cfg.Watch()
// Update file // Update file
os.WriteFile(configFile, []byte(` writeTestFile(t, configFile, []byte(`
server = "file-server-2" server = "file-server-2"
port = 9090 port = 9090
`), 0644) `), 0644)
// Wait for auto-update // The watcher publishes registered-path events after updating the snapshot.
time.Sleep(300 * time.Millisecond) _ = receiveWatchEvent(t, changes)
// File still wins with new value // File still wins with new value
val, _ = cfg.Get("server") val, _ = cfg.Get("server")
assert.Equal(t, "file-server-2", val) checkEqual(t, val, "file-server-2")
// CLI value is preserved but not active // CLI value is preserved but not active
cliVal, exists := cfg.GetSource("server", SourceCLI) cliVal, exists := cfg.GetSource("server", SourceCLI)
assert.True(t, exists) if !exists {
assert.Equal(t, "cli-server", cliVal) t.Errorf("exists should be true")
}
checkEqual(t, cliVal, "cli-server")
// Switch back to CLI > File // Switch back to CLI > File
err = cfg.SetPrecedence(SourceCLI, SourceFile, SourceEnv, SourceDefault) err = cfg.SetPrecedence(SourceCLI, SourceFile, SourceEnv, SourceDefault)
require.NoError(t, err) mustNoError(t, err)
// CLI wins again // CLI wins again
val, _ = cfg.Get("server") val, _ = cfg.Get("server")
assert.Equal(t, "cli-server", val) checkEqual(t, val, "cli-server")
} }
// TestTypeConversion tests automatic type conversion through checked decoding // TestTypeConversion tests automatic type conversion through checked decoding
@@ -469,7 +498,7 @@ func TestTypeConversion(t *testing.T) {
} }
err := cfg.RegisterStruct("", defaults) err := cfg.RegisterStruct("", defaults)
require.NoError(t, err) mustNoError(t, err)
// Test string conversions from environment // Test string conversions from environment
cfg.SetSource(SourceEnv, "int", "100") cfg.SetSource(SourceEnv, "int", "100")
@@ -481,22 +510,22 @@ func TestTypeConversion(t *testing.T) {
cfg.SetSource(SourceEnv, "ipnet", "10.0.0.0/8") cfg.SetSource(SourceEnv, "ipnet", "10.0.0.0/8")
cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path") cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path")
cfg.SetSource(SourceEnv, "strings", "x,y,z") cfg.SetSource(SourceEnv, "strings", "x,y,z")
require.NoError(t, cfg.SetSource(SourceEnv, "ints", "7,8,9")) mustNoError(t, cfg.SetSource(SourceEnv, "ints", "7,8,9"))
// Scan into struct // Scan into struct
var result TestConfig var result TestConfig
err = cfg.Scan(&result) err = cfg.Scan(&result)
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, int64(100), result.IntValue) checkEqual(t, result.IntValue, int64(100))
assert.Equal(t, 2.718, result.FloatValue) checkEqual(t, result.FloatValue, 2.718)
assert.Equal(t, false, result.BoolValue) checkEqual(t, result.BoolValue, false)
assert.Equal(t, 90*time.Second, result.Duration) checkEqual(t, result.Duration, 90*time.Second)
assert.Equal(t, "2024-12-25T10:00:00Z", result.Time.Format(time.RFC3339)) checkEqual(t, result.Time.Format(time.RFC3339), "2024-12-25T10:00:00Z")
assert.Equal(t, "192.168.1.1", result.IP.String()) checkEqual(t, result.IP.String(), "192.168.1.1")
assert.Equal(t, "10.0.0.0/8", result.IPNet.String()) checkEqual(t, result.IPNet.String(), "10.0.0.0/8")
assert.Equal(t, "https://example.com:8080/path", result.URL.String()) checkEqual(t, result.URL.String(), "https://example.com:8080/path")
assert.Equal(t, []string{"x", "y", "z"}, result.StringSlice) checkEqual(t, result.StringSlice, []string{"x", "y", "z"})
// Note: String to int slice conversion through env requires handling in the test // Note: String to int slice conversion through env requires handling in the test
} }
@@ -566,7 +595,9 @@ func TestConcurrentAccess(t *testing.T) {
for err := range errors { for err := range errors {
errs = append(errs, err) errs = append(errs, err)
} }
assert.Empty(t, errs, "Concurrent access should not produce errors") if got := len(errs); got != 0 {
t.Errorf("length = %d, want %d: %s", got, 0, "Concurrent access should not produce errors")
}
} }
// TestUnregister tests path unregistration // TestUnregister tests path unregistration
@@ -582,34 +613,47 @@ func TestUnregister(t *testing.T) {
t.Run("UnregisterSinglePath", func(t *testing.T) { t.Run("UnregisterSinglePath", func(t *testing.T) {
err := cfg.Unregister("server.port") err := cfg.Unregister("server.port")
assert.NoError(t, err) if err != nil {
t.Errorf("unexpected error: %v", err)
}
_, exists := cfg.Get("server.port") _, exists := cfg.Get("server.port")
assert.False(t, exists) if exists {
t.Errorf("exists should be false")
}
// Other paths should remain // Other paths should remain
_, exists = cfg.Get("server.host") _, exists = cfg.Get("server.host")
assert.True(t, exists) if !exists {
t.Errorf("exists should be true")
}
}) })
t.Run("UnregisterParentPath", func(t *testing.T) { t.Run("UnregisterParentPath", func(t *testing.T) {
err := cfg.Unregister("server.tls") err := cfg.Unregister("server.tls")
assert.NoError(t, err) if err != nil {
t.Errorf("unexpected error: %v", err)
}
// All child paths should be removed // All child paths should be removed
_, exists := cfg.Get("server.tls.enabled") _, exists := cfg.Get("server.tls.enabled")
assert.False(t, exists) if exists {
t.Errorf("exists should be false")
}
_, exists = cfg.Get("server.tls.cert") _, exists = cfg.Get("server.tls.cert")
assert.False(t, exists) if exists {
t.Errorf("exists should be false")
}
// Sibling paths should remain // Sibling paths should remain
_, exists = cfg.Get("server.host") _, exists = cfg.Get("server.host")
assert.True(t, exists) if !exists {
t.Errorf("exists should be true")
}
}) })
t.Run("UnregisterNonExistentPath", func(t *testing.T) { t.Run("UnregisterNonExistentPath", func(t *testing.T) {
err := cfg.Unregister("nonexistent.path") err := cfg.Unregister("nonexistent.path")
assert.Error(t, err) checkErrorContains(t, err, "path not registered")
assert.Contains(t, err.Error(), "path not registered")
}) })
} }
@@ -629,12 +673,16 @@ func TestResetFunctionality(t *testing.T) {
// Env value should be gone // Env value should be gone
_, exists := cfg.GetSource("test1", SourceEnv) _, exists := cfg.GetSource("test1", SourceEnv)
assert.False(t, exists) if exists {
t.Errorf("exists should be false")
}
// Other sources should remain // Other sources should remain
val, exists := cfg.GetSource("test1", SourceFile) val, exists := cfg.GetSource("test1", SourceFile)
assert.True(t, exists) if !exists {
assert.Equal(t, "file1", val) t.Errorf("exists should be true")
}
checkEqual(t, val, "file1")
}) })
t.Run("ResetAll", func(t *testing.T) { t.Run("ResetAll", func(t *testing.T) {
@@ -643,12 +691,14 @@ func TestResetFunctionality(t *testing.T) {
// All values should revert to defaults // All values should revert to defaults
val1, _ := cfg.Get("test1") val1, _ := cfg.Get("test1")
val2, _ := cfg.Get("test2") val2, _ := cfg.Get("test2")
assert.Equal(t, "default1", val1) checkEqual(t, val1, "default1")
assert.Equal(t, "default2", val2) checkEqual(t, val2, "default2")
// Source values should be cleared // Source values should be cleared
sources := cfg.GetSources("test1") sources := cfg.GetSources("test1")
assert.Empty(t, sources) if got := len(sources); got != 0 {
t.Errorf("length = %d, want %d", got, 0)
}
}) })
} }
@@ -664,8 +714,10 @@ func TestValueSizeLimit(t *testing.T) {
} }
err := cfg.Set("test", string(largeValue)) err := cfg.Set("test", string(largeValue))
assert.Error(t, err) if err == nil {
assert.Equal(t, ErrValueSize, err) t.Errorf("expected an error")
}
checkEqual(t, err, ErrValueSize)
} }
// TestGetRegisteredPaths tests path listing functionality // TestGetRegisteredPaths tests path listing functionality
@@ -687,24 +739,42 @@ func TestGetRegisteredPaths(t *testing.T) {
t.Run("GetAllPaths", func(t *testing.T) { t.Run("GetAllPaths", func(t *testing.T) {
all := cfg.GetRegisteredPaths("") all := cfg.GetRegisteredPaths("")
assert.Len(t, all, len(paths)) if got := len(all); got != len(paths) {
t.Errorf("length = %d, want %d", got, len(paths))
}
for _, path := range paths { for _, path := range paths {
assert.True(t, all[path]) if !(all[path]) {
t.Errorf("all[path] should be true")
}
} }
}) })
t.Run("GetPathsWithPrefix", func(t *testing.T) { t.Run("GetPathsWithPrefix", func(t *testing.T) {
serverPaths := cfg.GetRegisteredPaths("server.") serverPaths := cfg.GetRegisteredPaths("server.")
assert.Len(t, serverPaths, 3) if got := len(serverPaths); got != 3 {
assert.True(t, serverPaths["server.host"]) t.Errorf("length = %d, want %d", got, 3)
assert.True(t, serverPaths["server.port"]) }
assert.True(t, serverPaths["server.tls.enabled"]) if !serverPaths["server.host"] {
t.Errorf("serverPaths[\"server.host\"] should be true")
}
if !serverPaths["server.port"] {
t.Errorf("serverPaths[\"server.port\"] should be true")
}
if !serverPaths["server.tls.enabled"] {
t.Errorf("serverPaths[\"server.tls.enabled\"] should be true")
}
}) })
t.Run("GetPathsWithDefaults", func(t *testing.T) { t.Run("GetPathsWithDefaults", func(t *testing.T) {
defaults := cfg.GetRegisteredPathsWithDefaults("database.") defaults := cfg.GetRegisteredPathsWithDefaults("database.")
assert.Len(t, defaults, 2) if got := len(defaults); got != 2 {
assert.Contains(t, defaults, "database.host") t.Errorf("length = %d, want %d", got, 2)
assert.Contains(t, defaults, "database.port") }
if _, ok := defaults["database.host"]; !ok {
t.Errorf("unexpected map membership for %q", "database.host")
}
if _, ok := defaults["database.port"]; !ok {
t.Errorf("unexpected map membership for %q", "database.port")
}
}) })
} }
+3 -1
View File
@@ -101,7 +101,9 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
dst.Set(next) dst.Set(next)
return nil return nil
} }
if atomicType(dst.Type()) && src.Type() == dst.Type() { // Native struct defaults can occur inside slices, arrays and map values.
// They already have the destination type, but still need a checked deep copy.
if (atomicType(dst.Type()) || dst.Kind() == reflect.Struct) && src.Type() == dst.Type() {
v, err := copyReflect(src, depth+1) v, err := copyReflect(src, depth+1)
if err != nil { if err != nil {
return err return err
+40 -40
View File
@@ -7,9 +7,6 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestScanWithComplexTypes tests scanning with various complex types // TestScanWithComplexTypes tests scanning with various complex types
@@ -48,7 +45,7 @@ func TestScanWithComplexTypes(t *testing.T) {
} }
err := cfg.RegisterStruct("", defaults) err := cfg.RegisterStruct("", defaults)
require.NoError(t, err) mustNoError(t, err)
// Set values from different sources // Set values from different sources
cfg.SetSource(SourceEnv, "network.ip", "192.168.1.100") cfg.SetSource(SourceEnv, "network.ip", "192.168.1.100")
@@ -67,19 +64,19 @@ func TestScanWithComplexTypes(t *testing.T) {
// Scan into struct // Scan into struct
var result AppConfig var result AppConfig
err = cfg.Scan(&result) err = cfg.Scan(&result)
require.NoError(t, err) mustNoError(t, err)
// Verify conversions // Verify conversions
assert.Equal(t, "192.168.1.100", result.Network.IP.String()) checkEqual(t, result.Network.IP.String(), "192.168.1.100")
assert.Equal(t, "192.168.1.0/24", result.Network.IPNet.String()) checkEqual(t, result.Network.IPNet.String(), "192.168.1.0/24")
assert.Equal(t, "https://api.example.com:8443/v1", result.Network.URL.String()) checkEqual(t, result.Network.URL.String(), "https://api.example.com:8443/v1")
assert.Equal(t, 150*time.Second, result.Network.Timeout) checkEqual(t, result.Network.Timeout, 150*time.Second)
assert.Equal(t, 5, result.Network.Retry.Count) checkEqual(t, result.Network.Retry.Count, 5)
assert.Equal(t, 10*time.Second, result.Network.Retry.Interval) checkEqual(t, result.Network.Retry.Interval, 10*time.Second)
assert.Equal(t, []string{"prod", "staging", "test"}, result.Tags) checkEqual(t, result.Tags, []string{"prod", "staging", "test"})
assert.Equal(t, []int{80, 443, 8080}, result.Ports) checkEqual(t, result.Ports, []int{80, 443, 8080})
assert.Equal(t, "production", result.Labels["env"]) checkEqual(t, result.Labels["env"], "production")
assert.Equal(t, "1.2.3", result.Labels["version"]) checkEqual(t, result.Labels["version"], "1.2.3")
} }
// TestScanWithBasePath tests scanning from nested paths // TestScanWithBasePath tests scanning from nested paths
@@ -102,18 +99,20 @@ func TestScanWithBasePath(t *testing.T) {
// Scan only the server section // Scan only the server section
var server ServerConfig var server ServerConfig
err := cfg.Scan(&server, "app.server") err := cfg.Scan(&server, "app.server")
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, "appserver", server.Host) checkEqual(t, server.Host, "appserver")
assert.Equal(t, 9000, server.Port) checkEqual(t, server.Port, 9000)
assert.Equal(t, true, server.Enabled) checkEqual(t, server.Enabled, true)
// Test non-existent base path // Test non-existent base path
var empty ServerConfig var empty ServerConfig
err = cfg.Scan(&empty, "app.nonexistent") err = cfg.Scan(&empty, "app.nonexistent")
assert.NoError(t, err) // Should not error, just empty if err != nil {
assert.Equal(t, "", empty.Host) t.Errorf("unexpected error: %v", err)
assert.Equal(t, 0, empty.Port) } // Should not error, just empty
checkEqual(t, empty.Host, "")
checkEqual(t, empty.Port, 0)
} }
// TestScanFromSource tests scanning from specific sources // TestScanFromSource tests scanning from specific sources
@@ -143,8 +142,8 @@ func TestScanFromSource(t *testing.T) {
t.Run(string(tt.source), func(t *testing.T) { t.Run(string(tt.source), func(t *testing.T) {
var result Config var result Config
err := cfg.ScanSource(tt.source, &result) err := cfg.ScanSource(tt.source, &result)
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, tt.expected, result.Value) checkEqual(t, result.Value, tt.expected)
}) })
} }
} }
@@ -167,8 +166,7 @@ func TestInvalidScanTargets(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := cfg.Scan(tt.target) err := cfg.Scan(tt.target)
assert.Error(t, err) checkErrorContains(t, err, tt.expectErr)
assert.Contains(t, err.Error(), tt.expectErr)
}) })
} }
} }
@@ -189,12 +187,14 @@ func TestCustomTypeConversion(t *testing.T) {
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
cfg := New() cfg := New()
require.NoError(t, cfg.Register("value", tc.initial)) mustNoError(t, cfg.Register("value", tc.initial))
err := cfg.Set("value", tc.input) err := cfg.Set("value", tc.input)
require.Error(t, err) if err == nil {
assert.Contains(t, err.Error(), tc.fragment) t.Fatalf("expected an error")
}
checkErrorContains(t, err, tc.fragment)
got, _ := cfg.Get("value") got, _ := cfg.Get("value")
assert.Equal(t, tc.initial, got) checkEqual(t, got, tc.initial)
}) })
} }
} }
@@ -228,12 +228,12 @@ func TestZeroFields(t *testing.T) {
} }
err := cfg.Scan(&result) err := cfg.Scan(&result)
require.NoError(t, err) mustNoError(t, err)
// ZeroFields should reset all fields before decoding // ZeroFields should reset all fields before decoding
assert.Equal(t, "newvalue", result.KeepValue) checkEqual(t, result.KeepValue, "newvalue")
assert.Equal(t, "resetdefault", result.ResetValue) checkEqual(t, result.ResetValue, "resetdefault")
assert.Equal(t, "initial", result.NestedValue.Field) // Unregistered, so Scan should not touch it checkEqual(t, result.NestedValue.Field, "initial") // Unregistered, so Scan should not touch it
} }
// TestWeaklyTypedInput tests weak type conversion // TestWeaklyTypedInput tests weak type conversion
@@ -259,11 +259,11 @@ func TestWeaklyTypedInput(t *testing.T) {
var result Config var result Config
err := cfg.Scan(&result) err := cfg.Scan(&result)
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, 42, result.IntFromString) checkEqual(t, result.IntFromString, 42)
assert.Equal(t, 3.14159, result.FloatFromString) checkEqual(t, result.FloatFromString, 3.14159)
assert.Equal(t, true, result.BoolFromString) checkEqual(t, result.BoolFromString, true)
assert.Equal(t, "12345", result.StringFromInt) checkEqual(t, result.StringFromInt, "12345")
assert.Equal(t, "true", result.StringFromBool) // Canonical boolean text checkEqual(t, result.StringFromBool, "true") // Canonical boolean text
} }
-66
View File
@@ -1,66 +0,0 @@
# Config audit and TOML migration
This branch updates only lixenwraith/config. It uses Go 1.27.1 and the user's merged
TOML main commit `b59d068ff6a48b86365a65e1121343f672f77a67`, pinned as
`v0.0.0-20260928180512-b59d068ff6a4` because no version tag existed at integration.
Logwisp adoption and other TOML consumers are separate follow-up work.
## Confirmed defects and fixes
| Finding | Result | Regression evidence |
| --- | --- | --- |
| Fractions/overflow could pass through weak numeric conversion | Checked TOML conversion and explicit destination-width parsing | `TestCheckedConversionsAndRollback`, `FuzzConfigNumericConversions` |
| Options, input containers and returned values could alias caller state | Owned options/values and detached reads/clones | `TestOptionsAreOwned`, `TestOwnedValuesAndSnapshots` |
| Updating the cached target changed old AsStruct results | Immutable cached result and a fresh snapshot per call | `TestOwnedValuesAndSnapshots` |
| ExportEnv compared uncomparable slices/maps directly | Deep equality, with callbacks outside Config locks | `TestExportEnvContainerValues` |
| Invalid sources could be published before decoding failed | Whole-source validation before mutation | `TestSourceReplacementIsTransactional` |
| Removed env/CLI values survived subsequent loads | Successful loads replace their complete source | `TestSourceReplacementIsTransactional` |
| A timed-out reload kept running and could publish after stop | Context, watcher identity, path and generation checked at commit | `TestReloadCannotPublishAfterStopTimeoutOrNewLoad` |
| A timeout could suppress retries of unchanged content | Retry timeouts while deduplicating their notifications | `TestWatcherRetriesTimedOutUnchangedContent` |
| Debounce did not cover errors from partial in-place writes | Errors and successful updates wait for a quiet interval | `TestWatcherDebouncesErrorsAndRecovers` |
| Metadata-only polling missed some replacements/edits | Content fingerprints on every poll; deletion/recreation recovery | `TestWatcherSameMetadataReplacementAndRecreation` |
| Source saving lost comments and widened permissions | Comment preamble, existing modes retained, new files 0600 | `TestSaveCommentsPermissionsAndFailure` |
| Atomic types were inconsistently registered/serialized | Exact type matching for duration/time/IP/CIDR/URL and pointer forms | `TestAtomicValuesAndFileRoundTrip` |
| A missing file could hide a joined invalid CLI/environment error | Missing-file fallback only when other sources succeed | `TestBuilderDoesNotHideInvalidSourceBehindMissingFile` |
| Required registration was a no-op; overlapping/duplicate fields were ambiguous | Required paths validated at Build; staged registration rejects conflicts | `TestRegistrationAndRequiredContracts` |
| Default source selection and narrow target schemas could be inconsistent | Default precedence honored; explicit defaults must match the typed target | `TestRegistrationAndRequiredContracts` |
Reproduction against original main `ccd280b`: fractional-to-integer conversion
silently produced 1 from 1.5; modifying an input slice changed its registered
default; an out-of-range CLI batch returned success; and requesting a refreshed
AsStruct changed an earlier result under its reader. All four were reproduced with
public-API probes; committed regressions cover the corrected behavior.
## Dependency and scope decision
Mapstructure is replaceable for the package's actual object model. The replacement
supports TOML field names, ordinary containers, pointers and the existing standard
atomic types. It does not reproduce mapstructure's broad weak coercions or add a
custom hook API. Numeric correctness is shared with lixenwraith/toml.
`go list -deps ./...` reports only the standard library, lixenwraith/toml and this
module. BurntSushi TOML, mapstructure and YAML are removed from runtime code.
Testify remains test-only, so its transitive YAML dependency still appears in go.mod
and go.sum. JSON/YAML files and tags are unsupported, with explicit rejection tests.
No global reflection cache or lock was introduced. Known container sizes are used
for allocation. Stored values are immutable after admission. One watcher worker
owns observation/debounce state, and subscription cleanup has no per-subscriber
goroutine. File I/O and environment callbacks execute outside Config locks.
## Verification and limits
The full unit and race suites and go vet pass with Go 1.27.1. Numeric fuzzing ran
for 10 seconds and passed 234,155 executions, covering full uint64 preservation,
signed boundaries and narrowing. The 386 and Windows test binaries cross-compile
locally. This host cannot execute 386 binaries; CI executes that suite and also
runs race tests, vet, fuzzing and Windows compilation. Final checks are on the PR.
TOML's supported subset is unchanged. Native datetime syntax is not added; time.Time
is represented by a quoted RFC3339Nano string. Full uint64 works in memory but values
above MaxInt64 cannot be saved as TOML integers. Comment text survives as a preamble;
locations and formatting do not. Unknown file keys are not saved. Depth is bounded
at 128 for configuration graphs. Live application validators remain an application
responsibility; Builder validators run only during initialization. Notifications
are best effort, and file timeouts prevent late publication without forcibly
interrupting kernel I/O. See the linked topic guides for migration details.
+32 -29
View File
@@ -1,9 +1,7 @@
package config package config
import ( import (
"github.com/stretchr/testify/assert" "fmt"
"github.com/stretchr/testify/require"
"os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"testing" "testing"
@@ -12,29 +10,37 @@ import (
func TestTOMLOnly(t *testing.T) { func TestTOMLOnly(t *testing.T) {
for _, format := range []string{"json", "yaml", "other"} { for _, format := range []string{"json", "yaml", "other"} {
c := New() c := New()
require.Error(t, c.SetFileFormat(format)) if err := c.SetFileFormat(format); err == nil {
t.Fatalf("expected an error")
}
_, err := NewBuilder().WithTagName(format).Build() _, err := NewBuilder().WithTagName(format).Build()
require.Error(t, err) if err == nil {
t.Fatalf("expected an error")
}
var dst struct{ Value string } var dst struct{ Value string }
require.Error(t, ScanMap(nil, &dst, format)) if err := ScanMap(nil, &dst, format); err == nil {
t.Fatalf("expected an error")
}
} }
for _, tc := range []struct{ ext, content string }{ for _, tc := range []struct{ ext, content string }{
{".toml", `value = "toml"`}, {".conf", `value = "toml"`}, {".config", `value = "toml"`}, {".toml", `value = "toml"`}, {".conf", `value = "toml"`}, {".config", `value = "toml"`},
} { } {
c := New() c := New()
require.NoError(t, c.Register("value", "")) mustNoError(t, c.Register("value", ""))
path := filepath.Join(t.TempDir(), "config"+tc.ext) path := filepath.Join(t.TempDir(), "config"+tc.ext)
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600)) writeTestFile(t, path, []byte(tc.content), 0600)
require.NoError(t, c.LoadFile(path)) mustNoError(t, c.LoadFile(path))
} }
for _, tc := range []struct{ ext, content string }{ for _, tc := range []struct{ ext, content string }{
{".json", `{"value":"json"}`}, {".yaml", "value: yaml"}, {".conf", `{"value":"json"}`}, {".json", `{"value":"json"}`}, {".yaml", "value: yaml"}, {".conf", `{"value":"json"}`},
} { } {
c := New() c := New()
require.NoError(t, c.Register("value", "")) mustNoError(t, c.Register("value", ""))
path := filepath.Join(t.TempDir(), "config"+tc.ext) path := filepath.Join(t.TempDir(), "config"+tc.ext)
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600)) writeTestFile(t, path, []byte(tc.content), 0600)
require.Error(t, c.LoadFile(path)) if err := c.LoadFile(path); err == nil {
t.Fatalf("expected an error")
}
} }
} }
@@ -57,17 +63,21 @@ func TestSecurityOptions(t *testing.T) {
for _, malPath := range maliciousPaths { for _, malPath := range maliciousPaths {
err := cfg.LoadFile(malPath) err := cfg.LoadFile(malPath)
assert.Error(t, err, "Should reject path: %s", malPath) if err == nil {
assert.Contains(t, err.Error(), "path traversal") t.Errorf("expected an error: %s", fmt.Sprintf("Should reject path: %s", malPath))
}
checkErrorContains(t, err, "path traversal")
} }
// Valid paths should work // Valid paths should work
validPath := filepath.Join(tmpDir, "config.toml") validPath := filepath.Join(tmpDir, "config.toml")
os.WriteFile(validPath, []byte(`test = "value"`), 0644) writeTestFile(t, validPath, []byte(`test = "value"`), 0644)
cfg.Register("test", "") cfg.Register("test", "")
err := cfg.LoadFile(validPath) err := cfg.LoadFile(validPath)
assert.NoError(t, err, "Should accept valid absolute path") if err != nil {
t.Errorf("unexpected error: %v: %s", err, "Should accept valid absolute path")
}
}) })
t.Run("FileSizeLimit", func(t *testing.T) { t.Run("FileSizeLimit", func(t *testing.T) {
@@ -82,11 +92,10 @@ func TestSecurityOptions(t *testing.T) {
for i := range largeContent { for i := range largeContent {
largeContent[i] = 'a' largeContent[i] = 'a'
} }
require.NoError(t, os.WriteFile(largePath, largeContent, 0644)) writeTestFile(t, largePath, largeContent, 0644)
err := cfg.LoadFile(largePath) err := cfg.LoadFile(largePath)
assert.Error(t, err) checkErrorContains(t, err, "exceeds maximum size")
assert.Contains(t, err.Error(), "exceeds maximum size")
}) })
t.Run("FileOwnership", func(t *testing.T) { t.Run("FileOwnership", func(t *testing.T) {
@@ -101,18 +110,12 @@ func TestSecurityOptions(t *testing.T) {
// Create file owned by current user (should succeed) // Create file owned by current user (should succeed)
ownedPath := filepath.Join(tmpDir, "owned.toml") ownedPath := filepath.Join(tmpDir, "owned.toml")
require.NoError(t, os.WriteFile(ownedPath, []byte(`test = "value"`), 0644)) writeTestFile(t, ownedPath, []byte(`test = "value"`), 0644)
cfg.Register("test", "") cfg.Register("test", "")
err := cfg.LoadFile(ownedPath) err := cfg.LoadFile(ownedPath)
assert.NoError(t, err) if err != nil {
t.Errorf("unexpected error: %v", err)
}
}) })
} }
// waitForWatchingState waits for watcher state, preventing race conditions of goroutine start and test check
func waitForWatchingState(t *testing.T, cfg *Config, expected bool, msgAndArgs ...any) {
t.Helper()
require.Eventually(t, func() bool {
return cfg.IsWatching() == expected
}, testEventuallyTimeout, 2*SpinWaitInterval, msgAndArgs...)
}
+1 -10
View File
@@ -2,13 +2,4 @@ module github.com/lixenwraith/config
go 1.27.1 go 1.27.1
require ( require github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-10
View File
@@ -1,12 +1,2 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY= github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY=
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0= github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3 -3
View File
@@ -187,11 +187,11 @@ func TestSourceReplacementIsTransactional(t *testing.T) {
t.Fatal("removed environment variable retained") t.Fatal("removed environment variable retained")
} }
path := filepath.Join(t.TempDir(), "c.toml") path := filepath.Join(t.TempDir(), "c.toml")
_ = os.WriteFile(path, []byte("port=100\nname=\"valid\""), 0600) writeTestFile(t, path, []byte("port=100\nname=\"valid\""), 0600)
if err := c.LoadFile(path); err != nil { if err := c.LoadFile(path); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_ = os.WriteFile(path, []byte("port=65536\nname=\"invalid\""), 0600) writeTestFile(t, path, []byte("port=65536\nname=\"invalid\""), 0600)
if err := c.LoadFile(path); err == nil { if err := c.LoadFile(path); err == nil {
t.Fatal("accepted invalid file") t.Fatal("accepted invalid file")
} }
@@ -212,7 +212,7 @@ func TestSaveCommentsPermissionsAndFailure(t *testing.T) {
_ = c.Register("text", "") _ = c.Register("text", "")
path := filepath.Join(t.TempDir(), "c.toml") path := filepath.Join(t.TempDir(), "c.toml")
original := "# heading\nvalue=7 # inline\ntext=\"hash # inside\"\n" original := "# heading\nvalue=7 # inline\ntext=\"hash # inside\"\n"
_ = os.WriteFile(path, []byte(original), 0600) writeTestFile(t, path, []byte(original), 0600)
if err := c.LoadFile(path); err != nil { if err := c.LoadFile(path); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+102 -103
View File
@@ -2,12 +2,12 @@
package config package config
import ( import (
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestFileLoading tests TOML file loading // TestFileLoading tests TOML file loading
@@ -31,7 +31,7 @@ key = "/path/to/key.pem"
connections = [1, 2, 3] connections = [1, 2, 3]
tags = ["primary", "replica"] tags = ["primary", "replica"]
` `
os.WriteFile(configFile, []byte(content), 0644) writeTestFile(t, configFile, []byte(content), 0644)
cfg := New() cfg := New()
// Register all paths // Register all paths
@@ -44,46 +44,49 @@ tags = ["primary", "replica"]
cfg.Register("database.tags", []string{}) cfg.Register("database.tags", []string{})
err := cfg.LoadFile(configFile) err := cfg.LoadFile(configFile)
require.NoError(t, err) mustNoError(t, err)
// Verify loaded values // Verify loaded values
host, _ := cfg.Get("server.host") host, _ := cfg.Get("server.host")
assert.Equal(t, "example.com", host) checkEqual(t, host, "example.com")
port, _ := cfg.Get("server.port") port, _ := cfg.Get("server.port")
assert.Equal(t, int64(9000), port) checkEqual(t, port, int64(9000))
enabled, _ := cfg.Get("server.enabled") enabled, _ := cfg.Get("server.enabled")
assert.Equal(t, true, enabled) checkEqual(t, enabled, true)
cert, _ := cfg.Get("server.tls.cert") cert, _ := cfg.Get("server.tls.cert")
assert.Equal(t, "/path/to/cert.pem", cert) checkEqual(t, cert, "/path/to/cert.pem")
// Arrays are loaded as []any // Arrays are loaded as []any
connections, _ := cfg.Get("database.connections") connections, _ := cfg.Get("database.connections")
assert.Equal(t, []any{int64(1), int64(2), int64(3)}, connections) checkEqual(t, connections, []any{int64(1), int64(2), int64(3)})
}) })
t.Run("InvalidTOMLFile", func(t *testing.T) { t.Run("InvalidTOMLFile", func(t *testing.T) {
configFile := filepath.Join(tmpDir, "invalid.toml") configFile := filepath.Join(tmpDir, "invalid.toml")
os.WriteFile(configFile, []byte(`invalid = toml content`), 0644) writeTestFile(t, configFile, []byte(`invalid = toml content`), 0644)
cfg := New() cfg := New()
err := cfg.LoadFile(configFile) err := cfg.LoadFile(configFile)
assert.Error(t, err) checkErrorContains(t, err, "failed to parse TOML")
assert.Contains(t, err.Error(), "failed to parse TOML")
}) })
t.Run("NonExistentFile", func(t *testing.T) { t.Run("NonExistentFile", func(t *testing.T) {
cfg := New() cfg := New()
err := cfg.LoadFile("/non/existent/file.toml") err := cfg.LoadFile("/non/existent/file.toml")
assert.Error(t, err) if err == nil {
assert.ErrorIs(t, err, ErrConfigNotFound) t.Errorf("expected an error")
}
if err := err; !errors.Is(err, ErrConfigNotFound) {
t.Errorf("error = %v, want errors.Is(_, %v)", err, ErrConfigNotFound)
}
}) })
t.Run("UnregisteredPathsIgnored", func(t *testing.T) { t.Run("UnregisteredPathsIgnored", func(t *testing.T) {
configFile := filepath.Join(tmpDir, "extra.toml") configFile := filepath.Join(tmpDir, "extra.toml")
os.WriteFile(configFile, []byte(` writeTestFile(t, configFile, []byte(`
registered = "value" registered = "value"
unregistered = "ignored" unregistered = "ignored"
`), 0644) `), 0644)
@@ -92,59 +95,51 @@ unregistered = "ignored"
cfg.Register("registered", "") cfg.Register("registered", "")
err := cfg.LoadFile(configFile) err := cfg.LoadFile(configFile)
require.NoError(t, err) mustNoError(t, err)
val, exists := cfg.Get("registered") val, exists := cfg.Get("registered")
assert.True(t, exists) if !exists {
assert.Equal(t, "value", val) t.Errorf("exists should be true")
}
checkEqual(t, val, "value")
_, exists = cfg.Get("unregistered") _, exists = cfg.Get("unregistered")
assert.False(t, exists) if exists {
t.Errorf("exists should be false")
}
}) })
} }
// TestEnvironmentLoading tests environment variable loading // TestEnvironmentLoading tests environment variable loading
func TestEnvironmentLoading(t *testing.T) { func TestEnvironmentLoading(t *testing.T) {
// Save and restore environment
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, e := range originalEnv {
parts := splitEnvVar(e)
if len(parts) == 2 {
os.Setenv(parts[0], parts[1])
}
}
}()
t.Run("DefaultEnvTransform", func(t *testing.T) { t.Run("DefaultEnvTransform", func(t *testing.T) {
cfg := New() cfg := New()
cfg.Register("server.host", "localhost") cfg.Register("server.host", "localhost")
cfg.Register("server.port", 8080) cfg.Register("server.port", 8080)
cfg.Register("enable_debug", false) cfg.Register("enable_debug", false)
os.Setenv("APP_SERVER_HOST", "envhost") t.Setenv("APP_SERVER_HOST", "envhost")
os.Setenv("APP_SERVER_PORT", "9090") t.Setenv("APP_SERVER_PORT", "9090")
os.Setenv("APP_ENABLE_DEBUG", "true") t.Setenv("APP_ENABLE_DEBUG", "true")
err := cfg.LoadEnv("APP_") err := cfg.LoadEnv("APP_")
require.NoError(t, err) mustNoError(t, err)
host, _ := cfg.Get("server.host") host, _ := cfg.Get("server.host")
assert.Equal(t, "envhost", host) checkEqual(t, host, "envhost")
port, _ := cfg.Get("server.port") port, _ := cfg.Get("server.port")
assert.Equal(t, "9090", port) // String from env checkEqual(t, port, "9090") // String from env
debug, _ := cfg.Get("enable_debug") debug, _ := cfg.Get("enable_debug")
assert.Equal(t, "true", debug) // String from env checkEqual(t, debug, "true") // String from env
}) })
t.Run("CustomEnvTransform", func(t *testing.T) { t.Run("CustomEnvTransform", func(t *testing.T) {
cfg := New() cfg := New()
cfg.Register("db.host", "localhost") cfg.Register("db.host", "localhost")
os.Setenv("DATABASE_HOSTNAME", "customhost") t.Setenv("DATABASE_HOSTNAME", "customhost")
opts := LoadOptions{ opts := LoadOptions{
Sources: []Source{SourceEnv, SourceDefault}, Sources: []Source{SourceEnv, SourceDefault},
@@ -157,10 +152,10 @@ func TestEnvironmentLoading(t *testing.T) {
} }
err := cfg.loadWithOptions("", nil, opts) err := cfg.loadWithOptions("", nil, opts)
require.NoError(t, err) mustNoError(t, err)
host, _ := cfg.Get("db.host") host, _ := cfg.Get("db.host")
assert.Equal(t, "customhost", host) checkEqual(t, host, "customhost")
}) })
t.Run("EnvWhitelist", func(t *testing.T) { t.Run("EnvWhitelist", func(t *testing.T) {
@@ -168,8 +163,8 @@ func TestEnvironmentLoading(t *testing.T) {
cfg.Register("allowed.path", "default1") cfg.Register("allowed.path", "default1")
cfg.Register("blocked.path", "default2") cfg.Register("blocked.path", "default2")
os.Setenv("ALLOWED_PATH", "env1") t.Setenv("ALLOWED_PATH", "env1")
os.Setenv("BLOCKED_PATH", "env2") t.Setenv("BLOCKED_PATH", "env2")
opts := LoadOptions{ opts := LoadOptions{
Sources: []Source{SourceEnv, SourceDefault}, Sources: []Source{SourceEnv, SourceDefault},
@@ -177,13 +172,13 @@ func TestEnvironmentLoading(t *testing.T) {
} }
err := cfg.loadWithOptions("", nil, opts) err := cfg.loadWithOptions("", nil, opts)
require.NoError(t, err) mustNoError(t, err)
allowed, _ := cfg.Get("allowed.path") allowed, _ := cfg.Get("allowed.path")
assert.Equal(t, "env1", allowed) checkEqual(t, allowed, "env1")
blocked, _ := cfg.Get("blocked.path") blocked, _ := cfg.Get("blocked.path")
assert.Equal(t, "default2", blocked) // Should not load from env checkEqual(t, blocked, "default2") // Should not load from env
}) })
t.Run("DiscoverEnv", func(t *testing.T) { t.Run("DiscoverEnv", func(t *testing.T) {
@@ -192,16 +187,18 @@ func TestEnvironmentLoading(t *testing.T) {
cfg.Register("test.two", "") cfg.Register("test.two", "")
cfg.Register("other.value", "") cfg.Register("other.value", "")
os.Setenv("PREFIX_TEST_ONE", "value1") t.Setenv("PREFIX_TEST_ONE", "value1")
os.Setenv("PREFIX_TEST_TWO", "value2") t.Setenv("PREFIX_TEST_TWO", "value2")
os.Setenv("PREFIX_OTHER_VALUE", "value3") t.Setenv("PREFIX_OTHER_VALUE", "value3")
os.Setenv("UNRELATED_VAR", "ignored") t.Setenv("UNRELATED_VAR", "ignored")
discovered := cfg.DiscoverEnv("PREFIX_") discovered := cfg.DiscoverEnv("PREFIX_")
assert.Len(t, discovered, 3) if got := len(discovered); got != 3 {
assert.Equal(t, "PREFIX_TEST_ONE", discovered["test.one"]) t.Errorf("length = %d, want %d", got, 3)
assert.Equal(t, "PREFIX_TEST_TWO", discovered["test.two"]) }
assert.Equal(t, "PREFIX_OTHER_VALUE", discovered["other.value"]) checkEqual(t, discovered["test.one"], "PREFIX_TEST_ONE")
checkEqual(t, discovered["test.two"], "PREFIX_TEST_TWO")
checkEqual(t, discovered["other.value"], "PREFIX_OTHER_VALUE")
}) })
} }
@@ -272,14 +269,16 @@ func TestCLIParsing(t *testing.T) {
} }
err := cfg.LoadCLI(tt.args) err := cfg.LoadCLI(tt.args)
require.NoError(t, err) mustNoError(t, err)
// Verify values // Verify values
for path, expected := range tt.expected { for path, expected := range tt.expected {
if path != "" { if path != "" {
val, exists := cfg.Get(path) val, exists := cfg.Get(path)
assert.True(t, exists, "Path %s should exist", path) if !exists {
assert.Equal(t, expected, val) t.Errorf("exists should be true: %s", fmt.Sprintf("Path %s should exist", path))
}
checkEqual(t, val, expected)
} }
} }
}) })
@@ -287,9 +286,10 @@ func TestCLIParsing(t *testing.T) {
t.Run("InvalidKeySegment", func(t *testing.T) { t.Run("InvalidKeySegment", func(t *testing.T) {
result, err := parseArgs([]string{"--invalid!key=value"}) result, err := parseArgs([]string{"--invalid!key=value"})
assert.Error(t, err) checkErrorContains(t, err, "invalid command-line key segment")
assert.Contains(t, err.Error(), "invalid command-line key segment") if got := result; got != nil {
assert.Nil(t, result) t.Errorf("got %v, want nil", got)
}
}) })
} }
@@ -297,18 +297,14 @@ func TestCLIParsing(t *testing.T) {
func TestLoadWithOptions(t *testing.T) { func TestLoadWithOptions(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.toml") configFile := filepath.Join(tmpDir, "config.toml")
os.WriteFile(configFile, []byte(` writeTestFile(t, configFile, []byte(`
[server] [server]
host = "filehost" host = "filehost"
port = 8080 port = 8080
`), 0644) `), 0644)
os.Setenv("TEST_SERVER_HOST", "envhost") t.Setenv("TEST_SERVER_HOST", "envhost")
os.Setenv("TEST_SERVER_PORT", "9090") t.Setenv("TEST_SERVER_PORT", "9090")
defer func() {
os.Unsetenv("TEST_SERVER_HOST")
os.Unsetenv("TEST_SERVER_PORT")
}()
cfg := New() cfg := New()
cfg.Register("server.host", "defaulthost") cfg.Register("server.host", "defaulthost")
@@ -322,21 +318,21 @@ port = 8080
} }
err := cfg.loadWithOptions(configFile, args, opts) err := cfg.loadWithOptions(configFile, args, opts)
require.NoError(t, err) mustNoError(t, err)
// CLI should win // CLI should win
port, _ := cfg.Get("server.port") port, _ := cfg.Get("server.port")
assert.Equal(t, "7070", port) checkEqual(t, port, "7070")
// ENV should win over file // ENV should win over file
host, _ := cfg.Get("server.host") host, _ := cfg.Get("server.host")
assert.Equal(t, "envhost", host) checkEqual(t, host, "envhost")
// Test source inspection // Test source inspection
sources := cfg.GetSources("server.port") sources := cfg.GetSources("server.port")
assert.Equal(t, "7070", sources[SourceCLI]) checkEqual(t, sources[SourceCLI], "7070")
assert.Equal(t, "9090", sources[SourceEnv]) checkEqual(t, sources[SourceEnv], "9090")
assert.Equal(t, int64(8080), sources[SourceFile]) checkEqual(t, sources[SourceFile], int64(8080))
} }
// TestAtomicSave tests atomic file saving // TestAtomicSave tests atomic file saving
@@ -355,23 +351,27 @@ func TestAtomicSave(t *testing.T) {
t.Run("SaveCurrentState", func(t *testing.T) { t.Run("SaveCurrentState", func(t *testing.T) {
savePath := filepath.Join(tmpDir, "saved.toml") savePath := filepath.Join(tmpDir, "saved.toml")
err := cfg.Save(savePath) err := cfg.Save(savePath)
require.NoError(t, err) mustNoError(t, err)
// Verify file exists and is readable // Verify file exists and is readable
content, err := os.ReadFile(savePath) content, err := os.ReadFile(savePath)
require.NoError(t, err) mustNoError(t, err)
assert.Contains(t, string(content), "savehost") if got := string(content); !strings.Contains(got, "savehost") {
assert.Contains(t, string(content), "9999") t.Errorf("unexpected substring membership: %q in %q", "savehost", got)
}
if got := string(content); !strings.Contains(got, "9999") {
t.Errorf("unexpected substring membership: %q in %q", "9999", got)
}
// Load into new config to verify // Load into new config to verify
cfg2 := New() cfg2 := New()
cfg2.Register("server.host", "") cfg2.Register("server.host", "")
cfg2.Register("server.port", 0) cfg2.Register("server.port", 0)
err = cfg2.LoadFile(savePath) err = cfg2.LoadFile(savePath)
require.NoError(t, err) mustNoError(t, err)
host, _ := cfg2.Get("server.host") host, _ := cfg2.Get("server.host")
assert.Equal(t, "savehost", host) checkEqual(t, host, "savehost")
}) })
t.Run("SaveSpecificSource", func(t *testing.T) { t.Run("SaveSpecificSource", func(t *testing.T) {
@@ -381,23 +381,31 @@ func TestAtomicSave(t *testing.T) {
savePath := filepath.Join(tmpDir, "env-only.toml") savePath := filepath.Join(tmpDir, "env-only.toml")
err := cfg.SaveSource(savePath, SourceEnv) err := cfg.SaveSource(savePath, SourceEnv)
require.NoError(t, err) mustNoError(t, err)
content, err := os.ReadFile(savePath) content, err := os.ReadFile(savePath)
require.NoError(t, err) mustNoError(t, err)
assert.Contains(t, string(content), "envhost") if got := string(content); !strings.Contains(got, "envhost") {
assert.Contains(t, string(content), "7777") t.Errorf("unexpected substring membership: %q in %q", "envhost", got)
assert.NotContains(t, string(content), "6666") }
if got := string(content); !strings.Contains(got, "7777") {
t.Errorf("unexpected substring membership: %q in %q", "7777", got)
}
if got := string(content); strings.Contains(got, "6666") {
t.Errorf("unexpected substring membership: %q in %q", "6666", got)
}
}) })
t.Run("SaveToNonExistentDirectory", func(t *testing.T) { t.Run("SaveToNonExistentDirectory", func(t *testing.T) {
savePath := filepath.Join(tmpDir, "new", "dir", "config.toml") savePath := filepath.Join(tmpDir, "new", "dir", "config.toml")
err := cfg.Save(savePath) err := cfg.Save(savePath)
require.NoError(t, err) mustNoError(t, err)
// Verify file was created // Verify file was created
_, err = os.Stat(savePath) _, err = os.Stat(savePath)
assert.NoError(t, err) if err != nil {
t.Errorf("unexpected error: %v", err)
}
}) })
} }
@@ -414,21 +422,12 @@ func TestExportEnv(t *testing.T) {
exports := cfg.ExportEnv("APP_") exports := cfg.ExportEnv("APP_")
assert.Len(t, exports, 2) if got := len(exports); got != 2 {
assert.Equal(t, "exporthost", exports["APP_SERVER_HOST"]) t.Errorf("length = %d, want %d", got, 2)
assert.Equal(t, "true", exports["APP_FEATURE_ENABLED"])
assert.NotContains(t, exports, "APP_SERVER_PORT") // Still default
}
// splitEnvVar splits environment variable into key and value
func splitEnvVar(env string) []string {
parts := make([]string, 2)
for i := 0; i < len(env); i++ {
if env[i] == '=' {
parts[0] = env[:i]
parts[1] = env[i+1:]
return parts
} }
} checkEqual(t, exports["APP_SERVER_HOST"], "exporthost")
return []string{env} checkEqual(t, exports["APP_FEATURE_ENABLED"], "true")
if _, ok := exports["APP_SERVER_PORT"]; ok {
t.Errorf("unexpected map membership for %q", "APP_SERVER_PORT")
} // Still default
} }
+89
View File
@@ -0,0 +1,89 @@
package config
import (
"os"
"reflect"
"strings"
"testing"
"time"
)
// Container and interface comparisons must retain Go types and nil/empty distinctions.
func checkEqual(t testing.TB, got, want any, context ...string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
prefix := ""
if len(context) > 0 {
prefix = strings.Join(context, "; ") + ": "
}
t.Errorf("%sgot %#v (%T), want %#v (%T)", prefix, got, got, want, want)
}
}
func mustNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func checkErrorContains(t testing.TB, err error, fragment string) {
t.Helper()
if err == nil || !strings.Contains(err.Error(), fragment) {
t.Errorf("error = %v, want text containing %q", err, fragment)
}
}
func mustPanic(t testing.TB, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatal("expected a panic")
}
}()
fn()
}
func writeTestFile(t testing.TB, path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatalf("write fixture %q: %v", path, err)
}
}
// File-backed capture avoids pipe capacity limits and restores stdout even if
// the tested operation calls Fatal. Tests using process globals run serially.
func captureStdout(t testing.TB, fn func()) string {
t.Helper()
file, err := os.CreateTemp(t.TempDir(), "stdout-*")
mustNoError(t, err)
previous := os.Stdout
t.Cleanup(func() { os.Stdout = previous; _ = file.Close() })
os.Stdout = file
fn()
os.Stdout = previous
mustNoError(t, file.Close())
data, err := os.ReadFile(file.Name())
mustNoError(t, err)
return string(data)
}
func checkWatchingState(t testing.TB, cfg *Config, expected bool, context ...string) {
t.Helper()
// Starting and stopping publish their state synchronously.
checkEqual(t, cfg.IsWatching(), expected, context...)
}
func receiveWatchEvent(t testing.TB, changes <-chan string) string {
t.Helper()
select {
case event, ok := <-changes:
if !ok {
t.Fatal("watch channel closed before the expected event")
}
return event
case <-time.After(testWatchTimeout):
t.Fatal("timed out waiting for a watch event")
return ""
}
}
+57
View File
@@ -0,0 +1,57 @@
package config
import "testing"
func TestTypedStructDefaultsAndSnapshots(t *testing.T) {
type plugin struct {
Limit uint64 `toml:"limit"`
Data map[string]any `toml:"data"`
}
type settings struct {
Plugins []plugin `toml:"plugins"`
ByName map[string]*plugin `toml:"by_name"`
Fixed [1]plugin `toml:"fixed"`
}
item := plugin{Limit: ^uint64(0), Data: map[string]any{"tags": []string{"original"}}}
initial := settings{Plugins: []plugin{item}, ByName: map[string]*plugin{"one": &item}, Fixed: [1]plugin{item}}
cfg, err := NewBuilder().WithTarget(&initial).WithArgs(nil).Build()
if err != nil {
t.Fatal(err)
}
value, err := cfg.AsStruct()
if err != nil {
t.Fatal(err)
}
first := value.(*settings)
initial.Plugins[0].Data["tags"].([]string)[0] = "caller"
first.Plugins[0].Data["tags"].([]string)[0] = "snapshot"
first.ByName["one"].Limit = 0
first.Fixed[0].Data["tags"] = nil
value, err = cfg.AsStruct()
if err != nil {
t.Fatal(err)
}
next := value.(*settings)
for _, got := range []plugin{next.Plugins[0], *next.ByName["one"], next.Fixed[0]} {
if got.Limit != ^uint64(0) || got.Data["tags"].([]string)[0] != "original" {
t.Fatalf("native struct was truncated or aliased: %+v", got)
}
}
}
func TestTypedStructDecodeStillRejectsCycles(t *testing.T) {
type node struct {
Next *node `toml:"next"`
}
cyclic := &node{}
cyclic.Next = cyclic
target := struct {
Nodes []node `toml:"nodes"`
}{Nodes: []node{{}}}
if err := ScanMap(map[string]any{"nodes": []node{*cyclic}}, &target); err == nil {
t.Fatal("native struct copying accepted a cycle")
}
if len(target.Nodes) != 1 || target.Nodes[0].Next != nil {
t.Fatal("failed decode modified the destination")
}
}
+135 -114
View File
@@ -5,18 +5,16 @@ import (
"flag" "flag"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestQuickFunctions tests the utility Quick* functions // TestQuickFunctions tests the utility Quick* functions
func TestQuickFunctions(t *testing.T) { func TestQuickFunctions(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "quick.toml") configFile := filepath.Join(tmpDir, "quick.toml")
os.WriteFile(configFile, []byte(` writeTestFile(t, configFile, []byte(`
host = "quickhost" host = "quickhost"
port = 7777 port = 7777
`), 0644) `), 0644)
@@ -37,18 +35,18 @@ port = 7777
// Mock os.Args // Mock os.Args
oldArgs := os.Args oldArgs := os.Args
os.Args = []string{"cmd", "--port=9999"} os.Args = []string{"cmd", "--port=9999"}
defer func() { os.Args = oldArgs }() t.Cleanup(func() { os.Args = oldArgs })
cfg, err := Quick(defaults, "QUICK_", configFile) cfg, err := Quick(defaults, "QUICK_", configFile)
require.NoError(t, err) mustNoError(t, err)
// CLI should override // CLI should override
port, _ := cfg.Get("port") port, _ := cfg.Get("port")
assert.Equal(t, "9999", port) checkEqual(t, port, "9999")
// File value // File value
host, _ := cfg.Get("host") host, _ := cfg.Get("host")
assert.Equal(t, "quickhost", host) checkEqual(t, host, "quickhost")
}) })
t.Run("QuickCustom", func(t *testing.T) { t.Run("QuickCustom", func(t *testing.T) {
@@ -58,22 +56,24 @@ port = 7777
} }
cfg, err := QuickCustom(defaults, opts, configFile) cfg, err := QuickCustom(defaults, opts, configFile)
require.NoError(t, err) mustNoError(t, err)
// Should use file value // Should use file value
port, _ := cfg.Get("port") port, _ := cfg.Get("port")
assert.Equal(t, int64(7777), port) checkEqual(t, port, int64(7777))
}) })
t.Run("MustQuickPanic", func(t *testing.T) { t.Run("MustQuickPanic", func(t *testing.T) {
// Valid case - should not panic // Valid case - should not panic
assert.NotPanics(t, func() { {
cfg := MustQuick(defaults, "TEST_", configFile) cfg := MustQuick(defaults, "TEST_", configFile)
assert.NotNil(t, cfg) if got := cfg; got == nil {
}) t.Errorf("got %v, want non-nil", got)
}
}
// Invalid struct - should panic // Invalid struct - should panic
assert.Panics(t, func() { mustPanic(t, func() {
MustQuick("not-a-struct", "TEST_", configFile) MustQuick("not-a-struct", "TEST_", configFile)
}) })
}) })
@@ -86,15 +86,15 @@ port = 7777
} }
cfg, err := QuickTyped(target, "TYPED_", configFile) cfg, err := QuickTyped(target, "TYPED_", configFile)
require.NoError(t, err) mustNoError(t, err)
// Should populate from file // Should populate from file
updated, err := cfg.AsStruct() updated, err := cfg.AsStruct()
require.NoError(t, err) mustNoError(t, err)
typedCfg := updated.(*QuickConfig) typedCfg := updated.(*QuickConfig)
assert.Equal(t, "quickhost", typedCfg.Host) checkEqual(t, typedCfg.Host, "quickhost")
assert.Equal(t, 7777, typedCfg.Port) checkEqual(t, typedCfg.Port, 7777)
}) })
} }
@@ -110,24 +110,34 @@ func TestFlagGeneration(t *testing.T) {
t.Run("GenerateFlags", func(t *testing.T) { t.Run("GenerateFlags", func(t *testing.T) {
fs := cfg.GenerateFlags() fs := cfg.GenerateFlags()
require.NotNil(t, fs) if got := fs; got == nil {
t.Fatalf("got %v, want non-nil", got)
}
// Verify flags exist // Verify flags exist
hostFlag := fs.Lookup("server.host") hostFlag := fs.Lookup("server.host")
require.NotNil(t, hostFlag) if got := hostFlag; got == nil {
assert.Equal(t, "localhost", hostFlag.DefValue) t.Fatalf("got %v, want non-nil", got)
}
checkEqual(t, hostFlag.DefValue, "localhost")
portFlag := fs.Lookup("server.port") portFlag := fs.Lookup("server.port")
require.NotNil(t, portFlag) if got := portFlag; got == nil {
assert.Equal(t, "8080", portFlag.DefValue) t.Fatalf("got %v, want non-nil", got)
}
checkEqual(t, portFlag.DefValue, "8080")
debugFlag := fs.Lookup("debug.enabled") debugFlag := fs.Lookup("debug.enabled")
require.NotNil(t, debugFlag) if got := debugFlag; got == nil {
assert.Equal(t, "false", debugFlag.DefValue) t.Fatalf("got %v, want non-nil", got)
}
checkEqual(t, debugFlag.DefValue, "false")
timeoutFlag := fs.Lookup("timeout") timeoutFlag := fs.Lookup("timeout")
require.NotNil(t, timeoutFlag) if got := timeoutFlag; got == nil {
assert.Equal(t, "30.5", timeoutFlag.DefValue) t.Fatalf("got %v, want non-nil", got)
}
checkEqual(t, timeoutFlag.DefValue, "30.5")
}) })
t.Run("BindFlags", func(t *testing.T) { t.Run("BindFlags", func(t *testing.T) {
@@ -138,21 +148,21 @@ func TestFlagGeneration(t *testing.T) {
// Parse with test values // Parse with test values
err := fs.Parse([]string{"-server.host=flaghost", "-server.port=5555", "-debug.enabled"}) err := fs.Parse([]string{"-server.host=flaghost", "-server.port=5555", "-debug.enabled"})
require.NoError(t, err) mustNoError(t, err)
// Bind to config // Bind to config
err = cfg.BindFlags(fs) err = cfg.BindFlags(fs)
require.NoError(t, err) mustNoError(t, err)
// Verify values were set // Verify values were set
host, _ := cfg.Get("server.host") host, _ := cfg.Get("server.host")
assert.Equal(t, "flaghost", host) checkEqual(t, host, "flaghost")
port, _ := cfg.Get("server.port") port, _ := cfg.Get("server.port")
assert.Equal(t, "5555", port) checkEqual(t, port, "5555")
debug, _ := cfg.Get("debug.enabled") debug, _ := cfg.Get("debug.enabled")
assert.Equal(t, "true", debug) checkEqual(t, debug, "true")
}) })
t.Run("BindFlagsError", func(t *testing.T) { t.Run("BindFlagsError", func(t *testing.T) {
@@ -161,8 +171,7 @@ func TestFlagGeneration(t *testing.T) {
fs.Parse([]string{"-unregistered.path=test"}) fs.Parse([]string{"-unregistered.path=test"})
err := cfg.BindFlags(fs) err := cfg.BindFlags(fs)
assert.Error(t, err) checkErrorContains(t, err, "unregistered flag")
assert.Contains(t, err.Error(), "unregistered flag")
}) })
} }
@@ -175,10 +184,9 @@ func TestValidation(t *testing.T) {
t.Run("ValidationFails", func(t *testing.T) { t.Run("ValidationFails", func(t *testing.T) {
err := cfg.Validate("required.host", "required.port") err := cfg.Validate("required.host", "required.port")
assert.Error(t, err) checkErrorContains(t, err, "missing required configuration")
assert.Contains(t, err.Error(), "missing required configuration") checkErrorContains(t, err, "required.host")
assert.Contains(t, err.Error(), "required.host") checkErrorContains(t, err, "required.port")
assert.Contains(t, err.Error(), "required.port")
}) })
t.Run("ValidationPasses", func(t *testing.T) { t.Run("ValidationPasses", func(t *testing.T) {
@@ -186,13 +194,14 @@ func TestValidation(t *testing.T) {
cfg.Set("required.port", 8080) cfg.Set("required.port", 8080)
err := cfg.Validate("required.host", "required.port") err := cfg.Validate("required.host", "required.port")
assert.NoError(t, err) if err != nil {
t.Errorf("unexpected error: %v", err)
}
}) })
t.Run("ValidationUnregisteredPath", func(t *testing.T) { t.Run("ValidationUnregisteredPath", func(t *testing.T) {
err := cfg.Validate("nonexistent.path") err := cfg.Validate("nonexistent.path")
assert.Error(t, err) checkErrorContains(t, err, "nonexistent.path (not registered)")
assert.Contains(t, err.Error(), "nonexistent.path (not registered)")
}) })
t.Run("ValidationWithSourceValue", func(t *testing.T) { t.Run("ValidationWithSourceValue", func(t *testing.T) {
@@ -203,7 +212,9 @@ func TestValidation(t *testing.T) {
cfg2.SetSource(SourceEnv, "test", "default") cfg2.SetSource(SourceEnv, "test", "default")
err := cfg2.Validate("test") err := cfg2.Validate("test")
assert.NoError(t, err) // Should pass because env provided value if err != nil {
t.Errorf("unexpected error: %v", err)
} // Should pass because env provided value
}) })
} }
@@ -220,35 +231,25 @@ func TestDebugAndDump(t *testing.T) {
t.Run("Debug", func(t *testing.T) { t.Run("Debug", func(t *testing.T) {
debug := cfg.Debug() debug := cfg.Debug()
assert.Contains(t, debug, "Configuration Debug Info") for _, fragment := range []string{"Configuration Debug Info", "Precedence:", "server.host:", "Current: envhost", "Default: localhost", "file: filehost", "env: envhost"} {
assert.Contains(t, debug, "Precedence:") if !strings.Contains(debug, fragment) {
assert.Contains(t, debug, "server.host:") t.Errorf("debug output %q is missing %q", debug, fragment)
assert.Contains(t, debug, "Current: envhost") }
assert.Contains(t, debug, "Default: localhost") }
assert.Contains(t, debug, "file: filehost")
assert.Contains(t, debug, "env: envhost")
}) })
t.Run("Dump", func(t *testing.T) { t.Run("Dump", func(t *testing.T) {
// Capture stdout outputStr := captureStdout(t, func() { mustNoError(t, cfg.Dump()) })
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := cfg.Dump() if got := outputStr; !strings.Contains(got, "[server]") {
assert.NoError(t, err) t.Errorf("unexpected substring membership: %q in %q", "[server]", got)
}
w.Close() if got := outputStr; !strings.Contains(got, "host = ") {
os.Stdout = oldStdout t.Errorf("unexpected substring membership: %q in %q", "host = ", got)
}
// Read output if got := outputStr; !strings.Contains(got, "port = ") {
output := make([]byte, 1024) t.Errorf("unexpected substring membership: %q in %q", "port = ", got)
n, _ := r.Read(output) }
outputStr := string(output[:n])
assert.Contains(t, outputStr, "[server]")
assert.Contains(t, outputStr, "host = ")
assert.Contains(t, outputStr, "port = ")
}) })
} }
@@ -262,16 +263,22 @@ func TestClone(t *testing.T) {
cfg.SetSource(SourceEnv, "shared.value", "envvalue") cfg.SetSource(SourceEnv, "shared.value", "envvalue")
clone := cfg.Clone() clone := cfg.Clone()
require.NotNil(t, clone) if got := clone; got == nil {
t.Fatalf("got %v, want non-nil", got)
}
// Verify values are copied // Verify values are copied
val, exists := clone.Get("original.value") val, exists := clone.Get("original.value")
assert.True(t, exists) if !exists {
assert.Equal(t, "filevalue", val) t.Errorf("exists should be true")
}
checkEqual(t, val, "filevalue")
val, exists = clone.Get("shared.value") val, exists = clone.Get("shared.value")
assert.True(t, exists) if !exists {
assert.Equal(t, "envvalue", val) t.Errorf("exists should be true")
}
checkEqual(t, val, "envvalue")
// Modify clone should not affect original // Modify clone should not affect original
clone.Set("original.value", "clonevalue") clone.Set("original.value", "clonevalue")
@@ -279,12 +286,12 @@ func TestClone(t *testing.T) {
originalVal, _ := cfg.Get("original.value") originalVal, _ := cfg.Get("original.value")
cloneVal, _ := clone.Get("original.value") cloneVal, _ := clone.Get("original.value")
assert.Equal(t, "filevalue", originalVal) checkEqual(t, originalVal, "filevalue")
assert.Equal(t, "clonevalue", cloneVal) checkEqual(t, cloneVal, "clonevalue")
// Verify source data is copied // Verify source data is copied
sources := clone.GetSources("shared.value") sources := clone.GetSources("shared.value")
assert.Equal(t, "envvalue", sources[SourceEnv]) checkEqual(t, sources[SourceEnv], "envvalue")
} }
// TestGenericHelpers tests generic helper functions // TestGenericHelpers tests generic helper functions
@@ -297,20 +304,22 @@ func TestGenericHelpers(t *testing.T) {
t.Run("GetTyped", func(t *testing.T) { t.Run("GetTyped", func(t *testing.T) {
port, err := GetTyped[int](cfg, "server.port") port, err := GetTyped[int](cfg, "server.port")
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, 8080, port) checkEqual(t, port, 8080)
host, err := GetTyped[string](cfg, "server.host") host, err := GetTyped[string](cfg, "server.host")
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, "localhost", host) checkEqual(t, host, "localhost")
// Test with custom decode hook type // Test with custom decode hook type
readTimeout, err := GetTyped[time.Duration](cfg, "timeouts.read") readTimeout, err := GetTyped[time.Duration](cfg, "timeouts.read")
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, 5*time.Second, readTimeout) checkEqual(t, readTimeout, 5*time.Second)
_, err = GetTyped[int](cfg, "nonexistent.path") _, err = GetTyped[int](cfg, "nonexistent.path")
assert.Error(t, err) if err == nil {
t.Errorf("expected an error")
}
}) })
t.Run("ScanTyped", func(t *testing.T) { t.Run("ScanTyped", func(t *testing.T) {
@@ -320,10 +329,12 @@ func TestGenericHelpers(t *testing.T) {
} }
serverConf, err := ScanTyped[ServerConfig](cfg, "server") serverConf, err := ScanTyped[ServerConfig](cfg, "server")
require.NoError(t, err) mustNoError(t, err)
require.NotNil(t, serverConf) if got := serverConf; got == nil {
assert.Equal(t, "localhost", serverConf.Host) t.Fatalf("got %v, want non-nil", got)
assert.Equal(t, 8080, serverConf.Port) }
checkEqual(t, serverConf.Host, "localhost")
checkEqual(t, serverConf.Port, 8080)
}) })
} }
@@ -334,13 +345,15 @@ func TestGetTypedWithDefault(t *testing.T) {
// Get with default when path doesn't exist // Get with default when path doesn't exist
port, err := GetTypedWithDefault(cfg, "server.port", int64(8080)) port, err := GetTypedWithDefault(cfg, "server.port", int64(8080))
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, int64(8080), port) checkEqual(t, port, int64(8080))
// Verify it was actually set // Verify it was actually set
val, exists := cfg.Get("server.port") val, exists := cfg.Get("server.port")
assert.True(t, exists) if !exists {
assert.Equal(t, int64(8080), val) t.Errorf("exists should be true")
}
checkEqual(t, val, int64(8080))
}) })
t.Run("PathAlreadySet", func(t *testing.T) { t.Run("PathAlreadySet", func(t *testing.T) {
@@ -350,8 +363,8 @@ func TestGetTypedWithDefault(t *testing.T) {
// Should return existing value, not default // Should return existing value, not default
host, err := GetTypedWithDefault(cfg, "server.host", "default.com") host, err := GetTypedWithDefault(cfg, "server.host", "default.com")
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, "example.com", host) checkEqual(t, host, "example.com")
}) })
t.Run("DifferentTypes", func(t *testing.T) { t.Run("DifferentTypes", func(t *testing.T) {
@@ -359,16 +372,18 @@ func TestGetTypedWithDefault(t *testing.T) {
// Test with various types // Test with various types
timeout, err := GetTypedWithDefault(cfg, "timeouts.read", 30*time.Second) timeout, err := GetTypedWithDefault(cfg, "timeouts.read", 30*time.Second)
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, 30*time.Second, timeout) checkEqual(t, timeout, 30*time.Second)
enabled, err := GetTypedWithDefault(cfg, "features.enabled", true) enabled, err := GetTypedWithDefault(cfg, "features.enabled", true)
require.NoError(t, err) mustNoError(t, err)
assert.True(t, enabled) if !enabled {
t.Errorf("enabled should be true")
}
tags, err := GetTypedWithDefault(cfg, "app.tags", []string{"default", "tag"}) tags, err := GetTypedWithDefault(cfg, "app.tags", []string{"default", "tag"})
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, []string{"default", "tag"}, tags) checkEqual(t, tags, []string{"default", "tag"})
}) })
} }
@@ -396,16 +411,18 @@ func TestScanMap(t *testing.T) {
var target Config var target Config
err := ScanMap(configMap, &target) err := ScanMap(configMap, &target)
require.NoError(t, err) mustNoError(t, err)
assert.Equal(t, "localhost", target.Server.Host) checkEqual(t, target.Server.Host, "localhost")
assert.Equal(t, 8080, target.Server.Port) checkEqual(t, target.Server.Port, 8080)
assert.Equal(t, 15*time.Second, target.Server.Timeout) checkEqual(t, target.Server.Timeout, 15*time.Second)
assert.Equal(t, "info", target.LogLevel) checkEqual(t, target.LogLevel, "info")
}) })
t.Run("RejectJSONTags", func(t *testing.T) { t.Run("RejectJSONTags", func(t *testing.T) {
var target Config var target Config
require.Error(t, ScanMap(map[string]any{}, &target, "json")) if err := ScanMap(map[string]any{}, &target, "json"); err == nil {
t.Fatalf("expected an error")
}
}) })
t.Run("NilMapInput", func(t *testing.T) { t.Run("NilMapInput", func(t *testing.T) {
@@ -414,13 +431,15 @@ func TestScanMap(t *testing.T) {
target.Server.Port = 1234 target.Server.Port = 1234
err := ScanMap(nil, &target) err := ScanMap(nil, &target)
require.NoError(t, err) mustNoError(t, err)
// Verify that fields are NOT changed when the map is empty, // Verify that fields are NOT changed when the map is empty,
// reflecting the observed behavior. // reflecting the observed behavior.
assert.Equal(t, "initial", target.LogLevel) checkEqual(t, target.LogLevel, "initial")
assert.Equal(t, 1234, target.Server.Port) checkEqual(t, target.Server.Port, 1234)
assert.Empty(t, target.Server.Host) if got := len(target.Server.Host); got != 0 {
t.Errorf("length = %d, want %d", got, 0)
}
}) })
t.Run("PartialMapBehavior", func(t *testing.T) { t.Run("PartialMapBehavior", func(t *testing.T) {
@@ -433,13 +452,13 @@ func TestScanMap(t *testing.T) {
target.LogLevel = "initial_log" target.LogLevel = "initial_log"
err := ScanMap(configMap, &target) err := ScanMap(configMap, &target)
require.NoError(t, err) mustNoError(t, err)
// Mapped field should be updated // Mapped field should be updated
assert.Equal(t, "warn", target.LogLevel) checkEqual(t, target.LogLevel, "warn")
// Unmapped fields should be untouched // Unmapped fields should be untouched
assert.Equal(t, "initial_host", target.Server.Host, "Unmapped field should be untouched") checkEqual(t, target.Server.Host, "initial_host", "Unmapped field should be untouched")
assert.Equal(t, 1234, target.Server.Port, "Unmapped field should be untouched") checkEqual(t, target.Server.Port, 1234, "Unmapped field should be untouched")
}) })
t.Run("InvalidTarget", func(t *testing.T) { t.Run("InvalidTarget", func(t *testing.T) {
@@ -447,8 +466,10 @@ func TestScanMap(t *testing.T) {
var target Config // Not a pointer var target Config // Not a pointer
err := ScanMap(configMap, target) err := ScanMap(configMap, target)
assert.Error(t, err) if err == nil {
t.Errorf("expected an error")
}
// Invalid targets return a categorized type error. // Invalid targets return a categorized type error.
assert.Contains(t, err.Error(), "must be non-nil pointer") checkErrorContains(t, err, "must be non-nil pointer")
}) })
} }
+47 -111
View File
@@ -1,163 +1,99 @@
// FILE: lixenwraith/config/validator_test.go
package config package config
import ( import (
"fmt"
"testing" "testing"
"github.com/stretchr/testify/assert"
) )
// TestPortValidator tests the Port validator // Each value is a subtest, so one failure does not hide another boundary case.
func TestPortValidator(t *testing.T) { func checkValidator[T any](t *testing.T, validate func(T) error, valid, invalid []T, errorText ...string) {
tests := []struct { t.Helper()
for _, group := range []struct {
name string name string
port int64 values []T
wantErr bool wantError bool
}{ }{{"valid", valid, false}, {"invalid", invalid, true}} {
{"ValidLowPort", 1, false}, t.Run(group.name, func(t *testing.T) {
{"ValidCommonPort", 8080, false}, for _, value := range group.values {
{"ValidHighPort", 65535, false}, t.Run(fmt.Sprintf("%v", value), func(t *testing.T) {
{"InvalidZeroPort", 0, true}, err := validate(value)
{"InvalidNegativePort", -1, true}, if (err != nil) != group.wantError {
{"InvalidTooHighPort", 65536, true}, t.Fatalf("validate(%#v) = %v, want error: %v", value, err, group.wantError)
} }
if group.wantError {
for _, tt := range tests { for _, fragment := range errorText {
t.Run(tt.name, func(t *testing.T) { checkErrorContains(t, err, fragment)
err := Port(tt.port) }
if tt.wantErr { }
assert.Error(t, err) })
} else {
assert.NoError(t, err)
} }
}) })
} }
} }
// TestPositiveValidator tests the Positive validator func TestPortValidator(t *testing.T) {
checkValidator(t, Port, []int64{1, 8080, 65535}, []int64{0, -1, 65536})
}
func TestPositiveValidator(t *testing.T) { func TestPositiveValidator(t *testing.T) {
t.Run("Int64", func(t *testing.T) { t.Run("Int64", func(t *testing.T) {
assert.NoError(t, Positive(int64(1))) checkValidator(t, Positive[int64], []int64{1}, []int64{0, -1})
assert.Error(t, Positive(int64(0)))
assert.Error(t, Positive(int64(-1)))
}) })
t.Run("Float64", func(t *testing.T) { t.Run("Float64", func(t *testing.T) {
assert.NoError(t, Positive(0.001)) checkValidator(t, Positive[float64], []float64{0.001}, []float64{0, -0.001})
assert.Error(t, Positive(0.0))
assert.Error(t, Positive(-0.001))
}) })
} }
// TestNonNegativeValidator tests the NonNegative validator
func TestNonNegativeValidator(t *testing.T) { func TestNonNegativeValidator(t *testing.T) {
t.Run("Int64", func(t *testing.T) { t.Run("Int64", func(t *testing.T) {
assert.NoError(t, NonNegative(int64(1))) checkValidator(t, NonNegative[int64], []int64{1, 0}, []int64{-1})
assert.NoError(t, NonNegative(int64(0)))
assert.Error(t, NonNegative(int64(-1)))
}) })
t.Run("Float64", func(t *testing.T) { t.Run("Float64", func(t *testing.T) {
assert.NoError(t, NonNegative(0.001)) checkValidator(t, NonNegative[float64], []float64{0.001, 0}, []float64{-0.001})
assert.NoError(t, NonNegative(0.0))
assert.Error(t, NonNegative(-0.001))
}) })
} }
// TestIPAddressValidators tests all IP-related validators
func TestIPAddressValidators(t *testing.T) { func TestIPAddressValidators(t *testing.T) {
t.Run("IPAddress", func(t *testing.T) { for _, tc := range []struct {
assert.NoError(t, IPAddress("192.168.1.1")) name string
assert.NoError(t, IPAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")) validate func(string) error
assert.NoError(t, IPAddress("")) valid, invalid []string
assert.NoError(t, IPAddress("0.0.0.0")) }{
assert.NoError(t, IPAddress("::")) {"IPAddress", IPAddress, []string{"192.168.1.1", "2001:0db8:85a3:0000:0000:8a2e:0370:7334", "", "0.0.0.0", "::"}, []string{"not-an-ip", "192.168.1.256"}},
assert.Error(t, IPAddress("not-an-ip")) {"IPv4Address", IPv4Address, []string{"192.168.1.1", "", "0.0.0.0"}, []string{"::1", "not-an-ip"}},
assert.Error(t, IPAddress("192.168.1.256")) {"IPv6Address", IPv6Address, []string{"2001:db8::1", "", "::"}, []string{"127.0.0.1", "not-an-ip"}},
}) } {
t.Run(tc.name, func(t *testing.T) { checkValidator(t, tc.validate, tc.valid, tc.invalid) })
t.Run("IPv4Address", func(t *testing.T) { }
assert.NoError(t, IPv4Address("192.168.1.1"))
assert.NoError(t, IPv4Address(""))
assert.NoError(t, IPv4Address("0.0.0.0"))
assert.Error(t, IPv4Address("::1")) // Is not IPv4
assert.Error(t, IPv4Address("not-an-ip"))
})
t.Run("IPv6Address", func(t *testing.T) {
assert.NoError(t, IPv6Address("2001:db8::1"))
assert.NoError(t, IPv6Address(""))
assert.NoError(t, IPv6Address("::"))
assert.Error(t, IPv6Address("127.0.0.1")) // Is not IPv6
assert.Error(t, IPv6Address("not-an-ip"))
})
} }
// TestURLPathValidator tests the URLPath validator
func TestURLPathValidator(t *testing.T) { func TestURLPathValidator(t *testing.T) {
assert.NoError(t, URLPath("/api/v1")) checkValidator(t, URLPath, []string{"/api/v1", "/", ""}, []string{"api/v1", "no-slash"})
assert.NoError(t, URLPath("/"))
assert.NoError(t, URLPath(""))
assert.Error(t, URLPath("api/v1"))
assert.Error(t, URLPath("no-slash"))
} }
// TestOneOfValidator tests the OneOf validator
func TestOneOfValidator(t *testing.T) { func TestOneOfValidator(t *testing.T) {
t.Run("String", func(t *testing.T) { t.Run("String", func(t *testing.T) {
validator := OneOf("prod", "dev", "staging") checkValidator(t, OneOf("prod", "dev", "staging"), []string{"prod", "dev"}, []string{"test"}, "must be one of")
assert.NoError(t, validator("prod"))
assert.NoError(t, validator("dev"))
err := validator("test")
assert.Error(t, err)
assert.Contains(t, err.Error(), "must be one of")
}) })
t.Run("Int", func(t *testing.T) { t.Run("Int", func(t *testing.T) {
validator := OneOf(200, 404, 500) checkValidator(t, OneOf(200, 404, 500), []int{404}, []int{302}, "must be one of")
assert.NoError(t, validator(404))
err := validator(302)
assert.Error(t, err)
assert.Contains(t, err.Error(), "must be one of")
}) })
} }
// TestRangeValidator tests the Range validator
func TestRangeValidator(t *testing.T) { func TestRangeValidator(t *testing.T) {
t.Run("Int64", func(t *testing.T) { t.Run("Int64", func(t *testing.T) {
validator := Range[int64](10, 100) checkValidator(t, Range[int64](10, 100), []int64{10, 50, 100}, []int64{9, 101})
assert.NoError(t, validator(10))
assert.NoError(t, validator(50))
assert.NoError(t, validator(100))
assert.Error(t, validator(9))
assert.Error(t, validator(101))
}) })
t.Run("Float64", func(t *testing.T) { t.Run("Float64", func(t *testing.T) {
validator := Range[float64](-1.5, 1.5) checkValidator(t, Range[float64](-1.5, 1.5), []float64{-1.5, 0, 1.5}, []float64{-1.51, 1.51})
assert.NoError(t, validator(-1.5))
assert.NoError(t, validator(0.0))
assert.NoError(t, validator(1.5))
assert.Error(t, validator(-1.51))
assert.Error(t, validator(1.51))
}) })
} }
// TestPatternValidator tests the Pattern validator
func TestPatternValidator(t *testing.T) { func TestPatternValidator(t *testing.T) {
// Simple email regex checkValidator(t, Pattern(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`),
validator := Pattern(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) []string{"test@example.com", "user.name+alias@domain.co.uk"}, []string{"not-an-email", "test@example"})
assert.NoError(t, validator("test@example.com"))
assert.NoError(t, validator("user.name+alias@domain.co.uk"))
assert.Error(t, validator("not-an-email"))
assert.Error(t, validator("test@example"))
} }
// TestNonEmptyValidator tests the NonEmpty validator
func TestNonEmptyValidator(t *testing.T) { func TestNonEmptyValidator(t *testing.T) {
assert.NoError(t, NonEmpty("hello")) checkValidator(t, NonEmpty, []string{"hello", " a "}, []string{"", " ", " \t\n "})
assert.NoError(t, NonEmpty(" a "))
assert.Error(t, NonEmpty(""))
assert.Error(t, NonEmpty(" "))
assert.Error(t, NonEmpty(" \t\n "))
} }
+79 -190
View File
@@ -9,31 +9,13 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// Test-specific timing constants derived from production values // Real watcher tests wait for events; debounce and timeout cases use manualWatcher.
// These accelerate test execution while maintaining timing relationships
const ( const (
// testAcceleration reduces all intervals by this factor for faster tests testPollInterval = DefaultPollInterval / 10
testAcceleration = 10 testDebounce = DefaultDebounce / 10
testWatchTimeout = 2 * DefaultPollInterval
// 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
testPollWindow = 3 * testPollInterval // 300ms change detection window
testStateStabilize = 4 * testDebounce // 200ms for state convergence
) )
// TestAutoUpdate tests automatic configuration reloading // TestAutoUpdate tests automatic configuration reloading
@@ -50,7 +32,7 @@ host = "localhost"
[features] [features]
enabled = true enabled = true
` `
require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0644)) writeTestFile(t, configPath, []byte(initialConfig), 0644)
// Create config with defaults // Create config with defaults
type TestConfig struct { type TestConfig struct {
@@ -72,12 +54,14 @@ enabled = true
WithDefaults(defaults). WithDefaults(defaults).
WithFile(configPath). WithFile(configPath).
Build() Build()
require.NoError(t, err) mustNoError(t, err)
// Verify initial values // Verify initial values
port, exists := cfg.Get("server.port") port, exists := cfg.Get("server.port")
assert.True(t, exists) if !exists {
assert.Equal(t, int64(8080), port) t.Errorf("exists should be true")
}
checkEqual(t, port, int64(8080))
// Enable auto-update with fast polling // Enable auto-update with fast polling
opts := WatchOptions{ opts := WatchOptions{
@@ -91,18 +75,6 @@ enabled = true
// Start watching // Start watching
changes := cfg.Watch() 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 // Update config file
updatedConfig := ` updatedConfig := `
[server] [server]
@@ -112,28 +84,30 @@ host = "0.0.0.0"
[features] [features]
enabled = false enabled = false
` `
require.NoError(t, os.WriteFile(configPath, []byte(updatedConfig), 0644)) writeTestFile(t, configPath, []byte(updatedConfig), 0644)
// Wait for changes to be detected // Wait for publication events, without a collector goroutine or fixed sleep.
time.Sleep(testPollWindow) changedPaths := make(map[string]bool)
for range 3 {
changedPaths[receiveWatchEvent(t, changes)] = true
}
// Verify new values // Verify new values
port, _ = cfg.Get("server.port") port, _ = cfg.Get("server.port")
assert.Equal(t, int64(9090), port) checkEqual(t, port, int64(9090))
host, _ := cfg.Get("server.host") host, _ := cfg.Get("server.host")
assert.Equal(t, "0.0.0.0", host) checkEqual(t, host, "0.0.0.0")
enabled, _ := cfg.Get("features.enabled") enabled, _ := cfg.Get("features.enabled")
assert.Equal(t, false, enabled) checkEqual(t, enabled, false)
// Check that changes were notified // Check that changes were notified
mu.Lock()
defer mu.Unlock()
expectedChanges := []string{"server.port", "server.host", "features.enabled"} expectedChanges := []string{"server.port", "server.host", "features.enabled"}
for _, path := range expectedChanges { for _, path := range expectedChanges {
assert.True(t, changedPaths[path], "Expected change notification for %s", path) if !(changedPaths[path]) {
t.Errorf("changedPaths[path] should be true: %s", fmt.Sprintf("Expected change notification for %s", path))
}
} }
} }
@@ -143,12 +117,12 @@ func TestWatchFileDeleted(t *testing.T) {
configPath := filepath.Join(tmpDir, "test.toml") configPath := filepath.Join(tmpDir, "test.toml")
// Create initial config // Create initial config
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) writeTestFile(t, configPath, []byte(`test = "value"`), 0644)
cfg := New() cfg := New()
cfg.Register("test", "default") cfg.Register("test", "default")
require.NoError(t, cfg.LoadFile(configPath)) mustNoError(t, cfg.LoadFile(configPath))
// Enable watching // Enable watching
opts := WatchOptions{ opts := WatchOptions{
@@ -161,15 +135,9 @@ func TestWatchFileDeleted(t *testing.T) {
changes := cfg.Watch() changes := cfg.Watch()
// Delete file // Delete file
require.NoError(t, os.Remove(configPath)) mustNoError(t, os.Remove(configPath))
// Wait for deletion detection checkEqual(t, receiveWatchEvent(t, changes), EventFileDeleted)
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 // TestWatchPermissionChange tests permission change detection
@@ -183,11 +151,11 @@ func TestWatchPermissionChange(t *testing.T) {
configPath := filepath.Join(tmpDir, "test.toml") configPath := filepath.Join(tmpDir, "test.toml")
// Create config with specific permissions // Create config with specific permissions
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) writeTestFile(t, configPath, []byte(`test = "value"`), 0644)
cfg := New() cfg := New()
cfg.Register("test", "default") cfg.Register("test", "default")
require.NoError(t, cfg.LoadFile(configPath)) mustNoError(t, cfg.LoadFile(configPath))
// Enable watching with permission verification // Enable watching with permission verification
opts := WatchOptions{ opts := WatchOptions{
@@ -201,15 +169,9 @@ func TestWatchPermissionChange(t *testing.T) {
changes := cfg.Watch() changes := cfg.Watch()
// Change permissions to world-writable (security risk) // Change permissions to world-writable (security risk)
require.NoError(t, os.Chmod(configPath, 0666)) mustNoError(t, os.Chmod(configPath, 0666))
// Wait for permission change detection checkEqual(t, receiveWatchEvent(t, changes), EventPermissionsChanged)
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 // TestMaxWatchers tests watcher limit enforcement
@@ -220,8 +182,8 @@ func TestMaxWatchers(t *testing.T) {
// Create config file // Create config file
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml") configPath := filepath.Join(tmpDir, "test.toml")
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) writeTestFile(t, configPath, []byte(`test = "value"`), 0644)
require.NoError(t, cfg.LoadFile(configPath)) mustNoError(t, cfg.LoadFile(configPath))
// Enable watching with low max watchers // Enable watching with low max watchers
opts := WatchOptions{ opts := WatchOptions{
@@ -231,94 +193,45 @@ func TestMaxWatchers(t *testing.T) {
cfg.AutoUpdateWithOptions(opts) cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate() defer cfg.StopAutoUpdate()
// Create maximum allowed watchers for i := range 4 {
channels := make([]<-chan string, 0, 4)
for i := 0; i < 4; i++ {
ch := cfg.Watch() ch := cfg.Watch()
channels = append(channels, ch)
// Check if channel is open
if i < 3 {
// First 3 should be open
select { select {
case _, ok := <-ch: case _, ok := <-ch:
assert.True(t, ok || i < 3, "Channel %d should be open", i) if ok || i < 3 {
default: t.Errorf("subscriber %d closed unexpectedly or received a spurious event", i)
// Channel is open and empty, expected
} }
} else { default:
// 4th should be closed immediately if i == 3 {
select { t.Error("excess subscriber was not closed synchronously")
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 // Verify watcher count
assert.Equal(t, 3, cfg.WatcherCount()) checkEqual(t, cfg.WatcherCount(), 3)
} }
// TestRapidDebounce tests that rapid changes are debounced // TestRapidDebounce tests that rapid changes are debounced
func TestRapidDebounce(t *testing.T) { func TestRapidDebounce(t *testing.T) {
tmpDir := t.TempDir() cfg, path := watcherFixture(t)
configPath := filepath.Join(tmpDir, "test.toml") w := manualWatcher(t, cfg, path)
w.opts.Debounce = time.Hour
// Create initial config changes := w.subscribe()
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++ { for i := 2; i <= 5; i++ {
content := fmt.Sprintf(`value = %d`, i) writeTestFile(t, path, []byte(fmt.Sprintf("value = %d", i)), 0600)
require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) w.checkAndReload(cfg)
time.Sleep(testDebounce) // Less than debounce period if len(changes) != 0 {
t.Fatal("change published before the quiet interval")
} }
}
// Wait for debounce to complete w.observedAt = time.Now().Add(-2 * time.Hour)
time.Sleep(2 * testStateStabilize) w.checkAndReload(cfg)
done <- true checkEqual(t, receiveWatchEvent(t, changes), "value")
if len(changes) != 0 {
// Should only see one change due to debounce t.Fatal("debounced changes emitted multiple events")
mu.Lock() }
defer mu.Unlock() value, _ := cfg.Get("value")
assert.Equal(t, 1, changeCount, "Expected 1 change due to debounce, got %d", changeCount) checkEqual(t, value, int64(5))
// Verify final value
val, _ := cfg.Get("value")
assert.Equal(t, int64(5), val)
} }
// TestWatchWithoutFile tests watching behavior when no file is configured // TestWatchWithoutFile tests watching behavior when no file is configured
@@ -331,23 +244,27 @@ func TestWatchWithoutFile(t *testing.T) {
select { select {
case _, ok := <-ch: case _, ok := <-ch:
assert.False(t, ok, "Channel should be closed when no file to watch") if ok {
t.Errorf("ok should be false: %s", "Channel should be closed when no file to watch")
}
case <-time.After(10 * time.Millisecond): case <-time.After(10 * time.Millisecond):
t.Error("Channel should be closed immediately") t.Error("Channel should be closed immediately")
} }
assert.False(t, cfg.IsWatching()) if cfg.IsWatching() {
t.Errorf("cfg.IsWatching() should be false")
}
} }
// TestConcurrentWatchOperations tests thread safety of watch operations // TestConcurrentWatchOperations tests thread safety of watch operations
func TestConcurrentWatchOperations(t *testing.T) { func TestConcurrentWatchOperations(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml") configPath := filepath.Join(tmpDir, "test.toml")
require.NoError(t, os.WriteFile(configPath, []byte(`value = 1`), 0644)) writeTestFile(t, configPath, []byte(`value = 1`), 0644)
cfg := New() cfg := New()
cfg.Register("value", 0) cfg.Register("value", 0)
require.NoError(t, cfg.LoadFile(configPath)) mustNoError(t, cfg.LoadFile(configPath))
opts := WatchOptions{ opts := WatchOptions{
PollInterval: testDebounce, PollInterval: testDebounce,
@@ -400,15 +317,7 @@ func TestConcurrentWatchOperations(t *testing.T) {
go func(id int) { go func(id int) {
defer wg.Done() defer wg.Done()
isWatching := false if !cfg.IsWatching() {
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) errors <- fmt.Errorf("checker %d: IsWatching returned false", id)
} }
}(i) }(i)
@@ -422,46 +331,24 @@ func TestConcurrentWatchOperations(t *testing.T) {
for err := range errors { for err := range errors {
errs = append(errs, err) errs = append(errs, err)
} }
assert.Empty(t, errs, "Concurrent operations should not produce errors") if got := len(errs); got != 0 {
} t.Errorf("length = %d, want %d: %s", got, 0, "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 // TestStopAutoUpdate tests clean shutdown of watcher
func TestStopAutoUpdate(t *testing.T) { func TestStopAutoUpdate(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml") configPath := filepath.Join(tmpDir, "test.toml")
require.NoError(t, os.WriteFile(configPath, []byte(`test = "value"`), 0644)) writeTestFile(t, configPath, []byte(`test = "value"`), 0644)
cfg := New() cfg := New()
cfg.Register("test", "value") cfg.Register("test", "value")
require.NoError(t, cfg.LoadFile(configPath)) mustNoError(t, cfg.LoadFile(configPath))
// Start watching // Start watching
cfg.AutoUpdate() cfg.AutoUpdate()
waitForWatchingState(t, cfg, true, "Watcher should be active after first start") checkWatchingState(t, cfg, true, "Watcher should be active after first start")
ch := cfg.Watch() ch := cfg.Watch()
@@ -469,20 +356,22 @@ func TestStopAutoUpdate(t *testing.T) {
cfg.StopAutoUpdate() cfg.StopAutoUpdate()
// Verify stopped // Verify stopped
waitForWatchingState(t, cfg, false, "Watcher should be inactive after stop") checkWatchingState(t, cfg, false, "Watcher should be inactive after stop")
assert.Equal(t, 0, cfg.WatcherCount()) checkEqual(t, cfg.WatcherCount(), 0)
// Channel should eventually close // Channel should eventually close
select { select {
case _, ok := <-ch: case _, ok := <-ch:
assert.False(t, ok, "Channel should be closed after stop") if ok {
t.Errorf("ok should be false: %s", "Channel should be closed after stop")
}
case <-time.After(ShutdownTimeout): case <-time.After(ShutdownTimeout):
// OK, channel might not close immediately t.Fatal("watch channel did not close after stop")
} }
// Starting again should work // Starting again should work
cfg.AutoUpdate() cfg.AutoUpdate()
waitForWatchingState(t, cfg, true, "Watcher should be active after restart") checkWatchingState(t, cfg, true, "Watcher should be active after restart")
cfg.StopAutoUpdate() cfg.StopAutoUpdate()
} }
@@ -496,13 +385,13 @@ func BenchmarkWatchOverhead(b *testing.B) {
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
configContent += fmt.Sprintf("value%d = %d\n", i, i) configContent += fmt.Sprintf("value%d = %d\n", i, i)
} }
require.NoError(b, os.WriteFile(configPath, []byte(configContent), 0644)) writeTestFile(b, configPath, []byte(configContent), 0644)
cfg := New() cfg := New()
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
cfg.Register(fmt.Sprintf("value%d", i), 0) cfg.Register(fmt.Sprintf("value%d", i), 0)
} }
require.NoError(b, cfg.LoadFile(configPath)) mustNoError(b, cfg.LoadFile(configPath))
// Enable watching // Enable watching
opts := WatchOptions{ opts := WatchOptions{
+3 -3
View File
@@ -122,7 +122,7 @@ func TestWatcherDebouncesErrorsAndRecovers(t *testing.T) {
t.Fatal(event) t.Fatal(event)
} }
// A stable invalid edit is reported once and leaves the old value intact. // A stable invalid edit is reported once and leaves the old value intact.
_ = os.WriteFile(path, []byte("value=256\n"), 0600) writeTestFile(t, path, []byte("value=256\n"), 0600)
w.checkAndReload(c) w.checkAndReload(c)
w.observedAt = time.Now().Add(-2 * time.Hour) w.observedAt = time.Now().Add(-2 * time.Hour)
w.checkAndReload(c) w.checkAndReload(c)
@@ -208,7 +208,7 @@ func TestWatcherSameMetadataReplacementAndRecreation(t *testing.T) {
w.checkAndReload(c) w.checkAndReload(c)
info, _ := os.Stat(path) info, _ := os.Stat(path)
replacement := path + ".new" replacement := path + ".new"
_ = os.WriteFile(replacement, []byte("value=2\n"), 0600) writeTestFile(t, replacement, []byte("value=2\n"), 0600)
_ = os.Chtimes(replacement, info.ModTime(), info.ModTime()) _ = os.Chtimes(replacement, info.ModTime(), info.ModTime())
if err := os.Rename(replacement, path); err != nil { if err := os.Rename(replacement, path); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -233,7 +233,7 @@ func TestWatcherSameMetadataReplacementAndRecreation(t *testing.T) {
t.Fatalf("repeated deletion: %s", event) t.Fatalf("repeated deletion: %s", event)
default: default:
} }
_ = os.WriteFile(path, []byte("value=3\n"), 0600) writeTestFile(t, path, []byte("value=3\n"), 0600)
w.checkAndReload(c) w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(3) { if value, _ := c.Get("value"); value != int64(3) {
t.Fatal("recreation missed") t.Fatal("recreation missed")