Files
config/config_test.go

454 lines
13 KiB
Go

// FILE: lixenwraith/config/config_test.go
package config
import (
"fmt"
"net"
"net/url"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestConfigCreation tests various config creation patterns
func TestConfigCreation(t *testing.T) {
t.Run("NewWithDefaultOptions", func(t *testing.T) {
cfg := New()
require.NotNil(t, cfg)
assert.NotNil(t, cfg.items)
assert.Equal(t, []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault}, cfg.options.Sources)
})
t.Run("NewWithCustomOptions", func(t *testing.T) {
opts := LoadOptions{
Sources: []Source{SourceEnv, SourceFile, SourceDefault},
EnvPrefix: "MYAPP_",
LoadMode: LoadModeReplace,
}
cfg := NewWithOptions(opts)
require.NotNil(t, cfg)
assert.Equal(t, opts.Sources, cfg.options.Sources)
assert.Equal(t, "MYAPP_", cfg.options.EnvPrefix)
})
}
// TestPathRegistration tests path registration edge cases
func TestPathRegistration(t *testing.T) {
tests := []struct {
name string
path string
defaultVal any
expectError bool
errorMsg string
}{
{"ValidSimplePath", "port", 8080, false, ""},
{"ValidNestedPath", "server.host.name", "localhost", false, ""},
{"EmptyPath", "", nil, true, "registration path cannot be empty"},
{"InvalidCharacter", "server.port!", 8080, true, "invalid path segment"},
{"InvalidDot", "server..port", 8080, true, "invalid path segment"},
{"LeadingDot", ".server.port", 8080, true, "invalid path segment"},
{"TrailingDot", "server.port.", 8080, true, "invalid path segment"},
{"ValidUnderscore", "server_config.max_connections", 100, false, ""},
{"ValidDash", "feature-flags.enable-debug", false, false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := New()
err := cfg.Register(tt.path, tt.defaultVal)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
assert.NoError(t, err)
val, exists := cfg.Get(tt.path)
assert.True(t, exists)
assert.Equal(t, tt.defaultVal, val)
}
})
}
}
// TestComplexStructRegistration tests struct registration with various tag types
func TestComplexStructRegistration(t *testing.T) {
type DatabaseConfig struct {
Host string `toml:"host" json:"db_host" yaml:"dbHost"`
Port int `toml:"port" json:"db_port" yaml:"dbPort"`
MaxConns int `toml:"max_connections"`
Timeout time.Duration `toml:"timeout"`
EnableDebug bool `toml:"debug" env:"DB_DEBUG"`
}
type ServerConfig struct {
Name string `toml:"name" json:"name"`
Database DatabaseConfig `toml:"db" json:"db"`
Tags []string `toml:"tags" json:"tags"`
Metadata map[string]any `toml:"metadata" json:"metadata"`
}
defaultConfig := &ServerConfig{
Name: "test-server",
Database: DatabaseConfig{
Host: "localhost",
Port: 5432,
MaxConns: 100,
Timeout: 30 * time.Second,
EnableDebug: false,
},
Tags: []string{"test", "development"},
Metadata: map[string]any{"version": "1.0"},
}
t.Run("TOMLTags", func(t *testing.T) {
cfg := New()
err := cfg.RegisterStruct("", defaultConfig)
require.NoError(t, err)
// Verify paths registered with TOML tags
paths := cfg.GetRegisteredPaths("")
assert.True(t, paths["name"])
assert.True(t, paths["db.host"])
assert.True(t, paths["db.port"])
assert.True(t, paths["db.max_connections"])
assert.True(t, paths["db.timeout"])
assert.True(t, paths["db.debug"])
assert.True(t, paths["tags"])
assert.True(t, paths["metadata"])
// Verify default values
val, _ := cfg.Get("db.timeout")
assert.Equal(t, 30*time.Second, val)
})
t.Run("JSONTags", func(t *testing.T) {
cfg := New()
err := cfg.RegisterStructWithTags("", defaultConfig, "json")
require.NoError(t, err)
// JSON tags should create different paths
paths := cfg.GetRegisteredPaths("")
assert.True(t, paths["db.db_host"])
assert.True(t, paths["db.db_port"])
})
t.Run("UnsupportedTag", func(t *testing.T) {
cfg := New()
err := cfg.RegisterStructWithTags("", defaultConfig, "xml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported tag name")
})
t.Run("WithPrefix", func(t *testing.T) {
cfg := New()
err := cfg.RegisterStruct("server", defaultConfig)
require.NoError(t, err)
paths := cfg.GetRegisteredPaths("server.")
assert.True(t, paths["server.name"])
assert.True(t, paths["server.db.host"])
})
}
// TestSourcePrecedence tests configuration source precedence
func TestSourcePrecedence(t *testing.T) {
cfg := New()
cfg.Register("test.value", "default")
// Set values in different sources
cfg.SetSource("test.value", SourceFile, "from-file")
cfg.SetSource("test.value", SourceEnv, "from-env")
cfg.SetSource("test.value", SourceCLI, "from-cli")
// Default precedence: CLI > Env > File > Default
val, _ := cfg.Get("test.value")
assert.Equal(t, "from-cli", val)
// Remove CLI value
cfg.ResetSource(SourceCLI)
val, _ = cfg.Get("test.value")
assert.Equal(t, "from-env", val)
// Change precedence
err := cfg.SetLoadOptions(LoadOptions{
Sources: []Source{SourceFile, SourceEnv, SourceCLI, SourceDefault},
})
require.NoError(t, err)
val, _ = cfg.Get("test.value")
assert.Equal(t, "from-file", val)
// Test GetSources
sources := cfg.GetSources("test.value")
assert.Equal(t, "from-file", sources[SourceFile])
assert.Equal(t, "from-env", sources[SourceEnv])
}
// TestTypeConversion tests automatic type conversion through mapstructure
func TestTypeConversion(t *testing.T) {
type TestConfig struct {
IntValue int64 `toml:"int"`
FloatValue float64 `toml:"float"`
BoolValue bool `toml:"bool"`
Duration time.Duration `toml:"duration"`
Time time.Time `toml:"time"`
IP net.IP `toml:"ip"`
IPNet *net.IPNet `toml:"ipnet"`
URL *url.URL `toml:"url"`
StringSlice []string `toml:"strings"`
IntSlice []int `toml:"ints"`
}
cfg := New()
defaults := &TestConfig{
IntValue: 42,
FloatValue: 3.14,
BoolValue: true,
Duration: 5 * time.Second,
Time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
IP: net.ParseIP("127.0.0.1"),
StringSlice: []string{"a", "b"},
IntSlice: []int{1, 2, 3},
}
err := cfg.RegisterStruct("", defaults)
require.NoError(t, err)
// Test string conversions from environment
cfg.SetSource("int", SourceEnv, "100")
cfg.SetSource("float", SourceEnv, "2.718")
cfg.SetSource("bool", SourceEnv, "false")
cfg.SetSource("duration", SourceEnv, "1m30s")
cfg.SetSource("time", SourceEnv, "2024-12-25T10:00:00Z")
cfg.SetSource("ip", SourceEnv, "192.168.1.1")
cfg.SetSource("ipnet", SourceEnv, "10.0.0.0/8")
cfg.SetSource("url", SourceEnv, "https://example.com:8080/path")
cfg.SetSource("strings", SourceEnv, "x,y,z")
// cfg.SetSource("ints", SourceEnv, "7,8,9") // failure due to mapstructure limitation
// Scan into struct
var result TestConfig
err = cfg.Scan("", &result)
require.NoError(t, err)
assert.Equal(t, int64(100), result.IntValue)
assert.Equal(t, 2.718, result.FloatValue)
assert.Equal(t, false, result.BoolValue)
assert.Equal(t, 90*time.Second, result.Duration)
assert.Equal(t, "2024-12-25T10:00:00Z", result.Time.Format(time.RFC3339))
assert.Equal(t, "192.168.1.1", result.IP.String())
assert.Equal(t, "10.0.0.0/8", result.IPNet.String())
assert.Equal(t, "https://example.com:8080/path", result.URL.String())
assert.Equal(t, []string{"x", "y", "z"}, result.StringSlice)
// Note: String to int slice conversion through env requires handling in the test
}
// TestConcurrentAccess tests thread safety
func TestConcurrentAccess(t *testing.T) {
cfg := New()
// Register paths
for i := 0; i < 100; i++ {
cfg.Register(fmt.Sprintf("path%d", i), i)
}
var wg sync.WaitGroup
errors := make(chan error, 1000)
// Concurrent readers
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
path := fmt.Sprintf("path%d", j)
if _, exists := cfg.Get(path); !exists {
errors <- fmt.Errorf("reader %d: path %s not found", id, path)
}
}
}(i)
}
// Concurrent writers
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
path := fmt.Sprintf("path%d", j)
value := fmt.Sprintf("writer%d-value%d", id, j)
if err := cfg.Set(path, value); err != nil {
errors <- fmt.Errorf("writer %d: %v", id, err)
}
}
}(i)
}
// Concurrent source changes
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sources := []Source{SourceFile, SourceEnv, SourceCLI}
for j := 0; j < 50; j++ {
path := fmt.Sprintf("path%d", j)
source := sources[j%len(sources)]
value := fmt.Sprintf("source%d-value%d", id, j)
if err := cfg.SetSource(path, source, value); err != nil {
errors <- fmt.Errorf("source writer %d: %v", id, err)
}
}
}(i)
}
wg.Wait()
close(errors)
// Check for errors
var errs []error
for err := range errors {
errs = append(errs, err)
}
assert.Empty(t, errs, "Concurrent access should not produce errors")
}
// TestUnregister tests path unregistration
func TestUnregister(t *testing.T) {
cfg := New()
// Register nested paths
cfg.Register("server.host", "localhost")
cfg.Register("server.port", 8080)
cfg.Register("server.tls.enabled", true)
cfg.Register("server.tls.cert", "/path/to/cert")
cfg.Register("database.host", "dbhost")
t.Run("UnregisterSinglePath", func(t *testing.T) {
err := cfg.Unregister("server.port")
assert.NoError(t, err)
_, exists := cfg.Get("server.port")
assert.False(t, exists)
// Other paths should remain
_, exists = cfg.Get("server.host")
assert.True(t, exists)
})
t.Run("UnregisterParentPath", func(t *testing.T) {
err := cfg.Unregister("server.tls")
assert.NoError(t, err)
// All child paths should be removed
_, exists := cfg.Get("server.tls.enabled")
assert.False(t, exists)
_, exists = cfg.Get("server.tls.cert")
assert.False(t, exists)
// Sibling paths should remain
_, exists = cfg.Get("server.host")
assert.True(t, exists)
})
t.Run("UnregisterNonExistentPath", func(t *testing.T) {
err := cfg.Unregister("nonexistent.path")
assert.Error(t, err)
assert.Contains(t, err.Error(), "path not registered")
})
}
// TestResetFunctionality tests reset operations
func TestResetFunctionality(t *testing.T) {
cfg := New()
cfg.Register("test1", "default1")
cfg.Register("test2", "default2")
// Set values in different sources
cfg.SetSource("test1", SourceFile, "file1")
cfg.SetSource("test1", SourceEnv, "env1")
cfg.SetSource("test2", SourceCLI, "cli2")
t.Run("ResetSingleSource", func(t *testing.T) {
cfg.ResetSource(SourceEnv)
// Env value should be gone
_, exists := cfg.GetSource("test1", SourceEnv)
assert.False(t, exists)
// Other sources should remain
val, exists := cfg.GetSource("test1", SourceFile)
assert.True(t, exists)
assert.Equal(t, "file1", val)
})
t.Run("ResetAll", func(t *testing.T) {
cfg.Reset()
// All values should revert to defaults
val1, _ := cfg.Get("test1")
val2, _ := cfg.Get("test2")
assert.Equal(t, "default1", val1)
assert.Equal(t, "default2", val2)
// Source values should be cleared
sources := cfg.GetSources("test1")
assert.Empty(t, sources)
})
}
// TestValueSizeLimit tests the MaxValueSize constraint
func TestValueSizeLimit(t *testing.T) {
cfg := New()
cfg.Register("test", "")
// Create a value larger than MaxValueSize
largeValue := make([]byte, MaxValueSize+1)
for i := range largeValue {
largeValue[i] = 'x'
}
err := cfg.Set("test", string(largeValue))
assert.Error(t, err)
assert.Equal(t, ErrValueSize, err)
}
// TestGetRegisteredPaths tests path listing functionality
func TestGetRegisteredPaths(t *testing.T) {
cfg := New()
paths := []string{
"server.host",
"server.port",
"server.tls.enabled",
"database.host",
"database.port",
"cache.ttl",
}
for _, path := range paths {
cfg.Register(path, "")
}
t.Run("GetAllPaths", func(t *testing.T) {
all := cfg.GetRegisteredPaths("")
assert.Len(t, all, len(paths))
for _, path := range paths {
assert.True(t, all[path])
}
})
t.Run("GetPathsWithPrefix", func(t *testing.T) {
serverPaths := cfg.GetRegisteredPaths("server.")
assert.Len(t, serverPaths, 3)
assert.True(t, serverPaths["server.host"])
assert.True(t, serverPaths["server.port"])
assert.True(t, serverPaths["server.tls.enabled"])
})
t.Run("GetPathsWithDefaults", func(t *testing.T) {
defaults := cfg.GetRegisteredPathsWithDefaults("database.")
assert.Len(t, defaults, 2)
assert.Contains(t, defaults, "database.host")
assert.Contains(t, defaults, "database.port")
})
}