Proxy: Harden internal/env tests and add counterfeiter fakes. v7.0.144 (#4665)

- **Refactor `internal/env` for testability.** Route every
`os`/filesystem
  call in `env.go` through swappable package-level function variables
(`getEnv`, `setEnv`, `lookupEnv`, `openFile`). Split `parseEnvFile` into
a
thin file-opening wrapper plus a pure `parseEnvReader(io.Reader)` so the
  parser can be tested directly without touching disk.
- **Hermetic tests, 96.9% coverage.** Rewrite `internal/env/env_test.go`
to
install in-memory fakes via `withFakeEnv` / `withFakeOpen` helpers that
  swap the package vars and restore them on `t.Cleanup`. Tests no longer
mutate real process env or write temp `.env` files, removing a source of
flakiness under parallel test execution. New cases cover
`NewEnvironment`,
`setEnvDefault`, `loadEnvFile` error paths, and edge cases in the
parser.
- **Counterfeiter-based fake generation.** Add `counterfeiter` as a Go
tool
dependency, a `//go:generate` directive for the `Environment` interface
  (`internal/env/gen.go`), and commit the generated
  `internal/env/envfakes/fake_environment.go` so downstream packages can
test against a spec-faithful fake instead of hand-rolling stubs. Expose
  the step as `make generate`.
- **Tooling.** `scripts/proxy-utest.sh` gains a `--coverage` / `-c` flag
  that runs `go test -coverprofile=...` across `./cmd/...` and
  `./internal/...` and prints per-function coverage via `go tool cover
  -func`. The `srs-develop` skill doc is updated to include the
  regenerate-fakes step and the new coverage flag.
- **Go version.** Bump `go.mod` to Go 1.25 (required for the `go tool`
  directive used to pin the counterfeiter CLI as a tool dep).

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Winlin 2026-04-18 20:33:07 -04:00 committed by GitHub
parent 460412c4b5
commit cd11a6720f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1796 additions and 139 deletions

View File

@ -105,15 +105,19 @@ Only after the user confirms the routing do you proceed to Step 2.
**Step 3: Implement and Verify**
1. Implement the code change.
2. Run the proxy unit tests to verify:
2. If you changed or added a Go interface with a `//go:generate go tool counterfeiter ...` directive, regenerate fakes:
```
bash scripts/proxy-utest.sh
make generate
```
3. Run the proxy E2E test (starts proxy + SRS origin, publishes RTMP, verifies playback):
3. Run the proxy unit tests to verify:
```
bash scripts/proxy-utest.sh --coverage
```
4. Run the proxy E2E test (starts proxy + SRS origin, publishes RTMP, verifies playback):
```
bash scripts/proxy-e2e-test.sh
```
4. If any tests fail, fix the issues and re-run until all tests pass.
5. If any tests fail, fix the issues and re-run until all tests pass.
All script paths are relative to this skill's directory.

View File

@ -2,6 +2,30 @@
# Run unit tests for the proxy server (cmd/ and internal/ packages).
set -e
usage() {
cat <<'EOF'
Run unit tests for the proxy server (cmd/ and internal/ packages).
Usage:
proxy-utest.sh # run tests
proxy-utest.sh --coverage # run tests and print per-function coverage
proxy-utest.sh -c # short form of --coverage
EOF
}
COVERAGE=0
for arg in "$@"; do
case "$arg" in
-c|--coverage) COVERAGE=1 ;;
-h|--help) usage; exit 0 ;;
*)
echo "Error: unknown argument: $arg" >&2
usage >&2
exit 2
;;
esac
done
SCRIPT_DIR="$(cd -P "$(dirname "$0")" && pwd)"
# Navigate: scripts/ -> srs-develop/ -> skills/ -> .openclaw/ -> srs
WORKSPACE="$(cd -P "$SCRIPT_DIR/../../../.." && pwd)"
@ -12,6 +36,18 @@ if [[ ! -f "$WORKSPACE/go.mod" ]]; then
fi
cd "$WORKSPACE"
echo "Running proxy unit tests in: $WORKSPACE"
go test ./cmd/... ./internal/... -v
PACKAGES=(./cmd/... ./internal/...)
if [[ $COVERAGE -eq 1 ]]; then
COVER_FILE="$(mktemp -t proxy-utest-coverage.XXXXXX.out)"
trap 'rm -f "$COVER_FILE"' EXIT
echo "Running proxy unit tests with coverage in: $WORKSPACE"
go test "${PACKAGES[@]}" -v -coverprofile="$COVER_FILE" -coverpkg=./cmd/...,./internal/...
echo
echo "=== Coverage (per function) ==="
go tool cover -func="$COVER_FILE"
else
echo "Running proxy unit tests in: $WORKSPACE"
go test "${PACKAGES[@]}" -v
fi

View File

@ -1,9 +1,12 @@
.PHONY: all build test fmt clean run
.PHONY: all build test fmt clean run generate
all: build
build: fmt bin/srs-proxy
generate:
go generate ./...
bin/srs-proxy: cmd/proxy/*.go internal/**/*.go
@mkdir -p bin
go build -o bin/srs-proxy ./cmd/proxy

9
go.mod
View File

@ -1,10 +1,17 @@
module srsx
go 1.24
go 1.25.0
require github.com/go-redis/redis/v8 v8.11.5
require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
tool github.com/maxbrunsfeld/counterfeiter/v6

12
go.sum
View File

@ -6,18 +6,30 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 h1:V23nK2R2B63g2GhygF9zVGpnigmhvoZoH8d0hrZwMGY=
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2/go.mod h1:Mr897yU9FmyKaQDPtRlVKibrjz40XXyOHUfyZBPSyZU=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

103
internal/env/env.go vendored
View File

@ -6,6 +6,7 @@ package env
import (
"bufio"
"context"
"io"
"os"
"strings"
@ -13,6 +14,17 @@ import (
"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.
type Environment interface {
// Go pprof profiling
@ -73,91 +85,91 @@ func NewEnvironment(ctx context.Context) (Environment, error) {
}
func (e *environment) GoPprof() string {
return os.Getenv("GO_PPROF")
return getEnv("GO_PPROF")
}
func (e *environment) GraceQuitTimeout() string {
return os.Getenv("PROXY_GRACE_QUIT_TIMEOUT")
return getEnv("PROXY_GRACE_QUIT_TIMEOUT")
}
func (e *environment) ForceQuitTimeout() string {
return os.Getenv("PROXY_FORCE_QUIT_TIMEOUT")
return getEnv("PROXY_FORCE_QUIT_TIMEOUT")
}
func (e *environment) HttpAPI() string {
return os.Getenv("PROXY_HTTP_API")
return getEnv("PROXY_HTTP_API")
}
func (e *environment) HttpServer() string {
return os.Getenv("PROXY_HTTP_SERVER")
return getEnv("PROXY_HTTP_SERVER")
}
func (e *environment) RtmpServer() string {
return os.Getenv("PROXY_RTMP_SERVER")
return getEnv("PROXY_RTMP_SERVER")
}
func (e *environment) WebRTCServer() string {
return os.Getenv("PROXY_WEBRTC_SERVER")
return getEnv("PROXY_WEBRTC_SERVER")
}
func (e *environment) SRTServer() string {
return os.Getenv("PROXY_SRT_SERVER")
return getEnv("PROXY_SRT_SERVER")
}
func (e *environment) SystemAPI() string {
return os.Getenv("PROXY_SYSTEM_API")
return getEnv("PROXY_SYSTEM_API")
}
func (e *environment) StaticFiles() string {
return os.Getenv("PROXY_STATIC_FILES")
return getEnv("PROXY_STATIC_FILES")
}
func (e *environment) LoadBalancerType() string {
return os.Getenv("PROXY_LOAD_BALANCER_TYPE")
return getEnv("PROXY_LOAD_BALANCER_TYPE")
}
func (e *environment) RedisHost() string {
return os.Getenv("PROXY_REDIS_HOST")
return getEnv("PROXY_REDIS_HOST")
}
func (e *environment) RedisPort() string {
return os.Getenv("PROXY_REDIS_PORT")
return getEnv("PROXY_REDIS_PORT")
}
func (e *environment) RedisPassword() string {
return os.Getenv("PROXY_REDIS_PASSWORD")
return getEnv("PROXY_REDIS_PASSWORD")
}
func (e *environment) RedisDB() string {
return os.Getenv("PROXY_REDIS_DB")
return getEnv("PROXY_REDIS_DB")
}
func (e *environment) DefaultBackendEnabled() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_ENABLED")
return getEnv("PROXY_DEFAULT_BACKEND_ENABLED")
}
func (e *environment) DefaultBackendIP() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_IP")
return getEnv("PROXY_DEFAULT_BACKEND_IP")
}
func (e *environment) DefaultBackendRTMP() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_RTMP")
return getEnv("PROXY_DEFAULT_BACKEND_RTMP")
}
func (e *environment) DefaultBackendHttp() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_HTTP")
return getEnv("PROXY_DEFAULT_BACKEND_HTTP")
}
func (e *environment) DefaultBackendAPI() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_API")
return getEnv("PROXY_DEFAULT_BACKEND_API")
}
func (e *environment) DefaultBackendRTC() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_RTC")
return getEnv("PROXY_DEFAULT_BACKEND_RTC")
}
func (e *environment) DefaultBackendSRT() string {
return os.Getenv("PROXY_DEFAULT_BACKEND_SRT")
return getEnv("PROXY_DEFAULT_BACKEND_SRT")
}
// loadEnvFile loads the environment variables from .env file.
@ -171,16 +183,10 @@ func loadEnvFile(ctx context.Context) error {
return errors.Wrapf(err, "load .env file")
}
// Build a set of existing environment variable keys, so we don't overwrite them.
currentEnv := make(map[string]bool)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
currentEnv[key] = true
}
// Skip keys already set in the environment so we don't overwrite them.
for key, value := range envMap {
if !currentEnv[key] {
os.Setenv(key, value)
if _, ok := lookupEnv(key); !ok {
setEnv(key, value)
}
}
@ -188,16 +194,21 @@ func loadEnvFile(ctx context.Context) error {
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) {
file, err := os.Open(filename)
file, err := openFile(filename)
if err != nil {
return nil, err
}
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)
scanner := bufio.NewScanner(file)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@ -314,22 +325,22 @@ func buildDefaultEnvironmentVariables(ctx context.Context) {
"PROXY_DEFAULT_BACKEND_RTC=%v, PROXY_DEFAULT_BACKEND_SRT=%v, "+
"PROXY_LOAD_BALANCER_TYPE=%v, PROXY_REDIS_HOST=%v, PROXY_REDIS_PORT=%v, "+
"PROXY_REDIS_PASSWORD=%v, PROXY_REDIS_DB=%v",
os.Getenv("GO_PPROF"),
os.Getenv("PROXY_FORCE_QUIT_TIMEOUT"), os.Getenv("PROXY_GRACE_QUIT_TIMEOUT"),
os.Getenv("PROXY_HTTP_API"), os.Getenv("PROXY_HTTP_SERVER"), os.Getenv("PROXY_RTMP_SERVER"),
os.Getenv("PROXY_WEBRTC_SERVER"), os.Getenv("PROXY_SRT_SERVER"),
os.Getenv("PROXY_SYSTEM_API"), os.Getenv("PROXY_STATIC_FILES"), os.Getenv("PROXY_DEFAULT_BACKEND_ENABLED"),
os.Getenv("PROXY_DEFAULT_BACKEND_IP"), os.Getenv("PROXY_DEFAULT_BACKEND_RTMP"),
os.Getenv("PROXY_DEFAULT_BACKEND_HTTP"), os.Getenv("PROXY_DEFAULT_BACKEND_API"),
os.Getenv("PROXY_DEFAULT_BACKEND_RTC"), os.Getenv("PROXY_DEFAULT_BACKEND_SRT"),
os.Getenv("PROXY_LOAD_BALANCER_TYPE"), os.Getenv("PROXY_REDIS_HOST"), os.Getenv("PROXY_REDIS_PORT"),
os.Getenv("PROXY_REDIS_PASSWORD"), os.Getenv("PROXY_REDIS_DB"),
getEnv("GO_PPROF"),
getEnv("PROXY_FORCE_QUIT_TIMEOUT"), getEnv("PROXY_GRACE_QUIT_TIMEOUT"),
getEnv("PROXY_HTTP_API"), getEnv("PROXY_HTTP_SERVER"), getEnv("PROXY_RTMP_SERVER"),
getEnv("PROXY_WEBRTC_SERVER"), getEnv("PROXY_SRT_SERVER"),
getEnv("PROXY_SYSTEM_API"), getEnv("PROXY_STATIC_FILES"), getEnv("PROXY_DEFAULT_BACKEND_ENABLED"),
getEnv("PROXY_DEFAULT_BACKEND_IP"), getEnv("PROXY_DEFAULT_BACKEND_RTMP"),
getEnv("PROXY_DEFAULT_BACKEND_HTTP"), getEnv("PROXY_DEFAULT_BACKEND_API"),
getEnv("PROXY_DEFAULT_BACKEND_RTC"), getEnv("PROXY_DEFAULT_BACKEND_SRT"),
getEnv("PROXY_LOAD_BALANCER_TYPE"), getEnv("PROXY_REDIS_HOST"), getEnv("PROXY_REDIS_PORT"),
getEnv("PROXY_REDIS_PASSWORD"), getEnv("PROXY_REDIS_DB"),
)
}
// setEnvDefault set env key=value if not set.
func setEnvDefault(key, value string) {
if os.Getenv(key) == "" {
os.Setenv(key, value)
if getEnv(key) == "" {
setEnv(key, value)
}
}

View File

@ -4,15 +4,59 @@
package env
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
srserrors "srsx/internal/errors"
)
func TestParseEnvFile_BasicKeyValue(t *testing.T) {
f := writeTempEnv(t, "FOO=bar\nHELLO=world\n")
m, err := parseEnvFile(f)
// fakeEnv is an in-memory replacement for process environment variables.
// Tests install it via withFakeEnv so no real os.Setenv/os.Getenv call is
// 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 {
t.Fatalf("unexpected error: %v", err)
}
@ -24,9 +68,8 @@ func TestParseEnvFile_BasicKeyValue(t *testing.T) {
}
}
func TestParseEnvFile_SkipCommentsAndBlankLines(t *testing.T) {
f := writeTempEnv(t, "# this is a comment\n\nKEY=value\n\n# another comment\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_SkipCommentsAndBlankLines(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("# this is a comment\n\nKEY=value\n\n# another comment\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -38,9 +81,8 @@ func TestParseEnvFile_SkipCommentsAndBlankLines(t *testing.T) {
}
}
func TestParseEnvFile_ExportPrefix(t *testing.T) {
f := writeTempEnv(t, "export PORT=8080\nexport HOST=localhost\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_ExportPrefix(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("export PORT=8080\nexport HOST=localhost\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -52,24 +94,21 @@ func TestParseEnvFile_ExportPrefix(t *testing.T) {
}
}
func TestParseEnvFile_SingleQuoted(t *testing.T) {
f := writeTempEnv(t, "KEY='hello world'\nRAW='no\\nescaping'\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_SingleQuoted(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("KEY='hello world'\nRAW='no\\nescaping'\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if 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` {
t.Errorf("RAW = %q, want %q", m["RAW"], `no\nescaping`)
}
}
func TestParseEnvFile_DoubleQuoted(t *testing.T) {
f := writeTempEnv(t, `KEY="hello world"`+"\n"+`MSG="line1\nline2"`+"\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_DoubleQuoted(t *testing.T) {
m, err := parseEnvReader(strings.NewReader(`KEY="hello world"` + "\n" + `MSG="line1\nline2"` + "\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -81,9 +120,8 @@ func TestParseEnvFile_DoubleQuoted(t *testing.T) {
}
}
func TestParseEnvFile_DoubleQuotedEscapes(t *testing.T) {
f := writeTempEnv(t, `KEY="say \"hi\""`+"\n"+`BS="back\\slash"`+"\n"+`CR="a\rb"`+"\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_DoubleQuotedEscapes(t *testing.T) {
m, err := parseEnvReader(strings.NewReader(`KEY="say \"hi\""` + "\n" + `BS="back\\slash"` + "\n" + `CR="a\rb"` + "\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -98,9 +136,8 @@ func TestParseEnvFile_DoubleQuotedEscapes(t *testing.T) {
}
}
func TestParseEnvFile_InlineComment(t *testing.T) {
f := writeTempEnv(t, "KEY=value # this is a comment\nNUM=42 # the answer\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_InlineComment(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("KEY=value # this is a comment\nNUM=42 # the answer\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -112,9 +149,8 @@ func TestParseEnvFile_InlineComment(t *testing.T) {
}
}
func TestParseEnvFile_NoEqualsSign(t *testing.T) {
f := writeTempEnv(t, "NOEQUALS\nKEY=value\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_NoEqualsSign(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("NOEQUALS\nKEY=value\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -126,9 +162,8 @@ func TestParseEnvFile_NoEqualsSign(t *testing.T) {
}
}
func TestParseEnvFile_EmptyValue(t *testing.T) {
f := writeTempEnv(t, "KEY=\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_EmptyValue(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("KEY=\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -137,9 +172,8 @@ func TestParseEnvFile_EmptyValue(t *testing.T) {
}
}
func TestParseEnvFile_ValueWithEquals(t *testing.T) {
f := writeTempEnv(t, "URL=postgres://host:5432/db?opt=val\n")
m, err := parseEnvFile(f)
func TestParseEnvReader_ValueWithEquals(t *testing.T) {
m, err := parseEnvReader(strings.NewReader("URL=postgres://host:5432/db?opt=val\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -148,19 +182,8 @@ func TestParseEnvFile_ValueWithEquals(t *testing.T) {
}
}
func TestParseEnvFile_FileNotFound(t *testing.T) {
_, err := parseEnvFile("/nonexistent/.env")
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)
func TestParseEnvReader_WhitespaceAroundKeyValue(t *testing.T) {
m, err := parseEnvReader(strings.NewReader(" KEY = value \n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -169,55 +192,187 @@ func TestParseEnvFile_WhitespaceAroundKeyValue(t *testing.T) {
}
}
func TestLoadEnvFile_DoesNotOverwriteExisting(t *testing.T) {
// Write a .env file in a temp dir.
dir := t.TempDir()
envFile := filepath.Join(dir, ".env")
if err := os.WriteFile(envFile, []byte("TEST_EXISTING=fromfile\nTEST_NEW=fromfile\n"), 0644); err != nil {
t.Fatalf("write .env: %v", err)
}
// Pre-set one of the keys in the real environment.
os.Setenv("TEST_EXISTING", "fromshell")
t.Cleanup(func() {
os.Unsetenv("TEST_EXISTING")
os.Unsetenv("TEST_NEW")
})
// Parse and apply, mimicking loadEnvFile logic.
m, err := parseEnvFile(envFile)
func TestParseEnvReader_ShortValue(t *testing.T) {
// Single-character value exercises the len(value) < 2 short-value branch.
m, err := parseEnvReader(strings.NewReader("A=x\nB=y\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
currentEnv := make(map[string]bool)
for _, entry := range os.Environ() {
k, _, _ := strings.Cut(entry, "=")
currentEnv[k] = true
if m["A"] != "x" {
t.Errorf("A = %q, want %q", m["A"], "x")
}
for k, v := range m {
if !currentEnv[k] {
os.Setenv(k, v)
}
if m["B"] != "y" {
t.Errorf("B = %q, want %q", m["B"], "y")
}
}
// Existing key should NOT be overwritten.
if got := os.Getenv("TEST_EXISTING"); got != "fromshell" {
func TestParseEnvFile_FileNotFound(t *testing.T) {
withFakeOpen(t, "", os.ErrNotExist)
_, err := parseEnvFile(".env")
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("expected os.ErrNotExist, got: %v", err)
}
}
func TestParseEnvFile_OpenError(t *testing.T) {
// A non-NotExist open error should bubble up as-is.
sentinel := errors.New("boom")
withFakeOpen(t, "", sentinel)
_, err := parseEnvFile(".env")
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")
}
// New key should be set.
if got := os.Getenv("TEST_NEW"); got != "fromfile" {
if got := fe.store["TEST_NEW"]; got != "fromfile" {
t.Errorf("TEST_NEW = %q, want %q", got, "fromfile")
}
}
// writeTempEnv writes content to a temp .env file and returns the path.
func writeTempEnv(t *testing.T, content string) string {
t.Helper()
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)
func TestLoadEnvFile_NoFileIsNoError(t *testing.T) {
withFakeEnv(t)
withFakeOpen(t, "", os.ErrNotExist)
if err := loadEnvFile(context.Background()); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestLoadEnvFile_OpenErrorIsWrapped(t *testing.T) {
withFakeEnv(t)
sentinel := errors.New("disk gone")
withFakeOpen(t, "", sentinel)
err := loadEnvFile(context.Background())
if err == nil {
t.Fatal("expected error, got nil")
}
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 {
t.Fatalf("unexpected error: %v", err)
}
if got := fe.store["TEST_LOAD_ENV_FILE_APPLIES"]; 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) {
withFakeEnv(t)
// No .env file present.
withFakeOpen(t, "", os.ErrNotExist)
// PROXY_DEFAULT_BACKEND_HTTP has no default in buildDefaultEnvironmentVariables;
// pre-set it so the accessor has a value to return.
setEnv("PROXY_DEFAULT_BACKEND_HTTP", "8080")
env, err := NewEnvironment(context.Background())
if err != nil {
t.Fatalf("NewEnvironment: %v", err)
}
cases := []struct {
name string
got string
want string
}{
{"GoPprof", env.GoPprof(), ""},
{"GraceQuitTimeout", env.GraceQuitTimeout(), "20s"},
{"ForceQuitTimeout", env.ForceQuitTimeout(), "30s"},
{"HttpAPI", env.HttpAPI(), "11985"},
{"HttpServer", env.HttpServer(), "18080"},
{"RtmpServer", env.RtmpServer(), "11935"},
{"WebRTCServer", env.WebRTCServer(), "18000"},
{"SRTServer", env.SRTServer(), "20080"},
{"SystemAPI", env.SystemAPI(), "12025"},
{"StaticFiles", env.StaticFiles(), "./trunk/research"},
{"LoadBalancerType", env.LoadBalancerType(), "memory"},
{"RedisHost", env.RedisHost(), "127.0.0.1"},
{"RedisPort", env.RedisPort(), "6379"},
{"RedisPassword", env.RedisPassword(), ""},
{"RedisDB", env.RedisDB(), "0"},
{"DefaultBackendEnabled", env.DefaultBackendEnabled(), "off"},
{"DefaultBackendIP", env.DefaultBackendIP(), "127.0.0.1"},
{"DefaultBackendRTMP", env.DefaultBackendRTMP(), "1935"},
{"DefaultBackendHttp", env.DefaultBackendHttp(), "8080"},
{"DefaultBackendAPI", env.DefaultBackendAPI(), "1985"},
{"DefaultBackendRTC", env.DefaultBackendRTC(), "8000"},
{"DefaultBackendSRT", env.DefaultBackendSRT(), "10080"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s() = %q, want %q", c.name, c.got, c.want)
}
}
}
func TestNewEnvironment_PreservesPreSetValues(t *testing.T) {
withFakeEnv(t)
withFakeOpen(t, "", os.ErrNotExist)
setEnv("PROXY_HTTP_API", "9999")
env, err := NewEnvironment(context.Background())
if err != nil {
t.Fatalf("NewEnvironment: %v", err)
}
if got := env.HttpAPI(); got != "9999" {
t.Errorf("HttpAPI() = %q, want %q", got, "9999")
}
}
func TestNewEnvironment_LoadEnvFailurePropagates(t *testing.T) {
withFakeEnv(t)
sentinel := errors.New("open failed")
withFakeOpen(t, "", sentinel)
_, err := NewEnvironment(context.Background())
if srserrors.Cause(err) != sentinel {
t.Errorf("expected wrapped sentinel, got: %v", err)
}
return f
}

1422
internal/env/envfakes/fake_environment.go vendored Normal file

File diff suppressed because it is too large Load Diff

6
internal/env/gen.go vendored Normal file
View File

@ -0,0 +1,6 @@
// Copyright (c) 2026 Winlin
//
// SPDX-License-Identifier: MIT
package env
//go:generate go tool counterfeiter -o envfakes/fake_environment.go . Environment

View File

@ -15,7 +15,7 @@ func VersionMinor() int {
}
func VersionRevision() int {
return 143
return 144
}
func Version() string {

View File

@ -7,6 +7,7 @@ The changelog for SRS.
<a name="v7-changes"></a>
## SRS 7.0 Changelog
* v7.0, 2026-04-18, Merge [#4665](https://github.com/ossrs/srs/pull/4665): Proxy: Harden internal/env tests and add counterfeiter fake generation. v7.0.144 (#4665)
* v7.0, 2026-04-12, Merge [#4661](https://github.com/ossrs/srs/pull/4661): Proxy: Move build output to bin/, replace godotenv with custom .env parser, and update docs. v7.0.143 (#4661)
* v7.0, 2026-04-06, Merge [#4657](https://github.com/ossrs/srs/pull/4657): Proxy: Refactor bootstrap for multi-server support and rebrand to SRSX. v7.0.142 (#4657)
* v7.0, 2026-03-26, Merge [#4654](https://github.com/ossrs/srs/pull/4654): OpenClaw: Restructure workspace with symlinks, add codebase map, and rewrite AI docs. v7.0.141 (#4654)

View File

@ -9,6 +9,6 @@
#define VERSION_MAJOR 7
#define VERSION_MINOR 0
#define VERSION_REVISION 143
#define VERSION_REVISION 144
#endif