Files
job-search/internal/config/conf_sections_test.go
T

467 lines
14 KiB
Go

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()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level})))
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)
}
if conf.Sources.FindATender.APIRoot != "https://www.find-tender.service.gov.uk/api/1.0" {
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]JobSearchConfigSource{
"find_a_tender": conf.Sources.FindATender.JobSearchConfigSource,
"fts_mock": conf.Sources.FTSMock.JobSearchConfigSource,
"sleeper": conf.Sources.Sleeper.JobSearchConfigSource,
} {
if src.Enabled || src.Interval != 10*time.Minute {
t.Errorf("%s source = %+v; want disabled, 10m", name, src)
}
}
}
func TestLoggingSection(t *testing.T) {
c := JobSearchConfigLogging{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 JobSearchConfigLogging
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 TestConfigLevelToLogLevel(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 := ConfigLevelToLogLevel(in); got != want {
t.Errorf("ConfigLevelToLogLevel(%q) = %v; want %v", in, got, want)
}
c := JobSearchConfigLogging{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 := JobSearchConfigSource{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": time.Minute}
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 != time.Minute {
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")
}
}
// sourceSection is the method set shared by every concrete source section.
type sourceSection interface {
Map() map[string]any
Flatten() map[string]any
TypeOf(string) (reflect.Kind, error)
ValueOf(string) (any, error)
SetValue(string, any) error
}
func TestConcreteSourceSections(t *testing.T) {
tests := []struct {
prefix string
section sourceSection
apiRoot bool
blockShutdown bool
}{
{JSCKSourceFindATender, &JobSearchConfigSourceFindATender{}, true, false},
{JSCKSourceFTSMock, &JobSearchConfigSourceFTSMock{}, true, false},
{JSCKSourceSleeper, &JobSearchConfigSourceSleeper{}, 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": 2 * time.Minute,
}
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 JobSearchConfigSources
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 <string>\n",
"sources.sleeper.enabled = false <bool>\n",
"sources.find_a_tender.interval = 10m0s <string>\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()))
}
}
func TestJSON(t *testing.T) {
conf := defaultConfig(t)
var m map[string]map[string]any
if err := json.Unmarshal([]byte(conf.JSON()), &m); err != nil {
t.Fatalf("JSON() is not valid JSON: %v\n%s", err, conf.JSON())
}
if m["logging"]["level"] != "warn" {
t.Errorf("JSON logging.level = %#v", m["logging"]["level"])
}
if _, ok := m["sources"]["fts_mock"]; !ok {
t.Errorf("JSON missing sources.fts_mock: %v", m["sources"])
}
}
func TestYAML(t *testing.T) {
conf := defaultConfig(t)
var m map[string]map[string]any
if err := yaml.Unmarshal([]byte(conf.YAML()), &m); err != nil {
t.Fatalf("YAML() is not valid YAML: %v\n%s", err, conf.YAML())
}
if m["logging"]["level"] != "warn" {
t.Errorf("YAML logging.level = %#v", m["logging"]["level"])
}
if _, ok := m["sources"]["sleeper"]; !ok {
t.Errorf("YAML missing sources.sleeper: %v", 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"
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)
}
}
}