diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..8dadfac --- /dev/null +++ b/internal/config/load_test.go @@ -0,0 +1,169 @@ +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) + } +} diff --git a/internal/config/modify_test.go b/internal/config/modify_test.go new file mode 100644 index 0000000..abba1f7 --- /dev/null +++ b/internal/config/modify_test.go @@ -0,0 +1,163 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/spf13/viper" +) + +// useConfigFile points Viper at a new YAML config file holding contents. +func useConfigFile(t *testing.T, contents string) string { + t.Helper() + resetViper(t) + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + t.Fatal(err) + } + return path +} + +// readConfigFile reads path with a fresh Viper. +func readConfigFile(t *testing.T, path string) *viper.Viper { + t.Helper() + v := viper.New() + v.SetConfigFile(path) + if err := v.ReadInConfig(); err != nil { + t.Fatal(err) + } + return v +} + +func TestStoreValue(t *testing.T) { + tests := []struct { + key, value string + want any + kind reflect.Kind + }{ + {"logging.level", "debug", "debug", reflect.String}, + {"sources.sleeper.enabled", "true", true, reflect.Bool}, + {"sources.fts_mock.api_root", "http://mock", "http://mock", reflect.String}, + {"sources.fts_mock.interval", "30s", "30s", reflect.String}, + } + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + path := useConfigFile(t, "logging:\n level: warn\n") + conf := defaultConfig(t) + + got, kind, err := StoreValue(&conf, tt.key, tt.value) + if err != nil { + t.Fatal(err) + } + if got != tt.want || kind != tt.kind { + t.Errorf("got %#v <%s>; want %#v <%s>", got, kind, tt.want, tt.kind) + } + if v := readConfigFile(t, path).Get(tt.key); v != tt.want { + t.Errorf("file has %s = %#v; want %#v", tt.key, v, tt.want) + } + }) + } +} + +func TestStoreValueKeepsOtherKeys(t *testing.T) { + path := useConfigFile(t, "logging:\n level: warn\n file: /tmp/js.log\n") + conf := defaultConfig(t) + if _, _, err := StoreValue(&conf, "logging.level", "error"); err != nil { + t.Fatal(err) + } + v := readConfigFile(t, path) + if got := v.GetString("logging.file"); got != "/tmp/js.log" { + t.Errorf("logging.file = %q; want it kept", got) + } +} + +func TestStoreValueErrors(t *testing.T) { + tests := []struct { + name, key, value, wantErr string + }{ + {"unknown key", "nope", "x", ""}, + {"section key", "sources", "x", ""}, + {"bad bool", "sources.sleeper.enabled", "maybe", "expected "}, + {"invalid log level", "logging.level", "shouty", "invalid log level"}, + {"bad duration", "sources.fts_mock.interval", "eventually", "duration"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := useConfigFile(t, "logging:\n level: warn\n") + before, _ := os.ReadFile(path) + conf := defaultConfig(t) + + _, _, err := StoreValue(&conf, tt.key, tt.value) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("err = %v; want it to mention %q", err, tt.wantErr) + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Errorf("config file changed:\n%s", after) + } + }) + } +} + +func TestStoreValueNoConfigFile(t *testing.T) { + resetViper(t) + conf := defaultConfig(t) + if _, _, err := StoreValue(&conf, "logging.level", "debug"); err == nil { + t.Fatal("expected an error with no config file to write") + } +} + +func TestPurgeValue(t *testing.T) { + path := useConfigFile(t, "logging:\n level: debug\n file: /tmp/js.log\n") + conf := defaultConfig(t) + + if err := PurgeValue(&conf, "logging.file"); err != nil { + t.Fatal(err) + } + v := readConfigFile(t, path) + if v.IsSet("logging.file") { + t.Errorf("logging.file still set: %v", v.Get("logging.file")) + } + if got := v.GetString("logging.level"); got != "debug" { + t.Errorf("logging.level = %q; want it kept", got) + } +} + +func TestPurgeValueErrors(t *testing.T) { + path := useConfigFile(t, "logging:\n level: debug\n") + before, _ := os.ReadFile(path) + conf := defaultConfig(t) + + if err := PurgeValue(&conf, "nope"); err == nil { + t.Error("expected an error for an unknown key") + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Errorf("config file changed:\n%s", after) + } + + resetViper(t) + if err := PurgeValue(&conf, "logging.level"); err == nil { + t.Error("expected an error with no config file to write") + } +} + +func TestStoreValueUpdatesConf(t *testing.T) { + useConfigFile(t, "logging:\n level: warn\n") + conf := defaultConfig(t) + if _, _, err := StoreValue(&conf, "sources.fts_mock.interval", "30s"); err != nil { + t.Fatal(err) + } + if conf.Sources.FTSMock.Interval != 30*time.Second { + t.Errorf("FTSMock.Interval = %v; want 30s", conf.Sources.FTSMock.Interval) + } +} diff --git a/internal/config/section.go b/internal/config/section.go index 68ef5cf..d3122d6 100644 --- a/internal/config/section.go +++ b/internal/config/section.go @@ -142,6 +142,8 @@ func mapOfValue(section reflect.Value) map[string]any { fv := section.FieldByIndex(f.index) if f.section { m[f.key] = mapOfValue(fv) + } else if f.typ == durationType { + m[f.key] = fv.Interface().(time.Duration).String() } else { m[f.key] = fv.Interface() } diff --git a/internal/config/section_test.go b/internal/config/section_test.go new file mode 100644 index 0000000..0850398 --- /dev/null +++ b/internal/config/section_test.go @@ -0,0 +1,297 @@ +package config + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" +) + +// Test sections exercising every field shape section.go supports. +type testInner struct { + Name string `cfg:"name" default:"inner"` +} + +type testEmbedded struct { + On bool `cfg:"on" default:"true"` +} + +type testSection struct { + testEmbedded + Str string `cfg:"str" default:"hello"` + Int int `cfg:"int" default:"-42"` + Int8 int8 `cfg:"int8"` + Uint uint `cfg:"uint" default:"7"` + Uint16 uint16 `cfg:"uint16"` + Float float64 `cfg:"float" default:"1.5"` + Float32 float32 `cfg:"float32"` + Dur time.Duration `cfg:"dur" default:"90s"` + When time.Time `cfg:"when"` + Slice []string `cfg:"slice"` + Inner testInner `cfg:"inner"` + NoTag string + Skipped string `cfg:"-"` + Empty string `cfg:""` + unexport string `cfg:"unexported"` +} + +type testValidated struct { + Value string `cfg:"value"` +} + +var errRejected = errors.New("rejected") + +func (c *testValidated) validate(key string, value any) error { + if key == "value" && value.(string) == "bad" { + return errRejected + } + return nil +} + +func TestKeysOf(t *testing.T) { + got := keysOf(&testSection{}) + want := []string{"on", "str", "int", "int8", "uint", "uint16", "float", + "float32", "dur", "when", "slice", "inner"} + if !reflect.DeepEqual(got, want) { + t.Errorf("keysOf = %v; want %v", got, want) + } +} + +func TestFieldsOfSkipsUntagged(t *testing.T) { + for _, f := range fieldsOf(reflect.TypeOf(testSection{})) { + switch f.key { + case "", "-", "unexported": + t.Errorf("fieldsOf included field with key %q", f.key) + } + if f.key == "when" && f.section { + t.Error("time.Time field treated as a section") + } + if f.key == "inner" && !f.section { + t.Error("struct field not treated as a section") + } + } +} + +func TestMapOf(t *testing.T) { + var s testSection + if err := applyDefaults(&s); err != nil { + t.Fatalf("applyDefaults: %v", err) + } + m := mapOf(s) + if got := m["str"]; got != "hello" { + t.Errorf("m[str] = %#v", got) + } + if got := m["on"]; got != true { + t.Errorf("m[on] (promoted) = %#v", got) + } + inner, ok := m["inner"].(map[string]any) + if !ok { + t.Fatalf("m[inner] = %#v; want nested map", m["inner"]) + } + if inner["name"] != "inner" { + t.Errorf("m[inner][name] = %#v", inner["name"]) + } + if _, ok := m["inner.name"]; ok { + t.Error("mapOf produced a flattened key") + } + // Pointer and value give the same result. + if !reflect.DeepEqual(m, mapOf(&s)) { + t.Error("mapOf(&s) != mapOf(s)") + } +} + +func TestFlattenOfPrefix(t *testing.T) { + m := flattenOf("p", testSection{}) + for _, k := range []string{"p.str", "p.on", "p.inner.name"} { + if _, ok := m[k]; !ok { + t.Errorf("flattenOf missing %q in %v", k, m) + } + } +} + +func TestStructValuePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("structValue(int) did not panic") + } + }() + structValue(42) +} + +func TestApplyDefaults(t *testing.T) { + var s testSection + if err := applyDefaults(&s); err != nil { + t.Fatalf("applyDefaults: %v", err) + } + want := testSection{ + testEmbedded: testEmbedded{On: true}, + Str: "hello", + Int: -42, + Uint: 7, + Float: 1.5, + Dur: 90 * time.Second, + Inner: testInner{Name: "inner"}, + } + if !reflect.DeepEqual(s, want) { + t.Errorf("applyDefaults = %+v; want %+v", s, want) + } +} + +func TestApplyDefaultsInvalid(t *testing.T) { + var bad struct { + N int `cfg:"n" default:"nope"` + } + err := applyDefaults(&bad) + if err == nil || !strings.Contains(err.Error(), "invalid default for n") { + t.Errorf("applyDefaults = %v; want invalid default error", err) + } + + var badNested struct { + Sub struct { + D time.Duration `cfg:"d" default:"forever"` + } `cfg:"sub"` + } + err = applyDefaults(&badNested) + if err == nil || !strings.Contains(err.Error(), "sub.d") { + t.Errorf("applyDefaults nested = %v; want error naming sub.d", err) + } +} + +func TestTypeOf(t *testing.T) { + tests := []struct { + key string + want reflect.Kind + }{ + {"str", reflect.String}, + {"int", reflect.Int}, + {"uint", reflect.Uint}, + {"float", reflect.Float64}, + {"on", reflect.Bool}, + {"dur", reflect.String}, // durations are read as strings + {"when", reflect.Struct}, + {"slice", reflect.Slice}, + {"inner.name", reflect.String}, + } + for _, tt := range tests { + got, err := typeOf(testSection{}, tt.key) + if err != nil || got != tt.want { + t.Errorf("typeOf(%q) = %v, %v; want %v", tt.key, got, err, tt.want) + } + } +} + +func TestUnknownKeys(t *testing.T) { + keys := []string{ + "missing", + "inner", // section without sub-key + "str.sub", // value with a sub-key + "inner.missing", // unknown nested key + "NoTag", // untagged field + "unexported", + "", + } + var s testSection + for _, key := range keys { + if k, err := typeOf(s, key); !errors.Is(err, errUnknownKey) || k != reflect.Invalid { + t.Errorf("typeOf(%q) = %v, %v; want Invalid, errUnknownKey", key, k, err) + } + if v, err := valueOf(s, key); !errors.Is(err, errUnknownKey) || v != nil { + t.Errorf("valueOf(%q) = %v, %v; want nil, errUnknownKey", key, v, err) + } + if err := setValue(&s, key, "x"); !errors.Is(err, errUnknownKey) { + t.Errorf("setValue(%q) = %v; want errUnknownKey", key, err) + } + } +} + +func TestSetValue(t *testing.T) { + tests := []struct { + key string + value any + want any + }{ + // strings are parsed + {"str", "world", "world"}, + {"on", "false", false}, + {"int", "123", 123}, + {"int8", "-128", int8(-128)}, + {"uint", "9", uint(9)}, + {"uint16", "65535", uint16(65535)}, + {"float", "2.25", 2.25}, + {"float32", "0.5", float32(0.5)}, + {"dur", "1h30m", 90 * time.Minute}, + {"inner.name", "nested", "nested"}, + // values of the field's type are used as-is + {"int", 5, 5}, + {"on", true, true}, + {"dur", 3 * time.Second, 3 * time.Second}, + {"slice", []string{"a", "b"}, []string{"a", "b"}}, + {"when", time.Unix(0, 0).UTC(), time.Unix(0, 0).UTC()}, + } + for _, tt := range tests { + var s testSection + if err := setValue(&s, tt.key, tt.value); err != nil { + t.Errorf("setValue(%q, %#v): %v", tt.key, tt.value, err) + continue + } + got, err := valueOf(s, tt.key) + if err != nil || !reflect.DeepEqual(got, tt.want) { + t.Errorf("after setValue(%q, %#v): valueOf = %#v, %v; want %#v", + tt.key, tt.value, got, err, tt.want) + } + } +} + +func TestSetValueErrors(t *testing.T) { + tests := []struct { + key string + value any + want string + }{ + {"int", "abc", "unable to convert"}, + {"int8", "200", "unable to convert"}, // out of range + {"uint", "-1", "unable to convert"}, + {"float", "x", "unable to convert"}, + {"on", "maybe", "unable to convert"}, + {"dur", "soon", "unable to convert to duration"}, + {"slice", "a,b", "unsupported type string"}, + {"when", "2024-01-01", "unsupported type string"}, + {"int", 1.5, "unsupported type float64"}, + {"int", int64(1), "unsupported type int64"}, + {"str", nil, "unsupported type "}, + } + for _, tt := range tests { + var s testSection + before := s + err := setValue(&s, tt.key, tt.value) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("setValue(%q, %#v) = %v; want error containing %q", + tt.key, tt.value, err, tt.want) + } + if !reflect.DeepEqual(s, before) { + t.Errorf("setValue(%q, %#v) modified the section on error", tt.key, tt.value) + } + } +} + +func TestSetValueValidator(t *testing.T) { + var s testValidated + if err := setValue(&s, "value", "good"); err != nil || s.Value != "good" { + t.Errorf("setValue(good) = %v, Value = %q", err, s.Value) + } + if err := setValue(&s, "value", "bad"); !errors.Is(err, errRejected) { + t.Errorf("setValue(bad) = %v; want errRejected", err) + } + if s.Value != "good" { + t.Errorf("rejected value was stored: %q", s.Value) + } + + // The validator of the section holding the field is used for nested keys. + var outer struct { + V testValidated `cfg:"v"` + } + if err := setValue(&outer, "v.value", "bad"); !errors.Is(err, errRejected) { + t.Errorf("nested setValue(bad) = %v; want errRejected", err) + } +}