package config import ( "log/slog" "os" "path/filepath" "strings" "testing" "time" ) // 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), 0o644); 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: JobSearchConfigLogging{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) } }