270 lines
7.4 KiB
Go
270 lines
7.4 KiB
Go
// FILE: lixenwraith/config/decode_test.go
|
|
package config
|
|
|
|
import (
|
|
"net"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestScanWithComplexTypes tests scanning with various complex types
|
|
func TestScanWithComplexTypes(t *testing.T) {
|
|
type NetworkConfig struct {
|
|
IP net.IP `toml:"ip"`
|
|
IPNet *net.IPNet `toml:"subnet"`
|
|
URL *url.URL `toml:"endpoint"`
|
|
Timeout time.Duration `toml:"timeout"`
|
|
Retry struct {
|
|
Count int `toml:"count"`
|
|
Interval time.Duration `toml:"interval"`
|
|
} `toml:"retry"`
|
|
}
|
|
|
|
type AppConfig struct {
|
|
Network NetworkConfig `toml:"network"`
|
|
Tags []string `toml:"tags"`
|
|
Ports []int `toml:"ports"`
|
|
Labels map[string]string `toml:"labels"`
|
|
}
|
|
|
|
cfg := New()
|
|
|
|
// Register with defaults
|
|
defaults := &AppConfig{
|
|
Network: NetworkConfig{
|
|
IP: net.ParseIP("127.0.0.1"),
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
Tags: []string{"default"},
|
|
Ports: []int{8080},
|
|
Labels: map[string]string{
|
|
"env": "dev",
|
|
},
|
|
}
|
|
|
|
err := cfg.RegisterStruct("", defaults)
|
|
mustNoError(t, err)
|
|
|
|
// Set values from different sources
|
|
cfg.SetSource(SourceEnv, "network.ip", "192.168.1.100")
|
|
cfg.SetSource(SourceEnv, "network.subnet", "192.168.1.0/24")
|
|
cfg.SetSource(SourceEnv, "network.endpoint", "https://api.example.com:8443/v1")
|
|
cfg.SetSource(SourceFile, "network.timeout", "2m30s")
|
|
cfg.SetSource(SourceFile, "network.retry.count", int64(5))
|
|
cfg.SetSource(SourceFile, "network.retry.interval", "10s")
|
|
cfg.SetSource(SourceCLI, "tags", "prod,staging,test")
|
|
cfg.SetSource(SourceFile, "ports", []any{int64(80), int64(443), int64(8080)})
|
|
cfg.SetSource(SourceFile, "labels", map[string]any{
|
|
"env": "production",
|
|
"version": "1.2.3",
|
|
})
|
|
|
|
// Scan into struct
|
|
var result AppConfig
|
|
err = cfg.Scan(&result)
|
|
mustNoError(t, err)
|
|
|
|
// Verify conversions
|
|
checkEqual(t, result.Network.IP.String(), "192.168.1.100")
|
|
checkEqual(t, result.Network.IPNet.String(), "192.168.1.0/24")
|
|
checkEqual(t, result.Network.URL.String(), "https://api.example.com:8443/v1")
|
|
checkEqual(t, result.Network.Timeout, 150*time.Second)
|
|
checkEqual(t, result.Network.Retry.Count, 5)
|
|
checkEqual(t, result.Network.Retry.Interval, 10*time.Second)
|
|
checkEqual(t, result.Tags, []string{"prod", "staging", "test"})
|
|
checkEqual(t, result.Ports, []int{80, 443, 8080})
|
|
checkEqual(t, result.Labels["env"], "production")
|
|
checkEqual(t, result.Labels["version"], "1.2.3")
|
|
}
|
|
|
|
// TestScanWithBasePath tests scanning from nested paths
|
|
func TestScanWithBasePath(t *testing.T) {
|
|
type ServerConfig struct {
|
|
Host string `toml:"host"`
|
|
Port int `toml:"port"`
|
|
Enabled bool `toml:"enabled"`
|
|
}
|
|
|
|
cfg := New()
|
|
cfg.Register("app.server.host", "localhost")
|
|
cfg.Register("app.server.port", 8080)
|
|
cfg.Register("app.server.enabled", true)
|
|
cfg.Register("app.database.host", "dbhost")
|
|
|
|
cfg.Set("app.server.host", "appserver")
|
|
cfg.Set("app.server.port", 9000)
|
|
|
|
// Scan only the server section
|
|
var server ServerConfig
|
|
err := cfg.Scan(&server, "app.server")
|
|
mustNoError(t, err)
|
|
|
|
checkEqual(t, server.Host, "appserver")
|
|
checkEqual(t, server.Port, 9000)
|
|
checkEqual(t, server.Enabled, true)
|
|
|
|
// Test non-existent base path
|
|
var empty ServerConfig
|
|
err = cfg.Scan(&empty, "app.nonexistent")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
} // Should not error, just empty
|
|
checkEqual(t, empty.Host, "")
|
|
checkEqual(t, empty.Port, 0)
|
|
}
|
|
|
|
// TestScanFromSource tests scanning from specific sources
|
|
func TestScanFromSource(t *testing.T) {
|
|
type Config struct {
|
|
Value string `toml:"value"`
|
|
}
|
|
|
|
cfg := New()
|
|
cfg.Register("value", "default")
|
|
|
|
cfg.SetSource(SourceFile, "value", "fromfile")
|
|
cfg.SetSource(SourceEnv, "value", "fromenv")
|
|
cfg.SetSource(SourceCLI, "value", "fromcli")
|
|
|
|
tests := []struct {
|
|
source Source
|
|
expected string
|
|
}{
|
|
{SourceFile, "fromfile"},
|
|
{SourceEnv, "fromenv"},
|
|
{SourceCLI, "fromcli"},
|
|
{SourceDefault, "default"}, // Registered defaults are a readable source
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.source), func(t *testing.T) {
|
|
var result Config
|
|
err := cfg.ScanSource(tt.source, &result)
|
|
mustNoError(t, err)
|
|
checkEqual(t, result.Value, tt.expected)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInvalidScanTargets tests error cases for scanning
|
|
func TestInvalidScanTargets(t *testing.T) {
|
|
cfg := New()
|
|
cfg.Register("test", "value")
|
|
|
|
tests := []struct {
|
|
name string
|
|
target any
|
|
expectErr string
|
|
}{
|
|
{"NilPointer", nil, "must be non-nil pointer"},
|
|
{"NonPointer", "not-a-pointer", "must be non-nil pointer"},
|
|
{"NilStructPointer", (*struct{})(nil), "must be non-nil pointer"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := cfg.Scan(tt.target)
|
|
checkErrorContains(t, err, tt.expectErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCustomTypeConversion tests edge cases in type conversion
|
|
func TestCustomTypeConversion(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
initial any
|
|
input string
|
|
fragment string
|
|
}{
|
|
{"IP", net.IP{}, "not-an-ip", "invalid IP address"},
|
|
{"CIDR", (*net.IPNet)(nil), "invalid-cidr", "invalid CIDR"},
|
|
{"URL", (*url.URL)(nil), "://invalid-url", "invalid URL"},
|
|
{"LongIP", net.IP{}, strings.Repeat("x", 50), "invalid IP length"},
|
|
{"LongURL", (*url.URL)(nil), strings.Repeat("x", MaxURLLength+1), "URL too long"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := New()
|
|
mustNoError(t, cfg.Register("value", tc.initial))
|
|
err := cfg.Set("value", tc.input)
|
|
if err == nil {
|
|
t.Fatalf("expected an error")
|
|
}
|
|
checkErrorContains(t, err, tc.fragment)
|
|
got, _ := cfg.Get("value")
|
|
checkEqual(t, got, tc.initial)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestZeroFields(t *testing.T) {
|
|
type Config struct {
|
|
KeepValue string `toml:"keep"`
|
|
ResetValue string `toml:"reset"`
|
|
NestedValue struct {
|
|
Field string `toml:"field"`
|
|
} `toml:"nested"`
|
|
}
|
|
|
|
cfg := New()
|
|
|
|
// Register only some fields
|
|
cfg.Register("keep", "keepdefault")
|
|
cfg.Register("reset", "resetdefault")
|
|
// Don't register nested.field
|
|
|
|
cfg.Set("keep", "newvalue")
|
|
// Don't set reset, so it uses default
|
|
|
|
// Start with non-zero struct
|
|
result := Config{
|
|
KeepValue: "initial",
|
|
ResetValue: "initial",
|
|
NestedValue: struct {
|
|
Field string `toml:"field"`
|
|
}{Field: "initial"},
|
|
}
|
|
|
|
err := cfg.Scan(&result)
|
|
mustNoError(t, err)
|
|
|
|
// ZeroFields should reset all fields before decoding
|
|
checkEqual(t, result.KeepValue, "newvalue")
|
|
checkEqual(t, result.ResetValue, "resetdefault")
|
|
checkEqual(t, result.NestedValue.Field, "initial") // Unregistered, so Scan should not touch it
|
|
}
|
|
|
|
// TestWeaklyTypedInput tests weak type conversion
|
|
func TestWeaklyTypedInput(t *testing.T) {
|
|
type Config struct {
|
|
IntFromString int `toml:"int_from_string"`
|
|
FloatFromString float64 `toml:"float_from_string"`
|
|
BoolFromString bool `toml:"bool_from_string"`
|
|
StringFromInt string `toml:"string_from_int"`
|
|
StringFromBool string `toml:"string_from_bool"`
|
|
}
|
|
|
|
cfg := New()
|
|
defaults := &Config{}
|
|
cfg.RegisterStruct("", defaults)
|
|
|
|
// Set string values that should convert
|
|
cfg.Set("int_from_string", "42")
|
|
cfg.Set("float_from_string", "3.14159")
|
|
cfg.Set("bool_from_string", "true")
|
|
cfg.Set("string_from_int", 12345)
|
|
cfg.Set("string_from_bool", true)
|
|
|
|
var result Config
|
|
err := cfg.Scan(&result)
|
|
mustNoError(t, err)
|
|
|
|
checkEqual(t, result.IntFromString, 42)
|
|
checkEqual(t, result.FloatFromString, 3.14159)
|
|
checkEqual(t, result.BoolFromString, true)
|
|
checkEqual(t, result.StringFromInt, "12345")
|
|
checkEqual(t, result.StringFromBool, "true") // Canonical boolean text
|
|
}
|