smartconfig/gjson_path_test.go
sneak 47801ce852 Add gjson path support to all typed getters
This change enhances the API to support gjson path syntax for accessing nested configuration values. Users can now use paths like 'server.ssl.enabled' or 'database.replicas.0.host' to directly access nested values without manual navigation.

Key changes:
- All typed getters now support gjson path syntax
- Backward compatibility maintained for top-level keys
- Proper error handling for null values and non-existent paths
- Special float values (Infinity, NaN) handled correctly
- Comprehensive test coverage for edge cases

This makes the API much more intuitive and reduces boilerplate code when working with nested configuration structures.
2025-07-22 12:24:59 +02:00

326 lines
7.7 KiB
Go

package smartconfig
import (
"strings"
"testing"
)
func TestGjsonPathSupport(t *testing.T) {
yamlContent := `
server:
host: localhost
port: 8080
ssl:
enabled: true
cert: /etc/ssl/cert.pem
database:
primary:
host: db1.example.com
port: 5432
credentials:
username: admin
password: secret123
replicas:
- host: db2.example.com
port: 5433
- host: db3.example.com
port: 5434
features:
new_ui: true
rate_limiting: false
max_connections: 100
timeout_seconds: 30.5
max_file_size: 10MB
metadata:
version: 1.2.3
build: 12345
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("GetString with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected string
}{
{"server.host", "localhost"},
{"server.ssl.cert", "/etc/ssl/cert.pem"},
{"database.primary.host", "db1.example.com"},
{"database.primary.credentials.username", "admin"},
{"database.replicas.0.host", "db2.example.com"},
{"database.replicas.1.host", "db3.example.com"},
{"metadata.version", "1.2.3"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetString(tt.path)
if err != nil {
t.Errorf("GetString(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetString(%q) = %q, want %q", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetInt with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected int
}{
{"server.port", 8080},
{"database.primary.port", 5432},
{"database.replicas.0.port", 5433},
{"database.replicas.1.port", 5434},
{"features.max_connections", 100},
{"metadata.build", 12345},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetInt(tt.path)
if err != nil {
t.Errorf("GetInt(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetInt(%q) = %d, want %d", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetBool with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"server.ssl.enabled", true},
{"features.new_ui", true},
{"features.rate_limiting", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetBool(tt.path)
if err != nil {
t.Errorf("GetBool(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetBool(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetFloat with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected float64
}{
{"features.timeout_seconds", 30.5},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetFloat(tt.path)
if err != nil {
t.Errorf("GetFloat(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetFloat(%q) = %f, want %f", tt.path, result, tt.expected)
}
})
}
})
t.Run("GetBytes with gjson paths", func(t *testing.T) {
tests := []struct {
path string
expected uint64
}{
{"features.max_file_size", 10000000}, // 10MB (decimal, as parsed by humanize)
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result, err := config.GetBytes(tt.path)
if err != nil {
t.Errorf("GetBytes(%q) failed: %v", tt.path, err)
return
}
if result != tt.expected {
t.Errorf("GetBytes(%q) = %d, want %d", tt.path, result, tt.expected)
}
})
}
})
t.Run("Get with gjson paths", func(t *testing.T) {
// Test array access
value, exists := config.Get("database.replicas")
if !exists {
t.Error("Get(database.replicas) should exist")
return
}
replicas, ok := value.([]interface{})
if !ok {
t.Errorf("Get(database.replicas) should return []interface{}, got %T", value)
return
}
if len(replicas) != 2 {
t.Errorf("Expected 2 replicas, got %d", len(replicas))
}
// Test nested object access
value, exists = config.Get("database.primary.credentials")
if !exists {
t.Error("Get(database.primary.credentials) should exist")
return
}
creds, ok := value.(map[string]interface{})
if !ok {
t.Errorf("Get(database.primary.credentials) should return map[string]interface{}, got %T", value)
return
}
if creds["username"] != "admin" {
t.Errorf("Expected username=admin, got %v", creds["username"])
}
})
t.Run("Non-existent paths", func(t *testing.T) {
_, err := config.GetString("server.nonexistent")
if err == nil {
t.Error("GetString(server.nonexistent) should return error")
}
_, err = config.GetInt("database.nonexistent.port")
if err == nil {
t.Error("GetInt(database.nonexistent.port) should return error")
}
_, exists := config.Get("features.nonexistent")
if exists {
t.Error("Get(features.nonexistent) should not exist")
}
})
t.Run("Backward compatibility", func(t *testing.T) {
// Test that top-level keys still work
yamlContent := `
app_name: TestApp
port: 9090
debug: true
timeout: 45.5
max_size: 50MB
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Test direct top-level access
name, err := config.GetString("app_name")
if err != nil || name != "TestApp" {
t.Errorf("GetString(app_name) = %q, %v; want TestApp, nil", name, err)
}
port, err := config.GetInt("port")
if err != nil || port != 9090 {
t.Errorf("GetInt(port) = %d, %v; want 9090, nil", port, err)
}
debug, err := config.GetBool("debug")
if err != nil || !debug {
t.Errorf("GetBool(debug) = %v, %v; want true, nil", debug, err)
}
timeout, err := config.GetFloat("timeout")
if err != nil || timeout != 45.5 {
t.Errorf("GetFloat(timeout) = %f, %v; want 45.5, nil", timeout, err)
}
maxSize, err := config.GetBytes("max_size")
expectedSize := uint64(50000000) // 50MB (decimal, as parsed by humanize)
if err != nil || maxSize != expectedSize {
t.Errorf("GetBytes(max_size) = %d, %v; want %d, nil", maxSize, err, expectedSize)
}
})
}
func TestGjsonAdvancedPaths(t *testing.T) {
yamlContent := `
users:
- name: Alice
age: 30
roles:
- admin
- developer
- name: Bob
age: 25
roles:
- developer
- name: Charlie
age: 35
roles:
- manager
- admin
settings:
features:
ui:
theme: dark
language: en
api:
rate_limit: 1000
timeout: 30
`
config, err := NewFromReader(strings.NewReader(yamlContent))
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
t.Run("Array element access", func(t *testing.T) {
// Access specific user
name, err := config.GetString("users.1.name")
if err != nil || name != "Bob" {
t.Errorf("GetString(users.1.name) = %q, %v; want Bob, nil", name, err)
}
age, err := config.GetInt("users.2.age")
if err != nil || age != 35 {
t.Errorf("GetInt(users.2.age) = %d, %v; want 35, nil", age, err)
}
// Access role in array
role, err := config.GetString("users.0.roles.0")
if err != nil || role != "admin" {
t.Errorf("GetString(users.0.roles.0) = %q, %v; want admin, nil", role, err)
}
})
t.Run("Deep nested access", func(t *testing.T) {
theme, err := config.GetString("settings.features.ui.theme")
if err != nil || theme != "dark" {
t.Errorf("GetString(settings.features.ui.theme) = %q, %v; want dark, nil", theme, err)
}
rateLimit, err := config.GetInt("settings.features.api.rate_limit")
if err != nil || rateLimit != 1000 {
t.Errorf("GetInt(settings.features.api.rate_limit) = %d, %v; want 1000, nil", rateLimit, err)
}
})
}