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:
parent
460412c4b5
commit
cd11a6720f
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -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
9
go.mod
|
|
@ -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
12
go.sum
|
|
@ -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
103
internal/env/env.go
vendored
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
321
internal/env/env_test.go
vendored
321
internal/env/env_test.go
vendored
|
|
@ -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
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
6
internal/env/gen.go
vendored
Normal 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
|
||||
|
|
@ -15,7 +15,7 @@ func VersionMinor() int {
|
|||
}
|
||||
|
||||
func VersionRevision() int {
|
||||
return 143
|
||||
return 144
|
||||
}
|
||||
|
||||
func Version() string {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@
|
|||
|
||||
#define VERSION_MAJOR 7
|
||||
#define VERSION_MINOR 0
|
||||
#define VERSION_REVISION 143
|
||||
#define VERSION_REVISION 144
|
||||
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user