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:") 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{ `logging.level = \"info\" `, `server.websocket.connection_limit.enabled = true `, `database.file = \"/tmp/js.db\" `, } { 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) } } }