Short and single-letter names in application code become full words (tests keep theirs). Wrapped calls, signatures and composite literals put one item per line, and one-line function bodies are expanded. Exported names and groups of constants get doc comments, and comments refer to the renamed identifiers.
264 lines
7.8 KiB
Go
264 lines
7.8 KiB
Go
package config
|
|
|
|
import (
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// initDir makes a temp working directory holding a .env file (if env is not
|
|
// nil) and a config.yaml (if config is not empty), and returns its path.
|
|
func initDir(t *testing.T, env []string, config string) string {
|
|
t.Helper()
|
|
resetViper(t)
|
|
dir := t.TempDir()
|
|
t.Chdir(dir)
|
|
if env != nil {
|
|
// godotenv sets variables without cleaning up after the test.
|
|
for _, kv := range env {
|
|
key, _, _ := strings.Cut(kv, "=")
|
|
t.Cleanup(func() {
|
|
os.Unsetenv(key)
|
|
})
|
|
}
|
|
writeFile(t, filepath.Join(dir, ".env"), strings.Join(env, "\n")+"\n")
|
|
}
|
|
if config != "" {
|
|
writeFile(t, filepath.Join(dir, "config.yaml"), config)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, contents string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
const testConfigYAML = `logging:
|
|
level: info
|
|
sources:
|
|
fts_mock:
|
|
enabled: true
|
|
interval: 30s
|
|
`
|
|
|
|
func TestInitLoadsEnvAndConfigFile(t *testing.T) {
|
|
initDir(t, []string{"JOBSEARCH_TEST_ENV=from-dotenv"}, testConfigYAML)
|
|
captureLogs(t, slog.LevelError)
|
|
|
|
var conf JobSearchConfig
|
|
if err := Init("", &conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := os.Getenv("JOBSEARCH_TEST_ENV"); got != "from-dotenv" {
|
|
t.Errorf("JOBSEARCH_TEST_ENV = %q; want it loaded from .env", got)
|
|
}
|
|
if conf.Logging.Level != "info" {
|
|
t.Errorf("Logging.Level = %q; want info", conf.Logging.Level)
|
|
}
|
|
if !conf.Sources.FTSMock.Enabled || conf.Sources.FTSMock.Interval != 30*time.Second {
|
|
t.Errorf("FTSMock = %+v; want enabled every 30s", conf.Sources.FTSMock)
|
|
}
|
|
// Keys missing from the file keep their defaults.
|
|
if conf.Sources.FindATender.Interval != 10*time.Minute {
|
|
t.Errorf("FindATender.Interval = %v; want default 10m", conf.Sources.FindATender.Interval)
|
|
}
|
|
}
|
|
|
|
func TestInitExplicitConfigFile(t *testing.T) {
|
|
dir := initDir(t, []string{}, "logging:\n level: info\n")
|
|
other := filepath.Join(dir, "other.yaml")
|
|
writeFile(t, other, "logging:\n level: error\n")
|
|
captureLogs(t, slog.LevelError)
|
|
|
|
var conf JobSearchConfig
|
|
if err := Init(other, &conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if conf.Logging.Level != "error" {
|
|
t.Errorf("Logging.Level = %q; want error from %s", conf.Logging.Level, other)
|
|
}
|
|
}
|
|
|
|
func TestInitNoConfigFile(t *testing.T) {
|
|
initDir(t, []string{}, "")
|
|
logs := captureLogs(t, slog.LevelWarn)
|
|
|
|
var conf JobSearchConfig
|
|
if err := Init("", &conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(logs.String(), "no config file found") {
|
|
t.Errorf("no warning logged: %s", logs)
|
|
}
|
|
if conf.Logging.Level != "warn" {
|
|
t.Errorf("Logging.Level = %q; want default warn", conf.Logging.Level)
|
|
}
|
|
}
|
|
|
|
// The -debug and -quiet levels set before Init win over the config file.
|
|
func TestInitKeepsCommandLineLevel(t *testing.T) {
|
|
for _, level := range []string{"debug", "error"} {
|
|
t.Run(level, func(t *testing.T) {
|
|
initDir(t, []string{}, "logging:\n level: info\n")
|
|
logs := captureLogs(t, slog.LevelDebug)
|
|
|
|
conf := JobSearchConfig{
|
|
Logging: JSCLogging{
|
|
Level: level,
|
|
},
|
|
}
|
|
if err := Init("", &conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if conf.Logging.Level != level {
|
|
t.Errorf("Logging.Level = %q; want %s kept", conf.Logging.Level, level)
|
|
}
|
|
// At debug, the environment and loaded config are logged.
|
|
gotDebug := strings.Contains(logs.String(), "loaded config value")
|
|
if gotDebug != (level == "debug") {
|
|
t.Errorf("debug output logged = %v at level %s: %s", gotDebug, level, logs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInitErrors(t *testing.T) {
|
|
t.Run("no .env file", func(t *testing.T) {
|
|
initDir(t, nil, testConfigYAML)
|
|
var conf JobSearchConfig
|
|
if err := Init("", &conf); err == nil || !strings.Contains(err.Error(), ".env") {
|
|
t.Fatalf("err = %v; want an invalid .env file error", err)
|
|
}
|
|
})
|
|
t.Run("invalid config file", func(t *testing.T) {
|
|
initDir(t, []string{}, "logging: [unclosed\n")
|
|
captureLogs(t, slog.LevelError)
|
|
var conf JobSearchConfig
|
|
if err := Init("", &conf); err == nil {
|
|
t.Fatal("expected an error for invalid YAML")
|
|
}
|
|
})
|
|
t.Run("missing explicit config file", func(t *testing.T) {
|
|
dir := initDir(t, []string{}, "")
|
|
captureLogs(t, slog.LevelError)
|
|
var conf JobSearchConfig
|
|
if err := Init(filepath.Join(dir, "missing.yaml"), &conf); err == nil {
|
|
t.Fatal("expected an error for a missing config file")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Environment variables override the config file, with nested keys joined
|
|
// by underscores.
|
|
func TestInitEnvOverride(t *testing.T) {
|
|
initDir(t, []string{}, testConfigYAML)
|
|
captureLogs(t, slog.LevelError)
|
|
t.Setenv("JOBSEARCH_LOGGING_LEVEL", "error")
|
|
t.Setenv("JOBSEARCH_SOURCES_FTS_MOCK_INTERVAL", "45s")
|
|
|
|
var conf JobSearchConfig
|
|
if err := Init("", &conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if conf.Logging.Level != "error" {
|
|
t.Errorf("Logging.Level = %q; want error from the environment", conf.Logging.Level)
|
|
}
|
|
if conf.Sources.FTSMock.Interval != 45*time.Second {
|
|
t.Errorf("FTSMock.Interval = %v; want 45s from the environment", conf.Sources.FTSMock.Interval)
|
|
}
|
|
}
|
|
|
|
func TestEnvVarName(t *testing.T) {
|
|
tests := map[string]string{
|
|
"logging.level": "JOBSEARCH_LOGGING_LEVEL",
|
|
"sources.fts_mock.interval": "JOBSEARCH_SOURCES_FTS_MOCK_INTERVAL",
|
|
"server.websocket.connection_limit.enabled": "JOBSEARCH_SERVER_WEBSOCKET_CONNECTION_LIMIT_ENABLED",
|
|
}
|
|
for key, want := range tests {
|
|
if got := envVarName(key); got != want {
|
|
t.Errorf("envVarName(%q) = %q; want %q", key, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// explicitConfigYAML sets a top-level and a nested key in a config file.
|
|
const explicitConfigYAML = `logging:
|
|
level: info
|
|
server:
|
|
websocket:
|
|
connection_limit:
|
|
enabled: true
|
|
`
|
|
|
|
// loadExplicit sets up Viper as Init does, with the defaults, the config
|
|
// file explicitConfigYAML and the JOBSEARCH_* environment variables.
|
|
func loadExplicit(t *testing.T) {
|
|
t.Helper()
|
|
resetViper(t)
|
|
LoadDefaultConfig()
|
|
viper.SetEnvPrefix(EnvPrefix)
|
|
viper.SetEnvKeyReplacer(envKeyReplacer)
|
|
viper.AutomaticEnv()
|
|
viper.SetConfigType("yaml")
|
|
if err := viper.ReadConfig(strings.NewReader(explicitConfigYAML)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// A key is explicitly set by the config file or a non-empty environment
|
|
// variable, not by its default or viper.Set.
|
|
func TestIsExplicitlySet(t *testing.T) {
|
|
t.Setenv("JOBSEARCH_DATABASE_FILE", "/tmp/js.db")
|
|
t.Setenv("JOBSEARCH_LOGGING_FILE", "")
|
|
loadExplicit(t)
|
|
viper.Set("sources.sleeper.enabled", true)
|
|
|
|
tests := map[string]bool{
|
|
"logging.level": true, // config file
|
|
"server.websocket.connection_limit.enabled": true, // nested, config file
|
|
"database.file": true, // environment
|
|
"logging.file": false, // empty environment variable
|
|
"sources.sleeper.interval": false, // default only
|
|
"sources.sleeper.enabled": false, // viper.Set
|
|
"server.websocket.connection_limit.burst": false, // default, beside a set key
|
|
}
|
|
for key, want := range tests {
|
|
if got := isExplicitlySet(key); got != want {
|
|
t.Errorf("isExplicitlySet(%q) = %v; want %v", key, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// printLoadedConfig logs only the keys set by the config file or the
|
|
// environment.
|
|
func TestPrintLoadedConfig(t *testing.T) {
|
|
t.Setenv("JOBSEARCH_DATABASE_FILE", "/tmp/js.db")
|
|
loadExplicit(t)
|
|
logs := captureLogs(t, slog.LevelDebug)
|
|
printLoadedConfig()
|
|
|
|
out := logs.String()
|
|
for _, want := range []string{
|
|
`key=logging.level value=info type=string`,
|
|
`key=server.websocket.connection_limit.enabled value=true type=bool`,
|
|
`key=database.file value=/tmp/js.db type=string`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("missing %q in:\n%s", want, out)
|
|
}
|
|
}
|
|
for _, unwanted := range []string{"sources.", "logging.file", "connection_limit.burst"} {
|
|
if strings.Contains(out, unwanted) {
|
|
t.Errorf("logged default key %q:\n%s", unwanted, out)
|
|
}
|
|
}
|
|
}
|