Log messages are lower-case event names with their details as key/value
attributes ("error" for errors), in place of formatted strings.
Loggable package take a WithLogger option as part of their "New*" call,
defaulting to slog.Default(). Each tags its lines with its `component`.
Some debug loggers print directly rather than using slog when in
terminal logging mode.
258 lines
7.8 KiB
Go
258 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)
|
|
}
|
|
}
|
|
}
|