Files
config/builder_test.go
T
lixen 2a47be454d
Test / test (push) Canceled after 0s
v0.2.1 remove testify dep
2026-09-29 01:11:44 -04:00

378 lines
8.2 KiB
Go

// FILE: lixenwraith/config/builder_test.go
package config
import (
"fmt"
"path/filepath"
"testing"
)
// TestBuilder tests the builder pattern
func TestBuilder(t *testing.T) {
t.Run("BasicBuilder", func(t *testing.T) {
type Config struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
defaults := &Config{
Host: "localhost",
Port: 8080,
}
cfg, err := NewBuilder().
WithDefaults(defaults).
WithEnvPrefix("TEST_").
Build()
mustNoError(t, err)
if got := cfg; got == nil {
t.Errorf("got %v, want non-nil", got)
}
val, exists := cfg.Get("host")
if !exists {
t.Errorf("exists should be true")
}
checkEqual(t, val, "localhost")
})
t.Run("BuilderWithAllOptions", func(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "test.toml")
writeTestFile(t, configFile, []byte(`host = "filehost"`), 0644)
type Config struct {
Host string `toml:"hostname"`
Port int `toml:"port"`
}
defaults := &Config{
Host: "defaulthost",
Port: 3000,
}
// Custom env transform
envTransform := func(path string) string {
return "CUSTOM_" + path
}
cfg, err := NewBuilder().
WithDefaults(defaults).
WithTagName("toml").
WithPrefix("server").
WithEnvPrefix("APP_").
WithFile(configFile).
WithArgs([]string{"--server.hostname=clihost"}).
WithSources(SourceCLI, SourceFile, SourceEnv, SourceDefault).
WithEnvTransform(envTransform).
WithEnvWhitelist("server.hostname").
Build()
mustNoError(t, err)
// CLI should take precedence
val, _ := cfg.Get("server.hostname")
checkEqual(t, val, "clihost")
})
t.Run("BuilderWithTarget", func(t *testing.T) {
type Config struct {
Database struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"db"`
Cache struct {
TTL int `toml:"ttl"`
} `toml:"cache"`
}
target := &Config{}
target.Database.Host = "localhost"
target.Database.Port = 5432
target.Cache.TTL = 300
cfg, err := NewBuilder().
WithTarget(target).
Build()
mustNoError(t, err)
// Verify paths were registered
paths := cfg.GetRegisteredPaths()
if !paths["db.host"] {
t.Errorf("paths[\"db.host\"] should be true")
}
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
result, err := cfg.AsStruct()
mustNoError(t, err)
checkEqual(t, result, target)
})
t.Run("BuilderWithValidator", func(t *testing.T) {
type UserConfig struct {
Port int `toml:"port"`
}
validatorCalled := false
validator := func(cfg *Config) error {
validatorCalled = true
val, exists := cfg.Get("port")
if !exists {
return fmt.Errorf("port not found")
}
// Convert to int - could be int64 from storage
var port int
switch v := val.(type) {
case int:
port = v
case int64:
port = int(v)
default:
return fmt.Errorf("port has unexpected type %T", v)
}
if port < 1024 {
return fmt.Errorf("port %d is below 1024", port)
}
return nil
}
// Valid case
cfg, err := NewBuilder().
WithDefaults(&UserConfig{Port: 8080}).
WithValidator(validator).
Build()
mustNoError(t, err)
if got := cfg; got == nil {
t.Errorf("got %v, want non-nil", got)
}
if !validatorCalled {
t.Errorf("validatorCalled should be true")
}
// Invalid case
validatorCalled = false
cfg2, err := NewBuilder().
WithDefaults(&UserConfig{Port: 80}).
WithValidator(validator).
Build()
if got := cfg2; got != nil {
t.Errorf("got %v, want nil", got)
}
checkErrorContains(t, err, "configuration validation failed")
if !validatorCalled {
t.Errorf("validatorCalled should be true")
}
})
t.Run("BuilderErrorAccumulation", func(t *testing.T) {
// Unsupported tag name
_, err := NewBuilder().
WithTagName("xml").
WithDefaults(struct{}{}).
Build()
checkErrorContains(t, err, "unsupported tag name")
// Invalid target
_, err = NewBuilder().
WithTarget("not-a-pointer").
Build()
checkErrorContains(t, err, "requires non-nil pointer to struct")
})
t.Run("MustBuildPanic", func(t *testing.T) {
// Should not panic with valid config
{
cfg := NewBuilder().
WithDefaults(struct{ Port int }{Port: 8080}).
MustBuild()
if got := cfg; got == nil {
t.Errorf("got %v, want non-nil", got)
}
}
// Should panic with error
mustPanic(t, func() {
NewBuilder().
WithTagName("invalid").
MustBuild()
})
})
}
// TestFileDiscovery tests automatic config file discovery
func TestFileDiscovery(t *testing.T) {
t.Run("DiscoveryWithCLIFlag", func(t *testing.T) {
tmpDir := t.TempDir()
// Use .toml extension for TOML content
configFile := filepath.Join(tmpDir, "custom.toml")
writeTestFile(t, configFile, []byte(`test = "value"`), 0644)
opts := DefaultDiscoveryOptions("myapp")
cfg, err := NewBuilder().
WithDefaults(struct {
Test string `toml:"test"`
}{Test: "default"}).
WithArgs([]string{"--config", configFile}).
WithFileDiscovery(opts).
Build()
mustNoError(t, err)
// Verify file was loaded
val, _ := cfg.Get("test")
checkEqual(t, val, "value")
})
t.Run("DiscoveryWithEnvVar", func(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "env.toml")
writeTestFile(t, configFile, []byte(`test = "envvalue"`), 0644)
t.Setenv("MYAPP_CONFIG", configFile)
opts := DefaultDiscoveryOptions("myapp")
cfg, err := NewBuilder().
WithDefaults(struct {
Test string `toml:"test"`
}{Test: "default"}).
WithFileDiscovery(opts).
Build()
mustNoError(t, err)
val, _ := cfg.Get("test")
checkEqual(t, val, "envvalue")
})
t.Run("DiscoveryInCurrentDir", func(t *testing.T) {
// Discovery runs in an isolated directory and never writes into the checkout.
t.Chdir(t.TempDir())
configFile := "myapp.toml"
writeTestFile(t, configFile, []byte(`test = "cwdvalue"`), 0644)
opts := FileDiscoveryOptions{
Name: "myapp",
Extensions: []string{".toml"},
UseCurrentDir: true,
}
cfg, err := NewBuilder().
WithDefaults(struct {
Test string `toml:"test"`
}{Test: "default"}).
WithFileDiscovery(opts).
Build()
mustNoError(t, err)
val, _ := cfg.Get("test")
checkEqual(t, val, "cwdvalue")
})
t.Run("DiscoveryPrecedence", func(t *testing.T) {
tmpDir := t.TempDir()
// Create multiple config files
cliFile := filepath.Join(tmpDir, "cli.toml")
envFile := filepath.Join(tmpDir, "env.toml")
writeTestFile(t, cliFile, []byte(`test = "clifile"`), 0644)
writeTestFile(t, envFile, []byte(`test = "envfile"`), 0644)
// CLI should take precedence over env
t.Setenv("MYAPP_CONFIG", envFile)
opts := DefaultDiscoveryOptions("myapp")
cfg, err := NewBuilder().
WithDefaults(struct {
Test string `toml:"test"`
}{Test: "default"}).
WithArgs([]string{"--config", cliFile}).
WithFileDiscovery(opts).
Build()
mustNoError(t, err)
val, _ := cfg.Get("test")
checkEqual(t, val, "clifile")
})
}
func TestBuilderWithTypedValidator(t *testing.T) {
type Cfg struct {
Port int `toml:"port"`
}
// Case 1: Valid configuration
t.Run("ValidTyped", func(t *testing.T) {
target := &Cfg{Port: 8080}
validator := func(c *Cfg) error {
if c.Port < 1024 {
return fmt.Errorf("port too low")
}
return nil
}
_, err := NewBuilder().
WithTarget(target).
WithTypedValidator(validator).
Build()
mustNoError(t, err)
})
// Case 2: Invalid configuration
t.Run("InvalidTyped", func(t *testing.T) {
target := &Cfg{Port: 80}
validator := func(c *Cfg) error {
if c.Port < 1024 {
return fmt.Errorf("port too low")
}
return nil
}
_, err := NewBuilder().
WithTarget(target).
WithTypedValidator(validator).
Build()
if err == nil {
t.Fatalf("expected an error")
}
checkErrorContains(t, err, "typed configuration validation failed: port too low")
})
// Case 3: Mismatched validator signature
t.Run("MismatchedSignature", func(t *testing.T) {
target := &Cfg{}
validator := func(c *struct{ Name string }) error { // Different type
return nil
}
_, err := NewBuilder().
WithTarget(target).
WithTypedValidator(validator).
Build()
if err == nil {
t.Fatalf("expected an error")
}
checkErrorContains(t, err, "typed validator signature")
})
}