Ensure that numeric conversions are lossless by verifying the converted value matches the original string when converted back. This prevents: - Loss of leading zeros (0123 stays as string) - Loss of trailing zeros in floats (1.50 stays as string) - Loss of plus signs (+123 stays as string) - Changes in notation (1e10 stays as string) Only convert to numeric types when the string representation would be preserved exactly. This maintains data integrity while still providing convenient type conversions for clean numeric values. Added comprehensive tests to verify the safety check behavior.
258 lines
6.2 KiB
Go
258 lines
6.2 KiB
Go
package smartconfig
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestTypedInterpolation(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
yaml string
|
|
env map[string]string
|
|
checks []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}
|
|
}{
|
|
{
|
|
name: "unquoted number interpolation",
|
|
yaml: `port: ${ENV:PORT}`,
|
|
env: map[string]string{"PORT": "8080"},
|
|
checks: []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}{
|
|
{key: "port", expectedVal: 8080, expectedType: "int"},
|
|
},
|
|
},
|
|
{
|
|
name: "force string with prefix",
|
|
yaml: `port: "port-${ENV:PORT}"`,
|
|
env: map[string]string{"PORT": "8080"},
|
|
checks: []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}{
|
|
{key: "port", expectedVal: "port-8080", expectedType: "string"},
|
|
},
|
|
},
|
|
{
|
|
name: "boolean interpolation",
|
|
yaml: `enabled: ${ENV:ENABLED}`,
|
|
env: map[string]string{"ENABLED": "true"},
|
|
checks: []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}{
|
|
{key: "enabled", expectedVal: true, expectedType: "bool"},
|
|
},
|
|
},
|
|
{
|
|
name: "float interpolation",
|
|
yaml: `timeout: ${ENV:TIMEOUT}`,
|
|
env: map[string]string{"TIMEOUT": "30.5"},
|
|
checks: []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}{
|
|
{key: "timeout", expectedVal: 30.5, expectedType: "float64"},
|
|
},
|
|
},
|
|
{
|
|
name: "mixed content stays string",
|
|
yaml: `message: "Hello ${ENV:NAME}!"`,
|
|
env: map[string]string{"NAME": "World"},
|
|
checks: []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}{
|
|
{key: "message", expectedVal: "Hello World!", expectedType: "string"},
|
|
},
|
|
},
|
|
{
|
|
name: "nested interpolation with type",
|
|
yaml: `value: ${ENV:PREFIX_${ENV:SUFFIX}}`,
|
|
env: map[string]string{"SUFFIX": "VAL", "PREFIX_VAL": "42"},
|
|
checks: []struct {
|
|
key string
|
|
expectedVal interface{}
|
|
expectedType string
|
|
}{
|
|
{key: "value", expectedVal: 42, expectedType: "int"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Set environment variables
|
|
for k, v := range tc.env {
|
|
_ = os.Setenv(k, v)
|
|
defer func(key string) { _ = os.Unsetenv(key) }(k)
|
|
}
|
|
|
|
// Load config
|
|
config, err := NewFromReader(strings.NewReader(tc.yaml))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// Check values and types
|
|
for _, check := range tc.checks {
|
|
val, exists := config.Get(check.key)
|
|
if !exists {
|
|
t.Errorf("Key %s not found", check.key)
|
|
continue
|
|
}
|
|
|
|
// Check type
|
|
actualType := ""
|
|
switch val.(type) {
|
|
case string:
|
|
actualType = "string"
|
|
case int:
|
|
actualType = "int"
|
|
case float64:
|
|
actualType = "float64"
|
|
case bool:
|
|
actualType = "bool"
|
|
default:
|
|
actualType = "unknown"
|
|
}
|
|
|
|
if actualType != check.expectedType {
|
|
t.Errorf("Key %s: expected type %s, got %s (value: %v)",
|
|
check.key, check.expectedType, actualType, val)
|
|
}
|
|
|
|
// Check value
|
|
if val != check.expectedVal {
|
|
t.Errorf("Key %s: expected value %v, got %v",
|
|
check.key, check.expectedVal, val)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNumericConversionSafety(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected interface{}
|
|
desc string
|
|
}{
|
|
{
|
|
name: "integer with leading zeros stays string",
|
|
input: "0123",
|
|
expected: "0123",
|
|
desc: "Leading zeros should prevent integer conversion",
|
|
},
|
|
{
|
|
name: "float with trailing zeros",
|
|
input: "1.50",
|
|
expected: "1.50",
|
|
desc: "Trailing zeros should prevent float conversion",
|
|
},
|
|
{
|
|
name: "scientific notation stays string",
|
|
input: "1e10",
|
|
expected: "1e10",
|
|
desc: "Scientific notation should stay as string",
|
|
},
|
|
{
|
|
name: "integer within range converts",
|
|
input: "12345",
|
|
expected: 12345,
|
|
desc: "Normal integers should convert",
|
|
},
|
|
{
|
|
name: "float with no trailing zeros converts",
|
|
input: "123.45",
|
|
expected: 123.45,
|
|
desc: "Normal floats should convert",
|
|
},
|
|
{
|
|
name: "negative integer converts",
|
|
input: "-123",
|
|
expected: -123,
|
|
desc: "Negative integers should convert",
|
|
},
|
|
{
|
|
name: "negative float converts",
|
|
input: "-123.45",
|
|
expected: -123.45,
|
|
desc: "Negative floats should convert",
|
|
},
|
|
{
|
|
name: "very large number stays string",
|
|
input: "999999999999999999999999999999",
|
|
expected: "999999999999999999999999999999",
|
|
desc: "Numbers beyond int range should stay as strings",
|
|
},
|
|
{
|
|
name: "float that looks like int converts to int",
|
|
input: "123.0",
|
|
expected: "123.0",
|
|
desc: "Float notation should be preserved even for whole numbers",
|
|
},
|
|
{
|
|
name: "plus sign prevents conversion",
|
|
input: "+123",
|
|
expected: "+123",
|
|
desc: "Plus sign should prevent conversion",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Set up environment variable
|
|
varName := "TEST_NUMERIC_" + strings.ToUpper(strings.ReplaceAll(tt.name, " ", "_"))
|
|
_ = os.Setenv(varName, tt.input)
|
|
defer func() { _ = os.Unsetenv(varName) }()
|
|
|
|
yaml := fmt.Sprintf("value: ${ENV:%s}", varName)
|
|
config, err := NewFromReader(strings.NewReader(yaml))
|
|
if err != nil {
|
|
t.Fatalf("Failed to parse YAML: %v", err)
|
|
}
|
|
|
|
val, exists := config.Get("value")
|
|
if !exists {
|
|
t.Fatal("Expected value to exist")
|
|
}
|
|
|
|
// Check type and value
|
|
switch expected := tt.expected.(type) {
|
|
case int:
|
|
if i, ok := val.(int); !ok {
|
|
t.Errorf("%s: Expected int, got %T: %v", tt.desc, val, val)
|
|
} else if i != expected {
|
|
t.Errorf("%s: Expected %d, got %d", tt.desc, expected, i)
|
|
}
|
|
case float64:
|
|
if f, ok := val.(float64); !ok {
|
|
t.Errorf("%s: Expected float64, got %T: %v", tt.desc, val, val)
|
|
} else if f != expected {
|
|
t.Errorf("%s: Expected %f, got %f", tt.desc, expected, f)
|
|
}
|
|
case string:
|
|
if s, ok := val.(string); !ok {
|
|
t.Errorf("%s: Expected string, got %T: %v", tt.desc, val, val)
|
|
} else if s != expected {
|
|
t.Errorf("%s: Expected %q, got %q", tt.desc, expected, s)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|