package config import ( "bytes" "encoding/json" "log/slog" "reflect" "strings" "testing" "time" "github.com/spf13/viper" "go.yaml.in/yaml/v3" ) // resetViper isolates tests that touch the global Viper instance. func resetViper(t *testing.T) { t.Helper() viper.Reset() t.Cleanup(viper.Reset) } // captureLogs sends the default slog logger to a buffer until the test ends. func captureLogs(t *testing.T, level slog.Level) *bytes.Buffer { t.Helper() var buf bytes.Buffer def := slog.Default() handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{ Level: level, }) slog.SetDefault(slog.New(handler)) t.Cleanup(func() { slog.SetDefault(def) }) return &buf } func defaultConfig(t *testing.T) JobSearchConfig { t.Helper() var conf JobSearchConfig if err := applyDefaults(&conf); err != nil { t.Fatalf("defaults: %v", err) } return conf } func TestDefaults(t *testing.T) { conf := defaultConfig(t) if conf.Logging.Level != "warn" { t.Errorf("Logging.Level = %q; want warn", conf.Logging.Level) } const apiRoot = "https://www.find-tender.service.gov.uk/api/1.0" if conf.Sources.FindATender.APIRoot != apiRoot { t.Errorf("FindATender.APIRoot = %q", conf.Sources.FindATender.APIRoot) } if conf.Sources.FTSMock.APIRoot != "" { t.Errorf("FTSMock.APIRoot = %q; want empty", conf.Sources.FTSMock.APIRoot) } for name, src := range map[string]JSCSourcesSource{ "find_a_tender": conf.Sources.FindATender.JSCSourcesSource, "fts_mock": conf.Sources.FTSMock.JSCSourcesSource, "sleeper": conf.Sources.Sleeper.JSCSourcesSource, } { if src.Enabled || src.Interval != 10*time.Minute { t.Errorf("%s source = %+v; want disabled, 10m", name, src) } } } func TestLoggingSection(t *testing.T) { c := JSCLogging{ Level: "info", File: "/tmp/x.log", } if got, want := c.Keys(), []string{ "level", "file", }; !reflect.DeepEqual(got, want) { t.Errorf("Keys = %v; want %v", got, want) } if got, want := c.Map(), map[string]any{ "level": "info", "file": "/tmp/x.log", }; !reflect.DeepEqual(got, want) { t.Errorf("Map = %v; want %v", got, want) } if got, want := c.Flatten(), map[string]any{ "logging.level": "info", "logging.file": "/tmp/x.log", }; !reflect.DeepEqual(got, want) { t.Errorf("Flatten = %v; want %v", got, want) } if k, err := c.TypeOf("file"); err != nil || k != reflect.String { t.Errorf("TypeOf(file) = %v, %v", k, err) } if v, err := c.ValueOf("level"); err != nil || v != "info" { t.Errorf("ValueOf(level) = %v, %v", v, err) } if _, err := c.TypeOf("nope"); err == nil { t.Error("TypeOf(nope) succeeded") } if err := c.SetValue("file", "/var/log/js.log"); err != nil || c.File != "/var/log/js.log" { t.Errorf("SetValue(file) = %v, File = %q", err, c.File) } } func TestLoggingLevelValidation(t *testing.T) { var c JSCLogging for _, lvl := range []string{"debug", "info", "warn", "error"} { if err := c.SetValue("level", lvl); err != nil || c.Level != lvl { t.Errorf("SetValue(level, %q) = %v, Level = %q", lvl, err, c.Level) } } for _, lvl := range []string{"", "trace", "WARN", "fatal"} { if err := c.SetValue("level", lvl); err == nil || !strings.Contains(err.Error(), "invalid log level") { t.Errorf("SetValue(level, %q) = %v; want invalid log level", lvl, err) } } if c.Level != "error" { t.Errorf("rejected level was stored: %q", c.Level) } // Validation also applies through the top-level config. conf := defaultConfig(t) if err := conf.SetValue("logging.level", "loud"); err == nil { t.Error("JobSearchConfig.SetValue(logging.level, loud) succeeded") } // Other keys are not validated. if err := c.validate("file", "anything"); err != nil { t.Errorf("validate(file) = %v", err) } } func TestLevelToLogLevel(t *testing.T) { tests := map[string]slog.Level{ "debug": slog.LevelDebug, "info": slog.LevelInfo, "warn": slog.LevelWarn, "error": slog.LevelError, "": 99, "bogus": 99, } for in, want := range tests { if got := LevelToLogLevel(in); got != want { t.Errorf("LevelToLogLevel(%q) = %v; want %v", in, got, want) } c := JSCLogging{ Level: in, } if got := c.LogLevel(); got != want { t.Errorf("LogLevel() with %q = %v; want %v", in, got, want) } } } func TestSourceSection(t *testing.T) { c := JSCSourcesSource{ Enabled: true, Interval: time.Minute, } if got, want := c.Keys(), []string{ "enabled", "interval", }; !reflect.DeepEqual(got, want) { t.Errorf("Keys = %v; want %v", got, want) } want := map[string]any{"enabled": true, "interval": "1m0s"} if got := c.Map(); !reflect.DeepEqual(got, want) { t.Errorf("Map = %v; want %v", got, want) } wantFlat := map[string]any{"enabled": true, "interval": "1m0s"} if got := c.Flatten(); !reflect.DeepEqual(got, wantFlat) { t.Errorf("Flatten = %v; want %v (no prefix)", got, wantFlat) } if k, err := c.TypeOf("interval"); err != nil || k != reflect.String { t.Errorf("TypeOf(interval) = %v, %v; want String", k, err) } if k, err := c.TypeOf("enabled"); err != nil || k != reflect.Bool { t.Errorf("TypeOf(enabled) = %v, %v; want Bool", k, err) } if v, err := c.ValueOf("interval"); err != nil || v != "1m0s" { t.Errorf("ValueOf(interval) = %v, %v", v, err) } if err := c.SetValue("interval", "5s"); err != nil || c.Interval != 5*time.Second { t.Errorf("SetValue(interval, 5s) = %v, Interval = %v", err, c.Interval) } if err := c.SetValue("interval", "never"); err == nil { t.Error("SetValue(interval, never) succeeded") } } func TestConcreteSourceSections(t *testing.T) { tests := []struct { prefix string section Section apiRoot bool blockShutdown bool }{ {JSCKSourceFindATender, &JSCSourceFindATender{}, true, false}, {JSCKSourceFTSMock, &JSCSourceFTSMock{}, true, false}, {JSCKSourceSleeper, &JSCSourceSleeper{}, false, true}, } for _, tt := range tests { t.Run(tt.prefix, func(t *testing.T) { s := tt.section // Embedded source fields are promoted. if err := s.SetValue("enabled", "true"); err != nil { t.Fatalf("SetValue(enabled): %v", err) } if err := s.SetValue("interval", 2*time.Minute); err != nil { t.Fatalf("SetValue(interval): %v", err) } if v, err := s.ValueOf("enabled"); err != nil || v != true { t.Errorf("ValueOf(enabled) = %v, %v", v, err) } if k, err := s.TypeOf("interval"); err != nil || k != reflect.String { t.Errorf("TypeOf(interval) = %v, %v", k, err) } wantMap := map[string]any{"enabled": true, "interval": "2m0s"} wantFlat := map[string]any{ tt.prefix + ".enabled": true, tt.prefix + ".interval": "2m0s", } if tt.apiRoot { if err := s.SetValue("api_root", "http://localhost:8080"); err != nil { t.Fatalf("SetValue(api_root): %v", err) } if v, err := s.ValueOf("api_root"); err != nil || v != "http://localhost:8080" { t.Errorf("ValueOf(api_root) = %v, %v", v, err) } wantMap["api_root"] = "http://localhost:8080" wantFlat[tt.prefix+".api_root"] = "http://localhost:8080" } else if _, err := s.TypeOf("api_root"); err == nil { t.Error("TypeOf(api_root) succeeded on a source without one") } if tt.blockShutdown { if err := s.SetValue("block_shutdown", "true"); err != nil { t.Fatalf("SetValue(block_shutdown): %v", err) } if k, err := s.TypeOf("block_shutdown"); err != nil || k != reflect.Bool { t.Errorf("TypeOf(block_shutdown) = %v, %v", k, err) } wantMap["block_shutdown"] = true wantFlat[tt.prefix+".block_shutdown"] = true } else if _, err := s.TypeOf("block_shutdown"); err == nil { t.Error("TypeOf(block_shutdown) succeeded on a source without one") } if got := s.Map(); !reflect.DeepEqual(got, wantMap) { t.Errorf("Map = %v; want %v", got, wantMap) } if got := s.Flatten(); !reflect.DeepEqual(got, wantFlat) { t.Errorf("Flatten = %v; want %v", got, wantFlat) } if _, err := s.ValueOf("nope"); err == nil { t.Error("ValueOf(nope) succeeded") } }) } } func TestSourcesSection(t *testing.T) { var c JSCSources if err := c.SetValue("fts_mock.api_root", "http://mock"); err != nil { t.Fatalf("SetValue: %v", err) } if c.FTSMock.APIRoot != "http://mock" { t.Errorf("FTSMock.APIRoot = %q", c.FTSMock.APIRoot) } if v, err := c.ValueOf("fts_mock.api_root"); err != nil || v != "http://mock" { t.Errorf("ValueOf = %v, %v", v, err) } if k, err := c.TypeOf("sleeper.enabled"); err != nil || k != reflect.Bool { t.Errorf("TypeOf(sleeper.enabled) = %v, %v", k, err) } if _, err := c.TypeOf("sleeper"); err == nil { t.Error("TypeOf(sleeper) succeeded on a section key") } m := c.Map() for _, k := range []string{"find_a_tender", "fts_mock", "sleeper"} { if _, ok := m[k].(map[string]any); !ok { t.Errorf("Map()[%q] = %#v; want nested map", k, m[k]) } } flat := c.Flatten() if flat["sources.fts_mock.api_root"] != "http://mock" { t.Errorf("Flatten missing sources.fts_mock.api_root: %v", flat) } if len(flat) != 9 { t.Errorf("Flatten has %d keys; want 9: %v", len(flat), flat) } } func TestJobSearchConfigMap(t *testing.T) { conf := defaultConfig(t) m := conf.Map() logging, ok := m["logging"].(map[string]any) if !ok || logging["level"] != "warn" { t.Errorf("Map()[logging] = %#v", m["logging"]) } sources, ok := m["sources"].(map[string]any) if !ok { t.Fatalf("Map()[sources] = %#v", m["sources"]) } fat, ok := sources["find_a_tender"].(map[string]any) if !ok || fat["interval"] != "10m0s" { t.Errorf("Map()[sources][find_a_tender] = %#v", sources["find_a_tender"]) } } func TestString(t *testing.T) { conf := defaultConfig(t) s := conf.String() for _, want := range []string{ "logging.level = warn \n", "sources.sleeper.enabled = false \n", "sources.find_a_tender.interval = 10m0s \n", } { if !strings.Contains(s, want) { t.Errorf("String() missing %q:\n%s", want, s) } } if n := strings.Count(s, "\n"); n != len(conf.Flatten()) { t.Errorf("String() has %d lines; want %d", n, len(conf.Flatten())) } } // JSON and YAML give the configuration as nested sections. func TestSerialised(t *testing.T) { conf := defaultConfig(t) tests := []struct { name string output string unmarshal func([]byte, any) error }{ {"JSON", conf.JSON(), json.Unmarshal}, {"YAML", conf.YAML(), yaml.Unmarshal}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var m map[string]map[string]any if err := tt.unmarshal([]byte(tt.output), &m); err != nil { t.Fatalf("%s() doesn't parse: %v\n%s", tt.name, err, tt.output) } if m["logging"]["level"] != "warn" { t.Errorf("logging.level = %#v", m["logging"]["level"]) } for _, source := range []string{"fts_mock", "sleeper"} { if _, ok := m["sources"][source]; !ok { t.Errorf("missing sources.%s: %v", source, m["sources"]) } } }) } } // Output of JSON()/YAML() can be read back as a config file. func TestSerialisedRoundTrip(t *testing.T) { orig := defaultConfig(t) orig.Logging.Level = "info" orig.Sources.FTSMock.Enabled = true orig.Sources.FTSMock.APIRoot = "http://mock" orig.Server.WebSocket.MaxMessageSize = 64 << 10 for _, format := range []string{"json", "yaml"} { t.Run(format, func(t *testing.T) { resetViper(t) out := orig.JSON() if format == "yaml" { out = orig.YAML() } viper.SetConfigType(format) if err := viper.ReadConfig(strings.NewReader(out)); err != nil { t.Fatalf("ReadConfig: %v", err) } var got JobSearchConfig LoadFromViper(&got) if !reflect.DeepEqual(got, orig) { t.Errorf("round trip =\n%+v\nwant\n%+v", got, orig) } }) } } func TestLoadToViper(t *testing.T) { resetViper(t) conf := defaultConfig(t) LoadToViper(conf) for key, want := range conf.Flatten() { if got := viper.Get(key); !reflect.DeepEqual(got, want) { t.Errorf("viper.Get(%q) = %#v; want %#v", key, got, want) } } } func TestLoadDefaultConfig(t *testing.T) { resetViper(t) LoadDefaultConfig() if got := viper.GetString("logging.level"); got != "warn" { t.Errorf("logging.level = %q; want warn", got) } if got := viper.GetDuration( "sources.sleeper.interval", ); got != 10*time.Minute { t.Errorf("sources.sleeper.interval = %v; want 10m", got) } } func TestLoadFromViper(t *testing.T) { resetViper(t) LoadDefaultConfig() viper.Set("logging.level", "debug") viper.Set("logging.file", "/tmp/js.log") viper.Set("sources.sleeper.enabled", "true") // string, read as bool viper.Set("sources.fts_mock.interval", "30s") viper.Set("sources.fts_mock.api_root", "http://mock") viper.Set("unknown.key", "ignored") var conf JobSearchConfig LoadFromViper(&conf) want := defaultConfig(t) want.Logging.Level = "debug" want.Logging.File = "/tmp/js.log" want.Sources.Sleeper.Enabled = true want.Sources.FTSMock.Interval = 30 * time.Second want.Sources.FTSMock.APIRoot = "http://mock" if !reflect.DeepEqual(conf, want) { t.Errorf("LoadFromViper =\n%+v\nwant\n%+v", conf, want) } } func TestViperRoundTrip(t *testing.T) { resetViper(t) orig := defaultConfig(t) orig.Logging.Level = "error" orig.Sources.FindATender.Enabled = true orig.Sources.FindATender.Interval = 45 * time.Second LoadToViper(orig) var got JobSearchConfig LoadFromViper(&got) if !reflect.DeepEqual(got, orig) { t.Errorf("round trip =\n%+v\nwant\n%+v", got, orig) } } // Invalid values from Viper are skipped with a warning, leaving the existing // value. func TestLoadFromViperInvalidValues(t *testing.T) { resetViper(t) logs := captureLogs(t, slog.LevelWarn) viper.Set("logging.level", "shouty") viper.Set("sources.sleeper.interval", "eventually") conf := defaultConfig(t) LoadFromViper(&conf) if conf.Logging.Level != "warn" { t.Errorf("Logging.Level = %q; want warn kept", conf.Logging.Level) } if conf.Sources.Sleeper.Interval != 10*time.Minute { t.Errorf( "Sleeper.Interval = %v; want 10m kept", conf.Sources.Sleeper.Interval, ) } for _, key := range []string{"logging.level", "sources.sleeper.interval"} { if !strings.Contains(logs.String(), "key="+key) { t.Errorf("no warning logged for %s: %s", key, logs) } } } func TestDatabaseSection(t *testing.T) { c := JSCDatabase{ File: "./jobsearch.db", } if got, want := c.Keys(), []string{"file"}; !reflect.DeepEqual(got, want) { t.Errorf("Keys = %v; want %v", got, want) } if got, want := c.Map(), map[string]any{ "file": "./jobsearch.db", }; !reflect.DeepEqual(got, want) { t.Errorf("Map = %v; want %v", got, want) } want := map[string]any{"database.file": "./jobsearch.db"} if got := c.Flatten(); !reflect.DeepEqual(got, want) { t.Errorf("Flatten = %v; want %v", got, want) } if k, err := c.TypeOf("file"); err != nil || k != reflect.String { t.Errorf("TypeOf(file) = %v, %v", k, err) } if v, err := c.ValueOf("file"); err != nil || v != "./jobsearch.db" { t.Errorf("ValueOf(file) = %v, %v", v, err) } if _, err := c.TypeOf("nope"); err == nil { t.Error("TypeOf(nope) succeeded") } if _, err := c.ValueOf("nope"); err == nil { t.Error("ValueOf(nope) succeeded") } if err := c.SetValue("file", "/var/lib/js.db"); err != nil || c.File != "/var/lib/js.db" { t.Errorf("SetValue(file) = %v, File = %q", err, c.File) } if err := c.SetValue("file", 42); err == nil { t.Error("SetValue(file, 42) succeeded") } // The root configuration reaches it as database.file. conf := defaultConfig(t) if err := conf.SetValue("database.file", "./other.db"); err != nil || conf.Database.File != "./other.db" { t.Errorf("SetValue(database.file) = %v, File = %q", err, conf.Database.File) } } func TestViperValue(t *testing.T) { resetViper(t) viper.Set("s", "text") viper.Set("i", "42") // strings are converted viper.Set("b", "true") viper.Set("env_list", "a, b") // e.g. from an environment variable viper.Set("file_list", []any{"a", "b"}) // e.g. from a YAML list for _, tt := range []struct { key string kind reflect.Kind want any }{ {"s", reflect.String, "text"}, {"i", reflect.Int, 42}, {"b", reflect.Bool, true}, {"env_list", reflect.Slice, "a, b"}, // split by SetValue {"file_list", reflect.Slice, []string{"a", "b"}}, } { got, ok := viperValue(tt.key, tt.kind) if !ok || !reflect.DeepEqual(got, tt.want) { t.Errorf( "viperValue(%q, %v) = %#v, %v; want %#v", tt.key, tt.kind, got, ok, tt.want, ) } } unhandled := []reflect.Kind{reflect.Float64, reflect.Int8, reflect.Map} for _, kind := range unhandled { if got, ok := viperValue("i", kind); ok { t.Errorf("viperValue(i, %v) = %#v, true; want unhandled", kind, got) } } } // max_message_size can't be -1 (no limit), directly or through the whole // configuration; 0 (the default) and explicit sizes are accepted. func TestWebSocketMaxMessageSizeNoLimit(t *testing.T) { var c JSCServerWebSocket if err := c.SetValue("max_message_size", "-1"); err == nil || !strings.Contains(err.Error(), "can't be -1") { t.Errorf("SetValue(max_message_size, -1) = %v; want rejected", err) } if c.MaxMessageSize != 0 { t.Errorf("rejected size was stored: %v", c.MaxMessageSize) } for _, text := range []string{"0B", "64KiB"} { if err := c.SetValue("max_message_size", text); err != nil { t.Errorf("SetValue(max_message_size, %s): %v", text, err) } } conf := defaultConfig(t) if err := conf.SetValue( "server.websocket.max_message_size", "-1", ); err == nil { t.Error( "JobSearchConfig.SetValue(server.websocket.max_message_size, -1) succeeded", ) } } // A source's interval must be greater than 0, through the embedded section's // promoted validate; a source is turned off with enabled. func TestSourceIntervalMustBePositive(t *testing.T) { conf := defaultConfig(t) for _, key := range []string{ "sources.find_a_tender.interval", "sources.fts_mock.interval", "sources.sleeper.interval", } { for _, value := range []string{"0s", "-1m"} { err := conf.SetValue(key, value) if err == nil || !strings.Contains(err.Error(), "greater than 0") { t.Errorf("SetValue(%s, %s) = %v; want rejected", key, value, err) } } if err := conf.SetValue(key, "1m"); err != nil { t.Errorf("SetValue(%s, 1m): %v", key, err) } } if conf.Sources.FindATender.Interval.Minutes() != 1 { t.Errorf( "FindATender.Interval = %v; want 1m", conf.Sources.FindATender.Interval, ) } }