package config import ( "errors" "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), 0o600); 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") //nolint:gosec // G304: reading a file the test created in t.TempDir 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) } //nolint:gosec // G304: reading a file the test created in t.TempDir 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") //nolint:gosec // G304: reading a file the test created in t.TempDir before, _ := os.ReadFile(path) conf := defaultConfig(t) if err := PurgeValue(&conf, "nope"); err == nil { t.Error("expected an error for an unknown key") } //nolint:gosec // G304: reading a file the test created in t.TempDir 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) } } // parseValue parses the kinds a key can have; no current key is an integer // or float, so StoreValue can't reach those cases yet. func TestParseValue(t *testing.T) { for _, tt := range []struct { kind reflect.Kind value string want any }{ {reflect.Bool, "true", true}, {reflect.String, "text", "text"}, {reflect.Int, "-42", int64(-42)}, {reflect.Int8, "7", int64(7)}, {reflect.Uint16, "65535", uint64(65535)}, {reflect.Float32, "1.5", 1.5}, {reflect.Slice, " a, b ,, c ", []string{"a", "b", "c"}}, } { got, err := parseValue(tt.kind, tt.value) if err != nil || !reflect.DeepEqual(got, tt.want) { t.Errorf( "parseValue(%v, %q) = %#v, %v; want %#v", tt.kind, tt.value, got, err, tt.want, ) } } for _, tt := range []struct { kind reflect.Kind value string }{ {reflect.Bool, "maybe"}, {reflect.Int, "1.5"}, {reflect.Uint, "-1"}, {reflect.Float64, "x"}, } { if got, err := parseValue(tt.kind, tt.value); err == nil { t.Errorf( "parseValue(%v, %q) = %#v; want an error", tt.kind, tt.value, got, ) } } _, err := parseValue(reflect.Map, "a") if !errors.Is(err, errors.ErrUnsupported) { t.Errorf("parseValue(map) err = %v; want ErrUnsupported", err) } } // numberSection has the numeric kinds JobSearchConfig doesn't use yet, to // test storeValue with them. type numberSection struct { Count int `cfg:"count"` Small int8 `cfg:"small"` Big uint16 `cfg:"big"` Ratio float32 `cfg:"ratio"` } func (c numberSection) Map() map[string]any { return mapOf(c) } func (c numberSection) Flatten() map[string]any { return flattenOf("", c) } func (c numberSection) TypeOf(key string) (reflect.Kind, error) { return typeOf(c, key) } func (c numberSection) ValueOf(key string) (any, error) { return valueOf(c, key) } func (c *numberSection) SetValue(key string, value any) error { return setValue(c, key, value) } // Numbers are stored as the field's own type, not as the int64, uint64 or // float64 that parsing produces. func TestStoreValueNumbers(t *testing.T) { for _, tt := range []struct { key, value string want any kind reflect.Kind }{ {"count", "-42", -42, reflect.Int}, {"small", "7", int8(7), reflect.Int8}, {"big", "65535", uint16(65535), reflect.Uint16}, {"ratio", "1.5", float32(1.5), reflect.Float32}, } { t.Run(tt.key, func(t *testing.T) { path := useConfigFile(t, "count: 1\n") var s numberSection got, kind, err := storeValue(&s, 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, _ := s.ValueOf(tt.key); v != tt.want { t.Errorf("section has %s = %#v; want %#v", tt.key, v, tt.want) } if v := readConfigFile(t, path).GetString(tt.key); v != tt.value { t.Errorf("file has %s = %q; want %q", tt.key, v, tt.value) } }) } for key, value := range map[string]string{ "count": "1.5", // not an integer "small": "300", // too big for an int8 "big": "-1", // negative "ratio": "lots", // not a number } { useConfigFile(t, "count: 1\n") if _, _, err := storeValue(&numberSection{}, key, value); err == nil { t.Errorf("storeValue(%s, %q) succeeded", key, value) } } } // Only the file's own settings are written back: defaults (and environment // variables) loaded into Viper stay out of the file. func TestStoreAndPurgeWriteOnlyFileSettings(t *testing.T) { path := useConfigFile(t, "logging:\n level: warn\n file: /tmp/js.log\n") LoadDefaultConfig() conf := defaultConfig(t) if _, _, err := StoreValue( &conf, "sources.sleeper.enabled", "true", ); err != nil { t.Fatal(err) } want := map[string]any{ "logging.level": "warn", "logging.file": "/tmp/js.log", "sources.sleeper.enabled": true, } checkFileSettings(t, path, want) if err := PurgeValue(&conf, "logging.file"); err != nil { t.Fatal(err) } delete(want, "logging.file") checkFileSettings(t, path, want) } // checkFileSettings checks the config file at path holds exactly want. func checkFileSettings(t *testing.T, path string, want map[string]any) { t.Helper() v := readConfigFile(t, path) got := map[string]any{} for _, k := range v.AllKeys() { got[k] = v.Get(k) } if !reflect.DeepEqual(got, want) { //nolint:gosec // G304: reading a file the test created in t.TempDir b, _ := os.ReadFile(path) t.Errorf("file settings = %v; want %v\n%s", got, want, b) } }