Proxy: Make internal/env tests hermetic by injecting env and file indirections

Swap direct os.Getenv/Setenv/LookupEnv and os.Open calls in internal/env
for package-level function variables so tests can replace them with
in-memory fakes. Tests no longer mutate process env or hit the filesystem,
making them safe to run in parallel and free of global side effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
winlin 2026-04-18 14:53:44 -04:00
parent 41ad82e19f
commit 431b842dd1
2 changed files with 208 additions and 197 deletions

42
internal/env/env.go vendored
View File

@ -6,6 +6,7 @@ package env
import ( import (
"bufio" "bufio"
"context" "context"
"io"
"os" "os"
"strings" "strings"
@ -13,6 +14,17 @@ import (
"srsx/internal/logger" "srsx/internal/logger"
) )
// Indirections over os and filesystem primitives so tests can swap them
// without touching real process env or the filesystem.
var (
getEnv = os.Getenv
setEnv = os.Setenv
lookupEnv = os.LookupEnv
openFile = func(name string) (io.ReadCloser, error) {
return os.Open(name)
}
)
// Environment provides access to environment variables. // Environment provides access to environment variables.
type Environment interface { type Environment interface {
// Go pprof profiling // Go pprof profiling
@ -171,16 +183,10 @@ func loadEnvFile(ctx context.Context) error {
return errors.Wrapf(err, "load .env file") return errors.Wrapf(err, "load .env file")
} }
// Build a set of existing environment variable keys, so we don't overwrite them. // Skip keys already set in the environment so we don't overwrite them.
currentEnv := make(map[string]bool)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
currentEnv[key] = true
}
for key, value := range envMap { for key, value := range envMap {
if !currentEnv[key] { if _, ok := lookupEnv(key); !ok {
os.Setenv(key, value) setEnv(key, value)
} }
} }
@ -188,16 +194,21 @@ func loadEnvFile(ctx context.Context) error {
return nil return nil
} }
// parseEnvFile reads a .env file and returns a map of key-value pairs. // parseEnvFile opens filename and parses its contents as .env-formatted lines.
func parseEnvFile(filename string) (map[string]string, error) { func parseEnvFile(filename string) (map[string]string, error) {
file, err := os.Open(filename) file, err := openFile(filename)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer file.Close() defer file.Close()
return parseEnvReader(file)
}
// parseEnvReader parses .env-formatted content from r. It performs no I/O
// beyond reading r, so it is trivially testable with strings.NewReader.
func parseEnvReader(r io.Reader) (map[string]string, error) {
envMap := make(map[string]string) envMap := make(map[string]string)
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(r)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
@ -330,11 +341,6 @@ func buildDefaultEnvironmentVariables(ctx context.Context) {
// setEnvDefault set env key=value if not set. // setEnvDefault set env key=value if not set.
func setEnvDefault(key, value string) { func setEnvDefault(key, value string) {
if getEnv(key) == "" { if getEnv(key) == "" {
os.Setenv(key, value) setEnv(key, value)
} }
} }
// getEnv get the env by key.
func getEnv(key string) string {
return os.Getenv(key)
}

View File

@ -5,14 +5,58 @@ package env
import ( import (
"context" "context"
"errors"
"io"
"os" "os"
"path/filepath" "strings"
"testing" "testing"
srserrors "srsx/internal/errors"
) )
func TestParseEnvFile_BasicKeyValue(t *testing.T) { // fakeEnv is an in-memory replacement for process environment variables.
f := writeTempEnv(t, "FOO=bar\nHELLO=world\n") // Tests install it via withFakeEnv so no real os.Setenv/os.Getenv call is
m, err := parseEnvFile(f) // ever made, which keeps tests hermetic and free of global side effects.
type fakeEnv struct {
store map[string]string
}
func (f *fakeEnv) get(k string) string { return f.store[k] }
func (f *fakeEnv) set(k, v string) error { f.store[k] = v; return nil }
func (f *fakeEnv) lookup(k string) (string, bool) {
v, ok := f.store[k]
return v, ok
}
// withFakeEnv swaps getEnv/setEnv/lookupEnv to an in-memory map for the
// duration of the test and restores the originals on cleanup.
func withFakeEnv(t *testing.T) *fakeEnv {
t.Helper()
fe := &fakeEnv{store: map[string]string{}}
origGet, origSet, origLookup := getEnv, setEnv, lookupEnv
getEnv, setEnv, lookupEnv = fe.get, fe.set, fe.lookup
t.Cleanup(func() {
getEnv, setEnv, lookupEnv = origGet, origSet, origLookup
})
return fe
}
// withFakeOpen swaps openFile to return either content or err, and
// restores the original on cleanup. If err is non-nil, content is ignored.
func withFakeOpen(t *testing.T, content string, err error) {
t.Helper()
orig := openFile
openFile = func(string) (io.ReadCloser, error) {
if err != nil {
return nil, err
}
return io.NopCloser(strings.NewReader(content)), nil
}
t.Cleanup(func() { openFile = orig })
}
func TestParseEnvReader_BasicKeyValue(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("FOO=bar\nHELLO=world\n"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -24,9 +68,8 @@ func TestParseEnvFile_BasicKeyValue(t *testing.T) {
} }
} }
func TestParseEnvFile_SkipCommentsAndBlankLines(t *testing.T) { func TestParseEnvReader_SkipCommentsAndBlankLines(t *testing.T) {
f := writeTempEnv(t, "# this is a comment\n\nKEY=value\n\n# another comment\n") m, err := parseEnvReader(strings.NewReader("# this is a comment\n\nKEY=value\n\n# another comment\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -38,9 +81,8 @@ func TestParseEnvFile_SkipCommentsAndBlankLines(t *testing.T) {
} }
} }
func TestParseEnvFile_ExportPrefix(t *testing.T) { func TestParseEnvReader_ExportPrefix(t *testing.T) {
f := writeTempEnv(t, "export PORT=8080\nexport HOST=localhost\n") m, err := parseEnvReader(strings.NewReader("export PORT=8080\nexport HOST=localhost\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -52,24 +94,21 @@ func TestParseEnvFile_ExportPrefix(t *testing.T) {
} }
} }
func TestParseEnvFile_SingleQuoted(t *testing.T) { func TestParseEnvReader_SingleQuoted(t *testing.T) {
f := writeTempEnv(t, "KEY='hello world'\nRAW='no\\nescaping'\n") m, err := parseEnvReader(strings.NewReader("KEY='hello world'\nRAW='no\\nescaping'\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if m["KEY"] != "hello world" { if m["KEY"] != "hello world" {
t.Errorf("KEY = %q, want %q", m["KEY"], "hello world") t.Errorf("KEY = %q, want %q", m["KEY"], "hello world")
} }
// Single quotes: backslash-n stays literal.
if m["RAW"] != `no\nescaping` { if m["RAW"] != `no\nescaping` {
t.Errorf("RAW = %q, want %q", m["RAW"], `no\nescaping`) t.Errorf("RAW = %q, want %q", m["RAW"], `no\nescaping`)
} }
} }
func TestParseEnvFile_DoubleQuoted(t *testing.T) { func TestParseEnvReader_DoubleQuoted(t *testing.T) {
f := writeTempEnv(t, `KEY="hello world"`+"\n"+`MSG="line1\nline2"`+"\n") m, err := parseEnvReader(strings.NewReader(`KEY="hello world"` + "\n" + `MSG="line1\nline2"` + "\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -81,9 +120,8 @@ func TestParseEnvFile_DoubleQuoted(t *testing.T) {
} }
} }
func TestParseEnvFile_DoubleQuotedEscapes(t *testing.T) { func TestParseEnvReader_DoubleQuotedEscapes(t *testing.T) {
f := writeTempEnv(t, `KEY="say \"hi\""`+"\n"+`BS="back\\slash"`+"\n"+`CR="a\rb"`+"\n") m, err := parseEnvReader(strings.NewReader(`KEY="say \"hi\""` + "\n" + `BS="back\\slash"` + "\n" + `CR="a\rb"` + "\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -98,9 +136,8 @@ func TestParseEnvFile_DoubleQuotedEscapes(t *testing.T) {
} }
} }
func TestParseEnvFile_InlineComment(t *testing.T) { func TestParseEnvReader_InlineComment(t *testing.T) {
f := writeTempEnv(t, "KEY=value # this is a comment\nNUM=42 # the answer\n") m, err := parseEnvReader(strings.NewReader("KEY=value # this is a comment\nNUM=42 # the answer\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -112,9 +149,8 @@ func TestParseEnvFile_InlineComment(t *testing.T) {
} }
} }
func TestParseEnvFile_NoEqualsSign(t *testing.T) { func TestParseEnvReader_NoEqualsSign(t *testing.T) {
f := writeTempEnv(t, "NOEQUALS\nKEY=value\n") m, err := parseEnvReader(strings.NewReader("NOEQUALS\nKEY=value\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -126,9 +162,8 @@ func TestParseEnvFile_NoEqualsSign(t *testing.T) {
} }
} }
func TestParseEnvFile_EmptyValue(t *testing.T) { func TestParseEnvReader_EmptyValue(t *testing.T) {
f := writeTempEnv(t, "KEY=\n") m, err := parseEnvReader(strings.NewReader("KEY=\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -137,9 +172,8 @@ func TestParseEnvFile_EmptyValue(t *testing.T) {
} }
} }
func TestParseEnvFile_ValueWithEquals(t *testing.T) { func TestParseEnvReader_ValueWithEquals(t *testing.T) {
f := writeTempEnv(t, "URL=postgres://host:5432/db?opt=val\n") m, err := parseEnvReader(strings.NewReader("URL=postgres://host:5432/db?opt=val\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -148,19 +182,8 @@ func TestParseEnvFile_ValueWithEquals(t *testing.T) {
} }
} }
func TestParseEnvFile_FileNotFound(t *testing.T) { func TestParseEnvReader_WhitespaceAroundKeyValue(t *testing.T) {
_, err := parseEnvFile("/nonexistent/.env") m, err := parseEnvReader(strings.NewReader(" KEY = value \n"))
if err == nil {
t.Fatal("expected error for missing file")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.IsNotExist, got: %v", err)
}
}
func TestParseEnvFile_WhitespaceAroundKeyValue(t *testing.T) {
f := writeTempEnv(t, " KEY = value \n")
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -169,32 +192,9 @@ func TestParseEnvFile_WhitespaceAroundKeyValue(t *testing.T) {
} }
} }
func TestLoadEnvFile_DoesNotOverwriteExisting(t *testing.T) { func TestParseEnvReader_ShortValue(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("TEST_EXISTING=fromfile\nTEST_NEW=fromfile\n"), 0644); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
t.Setenv("TEST_EXISTING", "fromshell")
unsetEnv(t, "TEST_NEW")
if err := loadEnvFile(context.Background()); err != nil {
t.Fatalf("loadEnvFile: %v", err)
}
if got := os.Getenv("TEST_EXISTING"); got != "fromshell" {
t.Errorf("TEST_EXISTING = %q, want %q (should not overwrite)", got, "fromshell")
}
if got := os.Getenv("TEST_NEW"); got != "fromfile" {
t.Errorf("TEST_NEW = %q, want %q", got, "fromfile")
}
}
func TestParseEnvFile_ShortValue(t *testing.T) {
// Single-character value exercises the len(value) < 2 short-value branch. // Single-character value exercises the len(value) < 2 short-value branch.
f := writeTempEnv(t, "A=x\nB=y\n") m, err := parseEnvReader(strings.NewReader("A=x\nB=y\n"))
m, err := parseEnvFile(f)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -206,121 +206,144 @@ func TestParseEnvFile_ShortValue(t *testing.T) {
} }
} }
func TestSetEnvDefault_SetsWhenEmpty(t *testing.T) { func TestParseEnvFile_FileNotFound(t *testing.T) {
const key = "TEST_SET_ENV_DEFAULT_EMPTY" withFakeOpen(t, "", os.ErrNotExist)
// t.Setenv both guards against t.Parallel and auto-restores the _, err := parseEnvFile(".env")
// original value on cleanup. setEnvDefault treats "" as unset. if !errors.Is(err, os.ErrNotExist) {
t.Setenv(key, "") t.Errorf("expected os.ErrNotExist, got: %v", err)
setEnvDefault(key, "defaultVal")
if got := os.Getenv(key); got != "defaultVal" {
t.Errorf("%s = %q, want %q", key, got, "defaultVal")
} }
} }
func TestSetEnvDefault_PreservesExisting(t *testing.T) { func TestParseEnvFile_OpenError(t *testing.T) {
const key = "TEST_SET_ENV_DEFAULT_EXISTING" // A non-NotExist open error should bubble up as-is.
t.Setenv(key, "original") sentinel := errors.New("boom")
setEnvDefault(key, "shouldNotApply") withFakeOpen(t, "", sentinel)
if got := os.Getenv(key); got != "original" { _, err := parseEnvFile(".env")
t.Errorf("%s = %q, want %q", key, got, "original") if !errors.Is(err, sentinel) {
t.Errorf("expected sentinel error, got: %v", err)
}
}
func TestParseEnvFile_DelegatesToReader(t *testing.T) {
withFakeOpen(t, "FOO=bar\n", nil)
m, err := parseEnvFile(".env")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m["FOO"] != "bar" {
t.Errorf("FOO = %q, want %q", m["FOO"], "bar")
}
}
func TestLoadEnvFile_DoesNotOverwriteExisting(t *testing.T) {
fe := withFakeEnv(t)
fe.store["TEST_EXISTING"] = "fromshell"
// TEST_NEW is absent from the store, so it should be loaded from the file.
withFakeOpen(t, "TEST_EXISTING=fromfile\nTEST_NEW=fromfile\n", nil)
if err := loadEnvFile(context.Background()); err != nil {
t.Fatalf("loadEnvFile: %v", err)
}
if got := fe.store["TEST_EXISTING"]; got != "fromshell" {
t.Errorf("TEST_EXISTING = %q, want %q (should not overwrite)", got, "fromshell")
}
if got := fe.store["TEST_NEW"]; got != "fromfile" {
t.Errorf("TEST_NEW = %q, want %q", got, "fromfile")
} }
} }
func TestLoadEnvFile_NoFileIsNoError(t *testing.T) { func TestLoadEnvFile_NoFileIsNoError(t *testing.T) {
// Run in a temp dir with no .env present. withFakeEnv(t)
t.Chdir(t.TempDir()) withFakeOpen(t, "", os.ErrNotExist)
if err := loadEnvFile(context.Background()); err != nil { if err := loadEnvFile(context.Background()); err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
} }
} }
func TestLoadEnvFile_AppliesFromFile(t *testing.T) { func TestLoadEnvFile_OpenErrorIsWrapped(t *testing.T) {
const key = "TEST_LOAD_ENV_FILE_APPLIES" withFakeEnv(t)
unsetEnv(t, key) sentinel := errors.New("disk gone")
withFakeOpen(t, "", sentinel)
dir := t.TempDir() err := loadEnvFile(context.Background())
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(key+"=loaded\n"), 0644); err != nil { if err == nil {
t.Fatalf("write .env: %v", err) t.Fatal("expected error, got nil")
} }
t.Chdir(dir) if srserrors.Cause(err) != sentinel {
t.Errorf("expected wrapped sentinel, got: %v", err)
}
}
func TestLoadEnvFile_AppliesFromFile(t *testing.T) {
fe := withFakeEnv(t)
withFakeOpen(t, "TEST_LOAD_ENV_FILE_APPLIES=loaded\n", nil)
if err := loadEnvFile(context.Background()); err != nil { if err := loadEnvFile(context.Background()); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if got := os.Getenv(key); got != "loaded" { if got := fe.store["TEST_LOAD_ENV_FILE_APPLIES"]; got != "loaded" {
t.Errorf("%s = %q, want %q", key, got, "loaded") t.Errorf("got %q, want %q", got, "loaded")
}
}
func TestSetEnvDefault_SetsWhenEmpty(t *testing.T) {
fe := withFakeEnv(t)
// Key is absent (getEnv returns ""), so the default should apply.
setEnvDefault("KEY", "defaultVal")
if got := fe.store["KEY"]; got != "defaultVal" {
t.Errorf("KEY = %q, want %q", got, "defaultVal")
}
}
func TestSetEnvDefault_PreservesExisting(t *testing.T) {
fe := withFakeEnv(t)
fe.store["KEY"] = "original"
setEnvDefault("KEY", "shouldNotApply")
if got := fe.store["KEY"]; got != "original" {
t.Errorf("KEY = %q, want %q", got, "original")
} }
} }
func TestNewEnvironment_AppliesDefaultsAndAccessors(t *testing.T) { func TestNewEnvironment_AppliesDefaultsAndAccessors(t *testing.T) {
// Work in a clean dir so no stray .env is picked up. withFakeEnv(t)
t.Chdir(t.TempDir()) // No .env file present.
withFakeOpen(t, "", os.ErrNotExist)
// Keys with a default in buildDefaultEnvironmentVariables. // PROXY_DEFAULT_BACKEND_HTTP has no default in buildDefaultEnvironmentVariables;
defaults := map[string]string{ // pre-set it so the accessor has a value to return.
"GO_PPROF": "", setEnv("PROXY_DEFAULT_BACKEND_HTTP", "8080")
"PROXY_FORCE_QUIT_TIMEOUT": "30s",
"PROXY_GRACE_QUIT_TIMEOUT": "20s",
"PROXY_HTTP_API": "11985",
"PROXY_HTTP_SERVER": "18080",
"PROXY_RTMP_SERVER": "11935",
"PROXY_WEBRTC_SERVER": "18000",
"PROXY_SRT_SERVER": "20080",
"PROXY_SYSTEM_API": "12025",
"PROXY_STATIC_FILES": "./trunk/research",
"PROXY_LOAD_BALANCER_TYPE": "memory",
"PROXY_REDIS_HOST": "127.0.0.1",
"PROXY_REDIS_PORT": "6379",
"PROXY_REDIS_PASSWORD": "",
"PROXY_REDIS_DB": "0",
"PROXY_DEFAULT_BACKEND_ENABLED": "off",
"PROXY_DEFAULT_BACKEND_IP": "127.0.0.1",
"PROXY_DEFAULT_BACKEND_RTMP": "1935",
"PROXY_DEFAULT_BACKEND_API": "1985",
"PROXY_DEFAULT_BACKEND_RTC": "8000",
"PROXY_DEFAULT_BACKEND_SRT": "10080",
}
// Clear defaults so setEnvDefault actually applies them.
for k := range defaults {
t.Setenv(k, "")
}
// PROXY_DEFAULT_BACKEND_HTTP has no default; set it explicitly so the
// accessor has a value to return.
t.Setenv("PROXY_DEFAULT_BACKEND_HTTP", "8080")
env, err := NewEnvironment(context.Background()) env, err := NewEnvironment(context.Background())
if err != nil { if err != nil {
t.Fatalf("NewEnvironment: %v", err) t.Fatalf("NewEnvironment: %v", err)
} }
// Verify every accessor returns the expected value.
cases := []struct { cases := []struct {
name string name string
got string got string
want string want string
}{ }{
{"GoPprof", env.GoPprof(), defaults["GO_PPROF"]}, {"GoPprof", env.GoPprof(), ""},
{"GraceQuitTimeout", env.GraceQuitTimeout(), defaults["PROXY_GRACE_QUIT_TIMEOUT"]}, {"GraceQuitTimeout", env.GraceQuitTimeout(), "20s"},
{"ForceQuitTimeout", env.ForceQuitTimeout(), defaults["PROXY_FORCE_QUIT_TIMEOUT"]}, {"ForceQuitTimeout", env.ForceQuitTimeout(), "30s"},
{"HttpAPI", env.HttpAPI(), defaults["PROXY_HTTP_API"]}, {"HttpAPI", env.HttpAPI(), "11985"},
{"HttpServer", env.HttpServer(), defaults["PROXY_HTTP_SERVER"]}, {"HttpServer", env.HttpServer(), "18080"},
{"RtmpServer", env.RtmpServer(), defaults["PROXY_RTMP_SERVER"]}, {"RtmpServer", env.RtmpServer(), "11935"},
{"WebRTCServer", env.WebRTCServer(), defaults["PROXY_WEBRTC_SERVER"]}, {"WebRTCServer", env.WebRTCServer(), "18000"},
{"SRTServer", env.SRTServer(), defaults["PROXY_SRT_SERVER"]}, {"SRTServer", env.SRTServer(), "20080"},
{"SystemAPI", env.SystemAPI(), defaults["PROXY_SYSTEM_API"]}, {"SystemAPI", env.SystemAPI(), "12025"},
{"StaticFiles", env.StaticFiles(), defaults["PROXY_STATIC_FILES"]}, {"StaticFiles", env.StaticFiles(), "./trunk/research"},
{"LoadBalancerType", env.LoadBalancerType(), defaults["PROXY_LOAD_BALANCER_TYPE"]}, {"LoadBalancerType", env.LoadBalancerType(), "memory"},
{"RedisHost", env.RedisHost(), defaults["PROXY_REDIS_HOST"]}, {"RedisHost", env.RedisHost(), "127.0.0.1"},
{"RedisPort", env.RedisPort(), defaults["PROXY_REDIS_PORT"]}, {"RedisPort", env.RedisPort(), "6379"},
{"RedisPassword", env.RedisPassword(), defaults["PROXY_REDIS_PASSWORD"]}, {"RedisPassword", env.RedisPassword(), ""},
{"RedisDB", env.RedisDB(), defaults["PROXY_REDIS_DB"]}, {"RedisDB", env.RedisDB(), "0"},
{"DefaultBackendEnabled", env.DefaultBackendEnabled(), defaults["PROXY_DEFAULT_BACKEND_ENABLED"]}, {"DefaultBackendEnabled", env.DefaultBackendEnabled(), "off"},
{"DefaultBackendIP", env.DefaultBackendIP(), defaults["PROXY_DEFAULT_BACKEND_IP"]}, {"DefaultBackendIP", env.DefaultBackendIP(), "127.0.0.1"},
{"DefaultBackendRTMP", env.DefaultBackendRTMP(), defaults["PROXY_DEFAULT_BACKEND_RTMP"]}, {"DefaultBackendRTMP", env.DefaultBackendRTMP(), "1935"},
{"DefaultBackendHttp", env.DefaultBackendHttp(), "8080"}, {"DefaultBackendHttp", env.DefaultBackendHttp(), "8080"},
{"DefaultBackendAPI", env.DefaultBackendAPI(), defaults["PROXY_DEFAULT_BACKEND_API"]}, {"DefaultBackendAPI", env.DefaultBackendAPI(), "1985"},
{"DefaultBackendRTC", env.DefaultBackendRTC(), defaults["PROXY_DEFAULT_BACKEND_RTC"]}, {"DefaultBackendRTC", env.DefaultBackendRTC(), "8000"},
{"DefaultBackendSRT", env.DefaultBackendSRT(), defaults["PROXY_DEFAULT_BACKEND_SRT"]}, {"DefaultBackendSRT", env.DefaultBackendSRT(), "10080"},
} }
for _, c := range cases { for _, c := range cases {
if c.got != c.want { if c.got != c.want {
@ -330,9 +353,9 @@ func TestNewEnvironment_AppliesDefaultsAndAccessors(t *testing.T) {
} }
func TestNewEnvironment_PreservesPreSetValues(t *testing.T) { func TestNewEnvironment_PreservesPreSetValues(t *testing.T) {
// When a var is already set, setEnvDefault must not overwrite it. withFakeEnv(t)
t.Chdir(t.TempDir()) withFakeOpen(t, "", os.ErrNotExist)
t.Setenv("PROXY_HTTP_API", "9999") setEnv("PROXY_HTTP_API", "9999")
env, err := NewEnvironment(context.Background()) env, err := NewEnvironment(context.Background())
if err != nil { if err != nil {
@ -343,31 +366,13 @@ func TestNewEnvironment_PreservesPreSetValues(t *testing.T) {
} }
} }
// unsetEnv removes key for the duration of the test and restores its func TestNewEnvironment_LoadEnvFailurePropagates(t *testing.T) {
// original value (or unset state) on cleanup. It also calls t.Setenv on withFakeEnv(t)
// a sentinel so the test will panic if anyone adds t.Parallel() — env sentinel := errors.New("open failed")
// vars are process-global and cannot be mutated safely in parallel. withFakeOpen(t, "", sentinel)
func unsetEnv(t *testing.T, key string) {
t.Helper()
t.Setenv("_ENV_TEST_NO_PARALLEL", "1")
prev, had := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() {
if had {
os.Setenv(key, prev)
} else {
os.Unsetenv(key)
}
})
}
// writeTempEnv writes content to a temp .env file and returns the path. _, err := NewEnvironment(context.Background())
func writeTempEnv(t *testing.T, content string) string { if srserrors.Cause(err) != sentinel {
t.Helper() t.Errorf("expected wrapped sentinel, got: %v", err)
dir := t.TempDir()
f := filepath.Join(dir, ".env")
if err := os.WriteFile(f, []byte(content), 0644); err != nil {
t.Fatalf("write temp .env: %v", err)
} }
return f
} }