Public API: - Unmarshal(dst, opts...) - UnmarshalPrefix(dst, prefix, opts...) - AsMap() / AsMapPrefix(prefix) - Options: WithTrim, WithWeaklyTyped, WithTagName, WithDecodeHook Merges three divergent in-repo copies (ai-fabric/pkg/env, markets-platform/TP-general-code/pkg/system, and the var/agents/issue-* snapshots), adds error wrapping, prefix filter, and pluggable hooks. 15 pure-Go unit tests, no synthetic mocks, lint clean. Extracted per inventar/docs/asr/ASR-0008-ai-fabric-audit.md.
223 lines
5.7 KiB
Go
223 lines
5.7 KiB
Go
package env
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
// asMapFromEnviron is tested with a synthetic environ slice — this is NOT a
|
|
// mock of anything external. It's a pure-input unit test that isolates the
|
|
// core mapping logic from process-global state.
|
|
|
|
func TestAsMapFromEnviron_FlatAndNested(t *testing.T) {
|
|
got := asMapFromEnviron([]string{
|
|
"FOO=bar",
|
|
"SERVICE_HTTP_PORT=8080",
|
|
"SERVICE_KEY=abc",
|
|
}, "")
|
|
|
|
want := map[string]any{
|
|
"foo": "bar",
|
|
"service": map[string]any{
|
|
"http": map[string]any{"port": "8080"},
|
|
"key": "abc",
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAsMapFromEnviron_PreservesValuesWithEquals(t *testing.T) {
|
|
got := asMapFromEnviron([]string{"TOKEN=a=b=c="}, "")
|
|
if got["token"] != "a=b=c=" {
|
|
t.Fatalf("expected full value preserved, got %#v", got["token"])
|
|
}
|
|
}
|
|
|
|
func TestAsMapFromEnviron_PrefixFilterAndStrip(t *testing.T) {
|
|
got := asMapFromEnviron([]string{
|
|
"APP_DB_HOST=localhost",
|
|
"APP_DB_PORT=5432",
|
|
"OTHER_THING=skip",
|
|
}, "APP_")
|
|
|
|
want := map[string]any{
|
|
"db": map[string]any{"host": "localhost", "port": "5432"},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("got %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAsMapFromEnviron_EmptyAndMalformedAreSkipped(t *testing.T) {
|
|
got := asMapFromEnviron([]string{"NOEQUALS", ""}, "")
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected no entries, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestAsMapFromEnviron_FirstWriteWinsOnCollision(t *testing.T) {
|
|
// This matches the ai-fabric original: once a leaf is set for a path,
|
|
// subsequent entries that would overwrite it are ignored.
|
|
got := asMapFromEnviron([]string{
|
|
"A=1",
|
|
"A=2",
|
|
}, "")
|
|
if got["a"] != "1" {
|
|
t.Fatalf("expected first-write-wins (a=1), got %#v", got["a"])
|
|
}
|
|
}
|
|
|
|
func TestAsMapFromEnviron_PrefixOnlyVariableIsSkipped(t *testing.T) {
|
|
// "APP_=xxx" with prefix "APP_" would strip to empty key — we skip it.
|
|
got := asMapFromEnviron([]string{"APP_=oops"}, "APP_")
|
|
if len(got) != 0 {
|
|
t.Fatalf("expected empty map, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decoder / option tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestUnmarshal_BasicStruct(t *testing.T) {
|
|
t.Setenv("SERVICE_KEY", "abc")
|
|
t.Setenv("SERVICE_PORT", "8080")
|
|
|
|
var cfg struct {
|
|
Service struct {
|
|
Key string
|
|
Port int
|
|
}
|
|
}
|
|
if err := Unmarshal(&cfg); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.Service.Key != "abc" {
|
|
t.Errorf("Key = %q, want abc", cfg.Service.Key)
|
|
}
|
|
if cfg.Service.Port != 8080 {
|
|
t.Errorf("Port = %d, want 8080 (weakly-typed string->int)", cfg.Service.Port)
|
|
}
|
|
}
|
|
|
|
func TestUnmarshal_TrimsStringsByDefault(t *testing.T) {
|
|
t.Setenv("NAME", " hello ")
|
|
|
|
var cfg struct{ Name string }
|
|
if err := Unmarshal(&cfg); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.Name != "hello" {
|
|
t.Fatalf("expected trimmed value, got %q", cfg.Name)
|
|
}
|
|
}
|
|
|
|
func TestUnmarshal_WithTrimOff(t *testing.T) {
|
|
t.Setenv("NAME", " keep me ")
|
|
|
|
var cfg struct{ Name string }
|
|
if err := Unmarshal(&cfg, WithTrim(false)); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.Name != " keep me " {
|
|
t.Fatalf("expected untrimmed value, got %q", cfg.Name)
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalPrefix_StripsAndFilters(t *testing.T) {
|
|
t.Setenv("APP_DB_HOST", "localhost")
|
|
t.Setenv("APP_DB_PORT", "5432")
|
|
t.Setenv("OTHER_DB_HOST", "should-be-ignored")
|
|
|
|
var cfg struct {
|
|
DB struct {
|
|
Host string
|
|
Port int
|
|
} `mapstructure:"db"`
|
|
}
|
|
if err := UnmarshalPrefix(&cfg, "APP_"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.DB.Host != "localhost" {
|
|
t.Errorf("Host = %q, want localhost", cfg.DB.Host)
|
|
}
|
|
if cfg.DB.Port != 5432 {
|
|
t.Errorf("Port = %d, want 5432", cfg.DB.Port)
|
|
}
|
|
}
|
|
|
|
func TestUnmarshal_WithTagName(t *testing.T) {
|
|
t.Setenv("DATABASE_URL", "postgres://x")
|
|
|
|
// The map is always nested by '_', so tags also reference the nested
|
|
// path, one component per struct level. Here we override the outer
|
|
// field name via a custom tag.
|
|
var cfg struct {
|
|
DB struct {
|
|
URL string
|
|
} `env:"database"`
|
|
}
|
|
if err := Unmarshal(&cfg, WithTagName("env")); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.DB.URL != "postgres://x" {
|
|
t.Fatalf("URL = %q, want postgres://x", cfg.DB.URL)
|
|
}
|
|
}
|
|
|
|
func TestUnmarshal_WeaklyTypedOff(t *testing.T) {
|
|
t.Setenv("PORT", "8080")
|
|
|
|
var cfg struct{ Port int }
|
|
err := Unmarshal(&cfg, WithWeaklyTyped(false))
|
|
// With strict typing, "8080" (string) cannot decode into an int.
|
|
if err == nil {
|
|
t.Fatal("expected strict typing to reject string->int coercion")
|
|
}
|
|
}
|
|
|
|
func TestUnmarshal_SpecialCharactersSurvive(t *testing.T) {
|
|
// Regression: keys with special chars in their values must pass through
|
|
// unchanged (modulo TrimSpace).
|
|
t.Setenv("SPECIAL", "@!#$%^&*()")
|
|
|
|
var cfg struct{ Special string }
|
|
if err := Unmarshal(&cfg); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.Special != "@!#$%^&*()" {
|
|
t.Fatalf("Special = %q, want @!#$%%^&*()", cfg.Special)
|
|
}
|
|
}
|
|
|
|
func TestUnmarshal_RejectsNonPointer(t *testing.T) {
|
|
var cfg struct{ A string }
|
|
err := Unmarshal(cfg) // no pointer
|
|
if err == nil {
|
|
t.Fatal("expected error when passing a non-pointer")
|
|
}
|
|
}
|
|
|
|
func TestAsMap_ExposesCurrentEnv(t *testing.T) {
|
|
t.Setenv("GO_ENV_ASMAP_PROBE", "hit")
|
|
|
|
m := AsMap()
|
|
goenv, ok := m["go"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected nested map under 'go', got %T", m["go"])
|
|
}
|
|
env, ok := goenv["env"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected nested map under 'go.env', got %T", goenv["env"])
|
|
}
|
|
asmap, ok := env["asmap"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected nested map under 'go.env.asmap', got %T", env["asmap"])
|
|
}
|
|
if asmap["probe"] != "hit" {
|
|
t.Fatalf("probe = %v, want hit", asmap["probe"])
|
|
}
|
|
}
|