Files
jety/jety_test.go
Tai Groot 9ff1fdc5ee fix!: SetEnvPrefix now re-reads env vars with prefix stripping
SetEnvPrefix was broken: it set the prefix string but never re-read
environment variables. This meant collapse() and SetDefault() couldn't
match prefixed env vars to their unprefixed config keys, so defaults
always won over env vars.

The fix makes SetEnvPrefix behave identically to WithEnvPrefix: it
re-reads os.Environ(), strips the prefix from matching keys, and
stores the stripped keys in envConfig. The envPrefix field is removed
entirely since keys are always pre-stripped.

BREAKING CHANGE: SetEnvPrefix now filters env vars to only those
matching the prefix (previously all env vars were accessible).
This matches the documented and expected behavior.
2026-03-01 20:42:30 +00:00

1350 lines
32 KiB
Go

package jety
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
)
// Test file contents
const (
tomlConfig = `
port = 8080
host = "localhost"
debug = true
timeout = "30s"
rate = 1.5
tags = ["api", "v1"]
counts = [1, 2, 3]
[database]
host = "db.example.com"
port = 5432
`
yamlConfig = `
port: 9090
host: "yaml-host"
debug: false
timeout: "1m"
rate: 2.5
tags:
- web
- v2
counts:
- 10
- 20
database:
host: "yaml-db.example.com"
port: 3306
`
jsonConfig = `{
"port": 7070,
"host": "json-host",
"debug": true,
"timeout": "15s",
"rate": 3.5,
"tags": ["json", "v3"],
"counts": [100, 200],
"database": {
"host": "json-db.example.com",
"port": 27017
}
}`
)
func TestNewConfigManager(t *testing.T) {
cm := NewConfigManager()
if cm == nil {
t.Fatal("NewConfigManager returned nil")
}
if cm.envConfig == nil {
t.Error("envConfig not initialized")
}
if cm.mapConfig == nil {
t.Error("mapConfig not initialized")
}
if cm.defaultConfig == nil {
t.Error("defaultConfig not initialized")
}
if cm.combinedConfig == nil {
t.Error("combinedConfig not initialized")
}
}
func TestSetAndGetString(t *testing.T) {
cm := NewConfigManager()
cm.Set("name", "test-value")
got := cm.GetString("name")
if got != "test-value" {
t.Errorf("GetString() = %q, want %q", got, "test-value")
}
// Case insensitive
got = cm.GetString("NAME")
if got != "test-value" {
t.Errorf("GetString(NAME) = %q, want %q", got, "test-value")
}
}
func TestSetAndGetInt(t *testing.T) {
cm := NewConfigManager()
tests := []struct {
name string
value any
want int
}{
{"int", 42, 42},
{"string", "123", 123},
{"float64", 99.9, 99},
{"float32", float32(50.5), 50},
{"invalid string", "not-a-number", 0},
{"nil", nil, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm.Set("key", tt.value)
got := cm.GetInt("key")
if got != tt.want {
t.Errorf("GetInt() = %d, want %d", got, tt.want)
}
})
}
}
func TestSetAndGetBool(t *testing.T) {
cm := NewConfigManager()
tests := []struct {
name string
value any
want bool
}{
{"true bool", true, true},
{"false bool", false, false},
{"string true", "true", true},
{"string TRUE", "TRUE", true},
{"string false", "false", false},
{"string other", "yes", false},
{"int zero", 0, false},
{"int nonzero", 1, true},
{"float32 zero", float32(0), false},
{"float32 nonzero", float32(1.5), true},
{"float64 zero", float64(0), false},
{"float64 nonzero", float64(1.5), true},
{"duration zero", time.Duration(0), false},
{"duration positive", time.Second, true},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm.Set("key", tt.value)
got := cm.GetBool("key")
if got != tt.want {
t.Errorf("GetBool() = %v, want %v", got, tt.want)
}
})
}
}
func TestSetAndGetDuration(t *testing.T) {
cm := NewConfigManager()
tests := []struct {
name string
value any
want time.Duration
}{
{"duration", 5 * time.Second, 5 * time.Second},
{"string", "10s", 10 * time.Second},
{"string minutes", "2m", 2 * time.Minute},
{"invalid string", "not-duration", 0},
{"int", 1000, time.Duration(1000)},
{"int64", int64(2000), time.Duration(2000)},
{"float64", float64(3000), time.Duration(3000)},
{"float32", float32(4000), time.Duration(4000)},
{"nil", nil, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm.Set("key", tt.value)
got := cm.GetDuration("key")
if got != tt.want {
t.Errorf("GetDuration() = %v, want %v", got, tt.want)
}
})
}
}
func TestSetAndGetStringSlice(t *testing.T) {
cm := NewConfigManager()
// Direct string slice
cm.Set("tags", []string{"a", "b", "c"})
got := cm.GetStringSlice("tags")
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
t.Errorf("GetStringSlice() = %v, want [a b c]", got)
}
// []any slice
cm.Set("mixed", []any{"x", 123, "z"})
got = cm.GetStringSlice("mixed")
if len(got) != 3 || got[0] != "x" || got[1] != "123" || got[2] != "z" {
t.Errorf("GetStringSlice() = %v, want [x 123 z]", got)
}
// Non-slice returns nil
cm.Set("notslice", "single")
got = cm.GetStringSlice("notslice")
if got != nil {
t.Errorf("GetStringSlice() = %v, want nil", got)
}
}
func TestSetAndGetIntSlice(t *testing.T) {
cm := NewConfigManager()
// Direct int slice
cm.Set("nums", []int{1, 2, 3})
got := cm.GetIntSlice("nums")
if len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 {
t.Errorf("GetIntSlice() = %v, want [1 2 3]", got)
}
// []any slice with mixed types
cm.Set("mixed", []any{10, "20", float64(30), float32(40)})
got = cm.GetIntSlice("mixed")
if len(got) != 4 || got[0] != 10 || got[1] != 20 || got[2] != 30 || got[3] != 40 {
t.Errorf("GetIntSlice() = %v, want [10 20 30 40]", got)
}
// Invalid entries skipped
cm.Set("invalid", []any{1, "not-a-number", nil, 2})
got = cm.GetIntSlice("invalid")
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Errorf("GetIntSlice() = %v, want [1 2]", got)
}
}
func TestSetAndGetStringMap(t *testing.T) {
cm := NewConfigManager()
m := map[string]any{"foo": "bar", "num": 123}
cm.Set("config", m)
got := cm.GetStringMap("config")
if got["foo"] != "bar" || got["num"] != 123 {
t.Errorf("GetStringMap() = %v, want %v", got, m)
}
// Non-map returns nil
cm.Set("notmap", "string")
got = cm.GetStringMap("notmap")
if got != nil {
t.Errorf("GetStringMap() = %v, want nil", got)
}
}
func TestGet(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", "value")
got := cm.Get("key")
if got != "value" {
t.Errorf("Get() = %v, want %q", got, "value")
}
// Non-existent key
got = cm.Get("nonexistent")
if got != nil {
t.Errorf("Get(nonexistent) = %v, want nil", got)
}
}
func TestSetDefault(t *testing.T) {
cm := NewConfigManager()
cm.SetDefault("port", 8080)
if got := cm.GetInt("port"); got != 8080 {
t.Errorf("GetInt(port) = %d, want 8080", got)
}
// Set overrides default
cm.Set("port", 9090)
if got := cm.GetInt("port"); got != 9090 {
t.Errorf("GetInt(port) after Set = %d, want 9090", got)
}
// New default doesn't override existing value
cm.SetDefault("port", 7070)
if got := cm.GetInt("port"); got != 9090 {
t.Errorf("GetInt(port) after second SetDefault = %d, want 9090", got)
}
}
func TestReadTOMLConfig(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "config.toml")
if err := os.WriteFile(configFile, []byte(tomlConfig), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("toml"); err != nil {
t.Fatal(err)
}
if err := cm.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
if got := cm.GetInt("port"); got != 8080 {
t.Errorf("GetInt(port) = %d, want 8080", got)
}
if got := cm.GetString("host"); got != "localhost" {
t.Errorf("GetString(host) = %q, want %q", got, "localhost")
}
if got := cm.GetBool("debug"); got != true {
t.Errorf("GetBool(debug) = %v, want true", got)
}
if got := cm.ConfigFileUsed(); got != configFile {
t.Errorf("ConfigFileUsed() = %q, want %q", got, configFile)
}
}
func TestReadYAMLConfig(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(configFile, []byte(yamlConfig), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
if err := cm.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
if got := cm.GetInt("port"); got != 9090 {
t.Errorf("GetInt(port) = %d, want 9090", got)
}
if got := cm.GetString("host"); got != "yaml-host" {
t.Errorf("GetString(host) = %q, want %q", got, "yaml-host")
}
if got := cm.GetBool("debug"); got != false {
t.Errorf("GetBool(debug) = %v, want false", got)
}
}
func TestReadJSONConfig(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "config.json")
if err := os.WriteFile(configFile, []byte(jsonConfig), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("json"); err != nil {
t.Fatal(err)
}
if err := cm.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
if got := cm.GetInt("port"); got != 7070 {
t.Errorf("GetInt(port) = %d, want 7070", got)
}
if got := cm.GetString("host"); got != "json-host" {
t.Errorf("GetString(host) = %q, want %q", got, "json-host")
}
if got := cm.GetBool("debug"); got != true {
t.Errorf("GetBool(debug) = %v, want true", got)
}
}
func TestReadConfigWithDirAndName(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "myconfig.yaml")
if err := os.WriteFile(configFile, []byte(yamlConfig), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigDir(dir)
cm.SetConfigName("myconfig")
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
if err := cm.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
if got := cm.GetInt("port"); got != 9090 {
t.Errorf("GetInt(port) = %d, want 9090", got)
}
if got := cm.ConfigFileUsed(); got != configFile {
t.Errorf("ConfigFileUsed() = %q, want %q", got, configFile)
}
}
func TestReadConfigNoFileSpecified(t *testing.T) {
cm := NewConfigManager()
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
err := cm.ReadInConfig()
if err == nil {
t.Error("ReadInConfig() expected error, got nil")
}
}
func TestReadConfigFileNotFound(t *testing.T) {
cm := NewConfigManager()
cm.SetConfigFile("/nonexistent/path/config.yaml")
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
err := cm.ReadInConfig()
if err != ErrConfigFileNotFound {
t.Errorf("ReadInConfig() error = %v, want ErrConfigFileNotFound", err)
}
}
func TestReadConfigFileEmpty(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "empty.yaml")
if err := os.WriteFile(configFile, []byte{}, 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
err := cm.ReadInConfig()
if err != ErrConfigFileEmpty {
t.Errorf("ReadInConfig() error = %v, want ErrConfigFileEmpty", err)
}
}
func TestWriteConfig(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
configType string
ext string
}{
{"TOML", "toml", ".toml"},
{"YAML", "yaml", ".yaml"},
{"JSON", "json", ".json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configFile := filepath.Join(dir, "write_test"+tt.ext)
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType(tt.configType); err != nil {
t.Fatal(err)
}
cm.Set("port", 8080)
cm.Set("host", "example.com")
if err := cm.WriteConfig(); err != nil {
t.Fatalf("WriteConfig() error = %v", err)
}
// Read it back
cm2 := NewConfigManager()
cm2.SetConfigFile(configFile)
if err := cm2.SetConfigType(tt.configType); err != nil {
t.Fatal(err)
}
if err := cm2.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
if got := cm2.GetInt("port"); got != 8080 {
t.Errorf("GetInt(port) = %d, want 8080", got)
}
if got := cm2.GetString("host"); got != "example.com" {
t.Errorf("GetString(host) = %q, want %q", got, "example.com")
}
})
}
}
func TestSetConfigTypeInvalid(t *testing.T) {
cm := NewConfigManager()
err := cm.SetConfigType("xml")
if err == nil {
t.Error("SetConfigType(xml) expected error, got nil")
}
}
func TestEnvPrefix(t *testing.T) {
// Set env vars BEFORE creating ConfigManager
os.Setenv("TESTAPP_PORT", "3000")
os.Setenv("TESTAPP_HOST", "envhost")
os.Setenv("OTHER_VAR", "other")
defer func() {
os.Unsetenv("TESTAPP_PORT")
os.Unsetenv("TESTAPP_HOST")
os.Unsetenv("OTHER_VAR")
}()
// Create new manager AFTER setting env vars, then apply prefix
cm := NewConfigManager().WithEnvPrefix("TESTAPP_")
if got := cm.GetString("port"); got != "3000" {
t.Errorf("GetString(port) = %q, want %q", got, "3000")
}
if got := cm.GetString("host"); got != "envhost" {
t.Errorf("GetString(host) = %q, want %q", got, "envhost")
}
// OTHER_VAR should not be accessible without prefix
if got := cm.GetString("other_var"); got != "" {
t.Errorf("GetString(other_var) = %q, want empty", got)
}
}
func TestEnvVarWithEqualsInValue(t *testing.T) {
os.Setenv("TEST_CONN", "host=localhost;user=admin")
defer os.Unsetenv("TEST_CONN")
cm := NewConfigManager()
if got := cm.GetString("test_conn"); got != "host=localhost;user=admin" {
t.Errorf("GetString(test_conn) = %q, want %q", got, "host=localhost;user=admin")
}
}
func TestEnvOverridesDefault(t *testing.T) {
os.Setenv("MYPORT", "5000")
defer os.Unsetenv("MYPORT")
cm := NewConfigManager()
cm.SetDefault("myport", 8080)
if got := cm.GetInt("myport"); got != 5000 {
t.Errorf("GetInt(myport) = %d, want 5000 (from env)", got)
}
}
func TestConfigFileOverridesEnv(t *testing.T) {
os.Setenv("PORT", "5000")
defer os.Unsetenv("PORT")
dir := t.TempDir()
configFile := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(configFile, []byte("port: 9000"), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
cm.SetDefault("port", 8080)
if err := cm.ReadInConfig(); err != nil {
t.Fatal(err)
}
// Config file should override env and default
if got := cm.GetInt("port"); got != 9000 {
t.Errorf("GetInt(port) = %d, want 9000 (from file)", got)
}
}
func TestCaseInsensitiveKeys(t *testing.T) {
cm := NewConfigManager()
cm.Set("MyKey", "value")
tests := []string{"MyKey", "mykey", "MYKEY", "mYkEy"}
for _, key := range tests {
if got := cm.GetString(key); got != "value" {
t.Errorf("GetString(%q) = %q, want %q", key, got, "value")
}
}
}
func TestGetNonExistentKey(t *testing.T) {
cm := NewConfigManager()
if got := cm.GetString("nonexistent"); got != "" {
t.Errorf("GetString(nonexistent) = %q, want empty", got)
}
if got := cm.GetInt("nonexistent"); got != 0 {
t.Errorf("GetInt(nonexistent) = %d, want 0", got)
}
if got := cm.GetBool("nonexistent"); got != false {
t.Errorf("GetBool(nonexistent) = %v, want false", got)
}
if got := cm.GetDuration("nonexistent"); got != 0 {
t.Errorf("GetDuration(nonexistent) = %v, want 0", got)
}
if got := cm.GetStringSlice("nonexistent"); got != nil {
t.Errorf("GetStringSlice(nonexistent) = %v, want nil", got)
}
if got := cm.GetIntSlice("nonexistent"); got != nil {
t.Errorf("GetIntSlice(nonexistent) = %v, want nil", got)
}
if got := cm.GetStringMap("nonexistent"); got != nil {
t.Errorf("GetStringMap(nonexistent) = %v, want nil", got)
}
}
func TestGetBoolUnknownType(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", struct{}{})
// Should not panic, should return false
got := cm.GetBool("key")
if got != false {
t.Errorf("GetBool(struct) = %v, want false", got)
}
}
func TestGetDurationUnknownType(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", struct{}{})
// Should not panic, should return 0
got := cm.GetDuration("key")
if got != 0 {
t.Errorf("GetDuration(struct) = %v, want 0", got)
}
}
func TestConcurrentAccess(t *testing.T) {
cm := NewConfigManager()
var wg sync.WaitGroup
// Concurrent writes
for i := range 100 {
wg.Add(1)
go func(n int) {
defer wg.Done()
cm.Set("key", n)
cm.SetDefault("default", n)
cm.SetString("str", "value")
cm.SetBool("bool", true)
}(i)
}
// Concurrent reads
for range 100 {
wg.Go(func() {
_ = cm.GetInt("key")
_ = cm.GetString("str")
_ = cm.GetBool("bool")
_ = cm.Get("key")
_ = cm.ConfigFileUsed()
})
}
wg.Wait()
}
func TestConcurrentReadWrite(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "concurrent.yaml")
if err := os.WriteFile(configFile, []byte("port: 8080"), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
// Reader goroutines
for range 50 {
wg.Go(func() {
for range 10 {
_ = cm.GetInt("port")
_ = cm.GetString("host")
}
})
}
// Writer goroutines
for i := range 50 {
wg.Add(1)
go func(n int) {
defer wg.Done()
for range 10 {
cm.Set("port", n)
cm.SetDefault("host", "localhost")
}
}(i)
}
// Config operations
for range 10 {
wg.Go(func() {
_ = cm.ReadInConfig()
})
}
wg.Wait()
}
// Package-level function tests (default.go)
func TestPackageLevelFunctions(t *testing.T) {
// Reset default manager for this test
defaultConfigManager = NewConfigManager()
dir := t.TempDir()
configFile := filepath.Join(dir, "pkg_test.yaml")
if err := os.WriteFile(configFile, []byte("port: 8888\nhost: pkghost"), 0o644); err != nil {
t.Fatal(err)
}
SetConfigFile(configFile)
if err := SetConfigType("yaml"); err != nil {
t.Fatal(err)
}
SetDefault("timeout", "30s")
if err := ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
// Set() must be called AFTER ReadInConfig to override file values
Set("debug", true)
if got := GetInt("port"); got != 8888 {
t.Errorf("GetInt(port) = %d, want 8888", got)
}
if got := GetString("host"); got != "pkghost" {
t.Errorf("GetString(host) = %q, want %q", got, "pkghost")
}
if got := GetBool("debug"); got != true {
t.Errorf("GetBool(debug) = %v, want true", got)
}
if got := GetDuration("timeout"); got != 30*time.Second {
t.Errorf("GetDuration(timeout) = %v, want 30s", got)
}
if got := ConfigFileUsed(); got != configFile {
t.Errorf("ConfigFileUsed() = %q, want %q", got, configFile)
}
}
func TestUseExplicitDefaults(t *testing.T) {
cm := NewConfigManager()
cm.UseExplicitDefaults(true)
// Just verify it doesn't panic and the field is set
cm.SetDefault("key", "value")
if got := cm.GetString("key"); got != "value" {
t.Errorf("GetString(key) = %q, want %q", got, "value")
}
}
func TestSetString(t *testing.T) {
cm := NewConfigManager()
cm.SetString("name", "test")
if got := cm.GetString("name"); got != "test" {
t.Errorf("GetString(name) = %q, want %q", got, "test")
}
}
func TestSetBool(t *testing.T) {
cm := NewConfigManager()
cm.SetBool("enabled", true)
if got := cm.GetBool("enabled"); got != true {
t.Errorf("GetBool(enabled) = %v, want true", got)
}
}
func TestWriteConfigUnsupportedType(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "test.txt")
cm := NewConfigManager()
cm.SetConfigFile(configFile)
// Don't set config type
err := cm.WriteConfig()
if err == nil {
t.Error("WriteConfig() expected error for unsupported type, got nil")
}
}
func TestSetEnvPrefix(t *testing.T) {
cm := NewConfigManager()
cm.SetEnvPrefix("PREFIX_")
}
func TestDeeplyNestedConfig(t *testing.T) {
const nestedYAML = `
app:
name: myapp
server:
host: localhost
port: 8080
tls:
enabled: true
cert: /path/to/cert.pem
key: /path/to/key.pem
database:
primary:
host: db1.example.com
port: 5432
credentials:
username: admin
password: secret
replicas:
- host: db2.example.com
port: 5432
- host: db3.example.com
port: 5432
features:
- name: feature1
enabled: true
config:
timeout: 30s
retries: 3
- name: feature2
enabled: false
`
const nestedTOML = `
[app]
name = "myapp"
[app.server]
host = "localhost"
port = 8080
[app.server.tls]
enabled = true
cert = "/path/to/cert.pem"
key = "/path/to/key.pem"
[app.database.primary]
host = "db1.example.com"
port = 5432
[app.database.primary.credentials]
username = "admin"
password = "secret"
[[app.database.replicas]]
host = "db2.example.com"
port = 5432
[[app.database.replicas]]
host = "db3.example.com"
port = 5432
[[app.features]]
name = "feature1"
enabled = true
[app.features.config]
timeout = "30s"
retries = 3
[[app.features]]
name = "feature2"
enabled = false
`
const nestedJSON = `{
"app": {
"name": "myapp",
"server": {
"host": "localhost",
"port": 8080,
"tls": {
"enabled": true,
"cert": "/path/to/cert.pem",
"key": "/path/to/key.pem"
}
},
"database": {
"primary": {
"host": "db1.example.com",
"port": 5432,
"credentials": {
"username": "admin",
"password": "secret"
}
},
"replicas": [
{"host": "db2.example.com", "port": 5432},
{"host": "db3.example.com", "port": 5432}
]
},
"features": [
{
"name": "feature1",
"enabled": true,
"config": {
"timeout": "30s",
"retries": 3
}
},
{
"name": "feature2",
"enabled": false
}
]
}
}`
tests := []struct {
name string
configType string
content string
ext string
}{
{"YAML", "yaml", nestedYAML, ".yaml"},
{"TOML", "toml", nestedTOML, ".toml"},
{"JSON", "json", nestedJSON, ".json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "nested"+tt.ext)
if err := os.WriteFile(configFile, []byte(tt.content), 0o644); err != nil {
t.Fatal(err)
}
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType(tt.configType); err != nil {
t.Fatal(err)
}
if err := cm.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
// Test that we can retrieve the top-level nested structure
app := cm.GetStringMap("app")
if app == nil {
t.Fatal("GetStringMap(app) = nil, want nested map")
}
// Verify app.name exists
if name, ok := app["name"].(string); !ok || name != "myapp" {
t.Errorf("app.name = %v, want %q", app["name"], "myapp")
}
// Verify nested server config
server, ok := app["server"].(map[string]any)
if !ok {
t.Fatalf("app.server is not a map: %T", app["server"])
}
if server["host"] != "localhost" {
t.Errorf("app.server.host = %v, want %q", server["host"], "localhost")
}
// Verify deeply nested TLS config
tls, ok := server["tls"].(map[string]any)
if !ok {
t.Fatalf("app.server.tls is not a map: %T", server["tls"])
}
if tls["enabled"] != true {
t.Errorf("app.server.tls.enabled = %v, want true", tls["enabled"])
}
if tls["cert"] != "/path/to/cert.pem" {
t.Errorf("app.server.tls.cert = %v, want %q", tls["cert"], "/path/to/cert.pem")
}
// Verify database.primary.credentials (4 levels deep)
database, ok := app["database"].(map[string]any)
if !ok {
t.Fatalf("app.database is not a map: %T", app["database"])
}
primary, ok := database["primary"].(map[string]any)
if !ok {
t.Fatalf("app.database.primary is not a map: %T", database["primary"])
}
creds, ok := primary["credentials"].(map[string]any)
if !ok {
t.Fatalf("app.database.primary.credentials is not a map: %T", primary["credentials"])
}
if creds["username"] != "admin" {
t.Errorf("credentials.username = %v, want %q", creds["username"], "admin")
}
// Verify array of nested objects (replicas)
// TOML decodes to []map[string]interface{}, YAML/JSON to []any
var replicaHost any
switch r := database["replicas"].(type) {
case []any:
if len(r) != 2 {
t.Errorf("len(replicas) = %d, want 2", len(r))
}
if len(r) > 0 {
replica0, ok := r[0].(map[string]any)
if !ok {
t.Fatalf("replicas[0] is not a map: %T", r[0])
}
replicaHost = replica0["host"]
}
case []map[string]any:
if len(r) != 2 {
t.Errorf("len(replicas) = %d, want 2", len(r))
}
if len(r) > 0 {
replicaHost = r[0]["host"]
}
default:
t.Fatalf("app.database.replicas unexpected type: %T", database["replicas"])
}
if replicaHost != "db2.example.com" {
t.Errorf("replicas[0].host = %v, want %q", replicaHost, "db2.example.com")
}
// Verify features array with nested config
// TOML decodes to []map[string]interface{}, YAML/JSON to []any
var featureName any
switch f := app["features"].(type) {
case []any:
if len(f) < 1 {
t.Fatal("features slice is empty")
}
feature0, ok := f[0].(map[string]any)
if !ok {
t.Fatalf("features[0] is not a map: %T", f[0])
}
featureName = feature0["name"]
case []map[string]any:
if len(f) < 1 {
t.Fatal("features slice is empty")
}
featureName = f[0]["name"]
default:
t.Fatalf("app.features unexpected type: %T", app["features"])
}
if featureName != "feature1" {
t.Errorf("features[0].name = %v, want %q", featureName, "feature1")
}
})
}
}
func TestPackageLevelGetIntSlice(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("nums", []int{1, 2, 3})
got := GetIntSlice("nums")
if len(got) != 3 || got[0] != 1 {
t.Errorf("GetIntSlice() = %v, want [1 2 3]", got)
}
}
func TestPackageLevelSetConfigName(t *testing.T) {
defaultConfigManager = NewConfigManager()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.json"), []byte(`{"port": 1234}`), 0o644); err != nil {
t.Fatal(err)
}
SetConfigName("app")
defaultConfigManager.SetConfigDir(dir)
if err := SetConfigType("json"); err != nil {
t.Fatal(err)
}
if err := ReadInConfig(); err != nil {
t.Fatal(err)
}
if got := GetInt("port"); got != 1234 {
t.Errorf("GetInt(port) = %d, want 1234", got)
}
}
func TestPackageLevelSetEnvPrefix(t *testing.T) {
defaultConfigManager = NewConfigManager()
SetEnvPrefix("JETY_TEST_")
}
func TestPackageLevelWriteConfig(t *testing.T) {
defaultConfigManager = NewConfigManager()
dir := t.TempDir()
f := filepath.Join(dir, "out.json")
SetConfigFile(f)
if err := SetConfigType("json"); err != nil {
t.Fatal(err)
}
Set("key", "value")
if err := WriteConfig(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(f)
if len(data) == 0 {
t.Error("WriteConfig produced empty file")
}
}
func TestPackageLevelGetStringMap(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("m", map[string]any{"a": 1})
got := GetStringMap("m")
if got == nil || got["a"] != 1 {
t.Errorf("GetStringMap() = %v", got)
}
}
func TestPackageLevelGetStringSlice(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("s", []string{"a", "b"})
got := GetStringSlice("s")
if len(got) != 2 || got[0] != "a" {
t.Errorf("GetStringSlice() = %v", got)
}
}
func TestGetStringNonStringValue(t *testing.T) {
cm := NewConfigManager()
cm.Set("num", 42)
if got := cm.GetString("num"); got != "42" {
t.Errorf("GetString(num) = %q, want %q", got, "42")
}
}
func TestGetIntInt64(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", int64(999))
if got := cm.GetInt("key"); got != 999 {
t.Errorf("GetInt(int64) = %d, want 999", got)
}
}
func TestGetIntUnknownType(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", struct{}{})
if got := cm.GetInt("key"); got != 0 {
t.Errorf("GetInt(struct) = %d, want 0", got)
}
}
func TestGetIntSliceInt64Values(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", []any{int64(10), int64(20)})
got := cm.GetIntSlice("key")
if len(got) != 2 || got[0] != 10 || got[1] != 20 {
t.Errorf("GetIntSlice(int64) = %v, want [10 20]", got)
}
}
func TestGetIntSliceNonSlice(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", "notaslice")
if got := cm.GetIntSlice("key"); got != nil {
t.Errorf("GetIntSlice(string) = %v, want nil", got)
}
}
func TestGetIntSliceUnknownInnerType(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", []any{struct{}{}, true})
got := cm.GetIntSlice("key")
if len(got) != 0 {
t.Errorf("GetIntSlice(unknown types) = %v, want []", got)
}
}
func TestDeeplyNestedWriteConfig(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
configType string
ext string
}{
{"YAML", "yaml", ".yaml"},
{"TOML", "toml", ".toml"},
{"JSON", "json", ".json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configFile := filepath.Join(dir, "nested_write"+tt.ext)
cm := NewConfigManager()
cm.SetConfigFile(configFile)
if err := cm.SetConfigType(tt.configType); err != nil {
t.Fatal(err)
}
// Set a deeply nested structure
nested := map[string]any{
"server": map[string]any{
"host": "localhost",
"port": 8080,
"tls": map[string]any{
"enabled": true,
"cert": "/path/to/cert.pem",
},
},
"database": map[string]any{
"primary": map[string]any{
"host": "db.example.com",
"port": 5432,
},
},
}
cm.Set("app", nested)
if err := cm.WriteConfig(); err != nil {
t.Fatalf("WriteConfig() error = %v", err)
}
// Read it back
cm2 := NewConfigManager()
cm2.SetConfigFile(configFile)
if err := cm2.SetConfigType(tt.configType); err != nil {
t.Fatal(err)
}
if err := cm2.ReadInConfig(); err != nil {
t.Fatalf("ReadInConfig() error = %v", err)
}
// Verify nested structure was preserved
app := cm2.GetStringMap("app")
if app == nil {
t.Fatal("GetStringMap(app) = nil after read")
}
server, ok := app["server"].(map[string]any)
if !ok {
t.Fatalf("app.server is not a map: %T", app["server"])
}
if server["host"] != "localhost" {
t.Errorf("app.server.host = %v, want %q", server["host"], "localhost")
}
tls, ok := server["tls"].(map[string]any)
if !ok {
t.Fatalf("app.server.tls is not a map: %T", server["tls"])
}
if tls["enabled"] != true {
t.Errorf("app.server.tls.enabled = %v, want true", tls["enabled"])
}
})
}
}
func TestSetEnvPrefixOverridesDefault(t *testing.T) {
// Subprocess test: env vars must exist before NewConfigManager is called.
if os.Getenv("TEST_SET_ENV_PREFIX") == "1" {
cm := NewConfigManager()
cm.SetEnvPrefix("MYAPP_")
cm.SetDefault("port", 8080)
if got := cm.GetInt("port"); got != 9999 {
fmt.Fprintf(os.Stderr, "GetInt(port) = %d, want 9999\n", got)
os.Exit(1)
}
if got := cm.GetString("host"); got != "envhost" {
fmt.Fprintf(os.Stderr, "GetString(host) = %q, want %q\n", got, "envhost")
os.Exit(1)
}
// Unprefixed var should not be visible.
if got := cm.GetString("other"); got != "" {
fmt.Fprintf(os.Stderr, "GetString(other) = %q, want empty\n", got)
os.Exit(1)
}
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestSetEnvPrefixOverridesDefault$")
cmd.Env = append(os.Environ(),
"TEST_SET_ENV_PREFIX=1",
"MYAPP_PORT=9999",
"MYAPP_HOST=envhost",
"OTHER=should_not_see",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("subprocess failed: %v\n%s", err, out)
}
}
func TestSetEnvPrefixWithSetDefault(t *testing.T) {
// SetDefault should pick up prefixed env vars after SetEnvPrefix.
if os.Getenv("TEST_SET_ENV_PREFIX_DEFAULT") == "1" {
cm := NewConfigManager()
cm.SetEnvPrefix("APP_")
cm.SetDefault("database_host", "localhost")
if got := cm.GetString("database_host"); got != "db.example.com" {
fmt.Fprintf(os.Stderr, "GetString(database_host) = %q, want %q\n", got, "db.example.com")
os.Exit(1)
}
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestSetEnvPrefixWithSetDefault$")
cmd.Env = append(os.Environ(),
"TEST_SET_ENV_PREFIX_DEFAULT=1",
"APP_DATABASE_HOST=db.example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("subprocess failed: %v\n%s", err, out)
}
}
func TestPackageLevelSetEnvPrefixOverrides(t *testing.T) {
// Package-level SetEnvPrefix should work the same way.
if os.Getenv("TEST_PKG_SET_ENV_PREFIX") == "1" {
// Reset the default manager to pick up our env vars.
defaultConfigManager = NewConfigManager()
SetEnvPrefix("PKG_")
SetDefault("val", "default")
if got := GetString("val"); got != "from_env" {
fmt.Fprintf(os.Stderr, "GetString(val) = %q, want %q\n", got, "from_env")
os.Exit(1)
}
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestPackageLevelSetEnvPrefixOverrides$")
cmd.Env = append(os.Environ(),
"TEST_PKG_SET_ENV_PREFIX=1",
"PKG_VAL=from_env",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("subprocess failed: %v\n%s", err, out)
}
}