A section tagged init:"hidden" isn't written to the file config init generates; fts_mock and sleeper are. config init --all writes them too. They still load, and the config commands read and set them as before.
851 lines
25 KiB
Go
851 lines
25 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()
|
|
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)
|
|
}
|
|
if conf.Sources.FindATender.RateLimitRetries != 5 ||
|
|
conf.Sources.FTSMock.RateLimitRetries != 5 {
|
|
t.Errorf(
|
|
"RateLimitRetries = %d, %d; want 5, 5",
|
|
conf.Sources.FindATender.RateLimitRetries,
|
|
conf.Sources.FTSMock.RateLimitRetries,
|
|
)
|
|
}
|
|
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"
|
|
|
|
// The Find a Tender sources' own validate must still check
|
|
// interval, as JSCSourcesSource's does.
|
|
if err := s.SetValue("rate_limit_retries", "3"); err != nil {
|
|
t.Fatalf("SetValue(rate_limit_retries): %v", err)
|
|
}
|
|
if err := s.SetValue("rate_limit_retries", "-1"); err == nil {
|
|
t.Error("SetValue(rate_limit_retries, -1) succeeded")
|
|
}
|
|
if err := s.SetValue("interval", "0s"); err == nil {
|
|
t.Error("SetValue(interval, 0s) succeeded")
|
|
}
|
|
wantMap["rate_limit_retries"] = 3
|
|
wantFlat[tt.prefix+".rate_limit_retries"] = 3
|
|
} 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")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// InitDefaults leaves out the sections tagged init:"hidden" unless asked.
|
|
func TestInitDefaults(t *testing.T) {
|
|
all := InitDefaults(true)
|
|
shown := InitDefaults(false)
|
|
if all["sources.sleeper.enabled"] != false ||
|
|
all["sources.fts_mock.interval"] != "10m0s" {
|
|
t.Errorf("all = %v; want the test sources' defaults", all)
|
|
}
|
|
for key := range all {
|
|
hidden := strings.HasPrefix(key, "sources.fts_mock.") ||
|
|
strings.HasPrefix(key, "sources.sleeper.")
|
|
if _, found := shown[key]; found == hidden {
|
|
t.Errorf("%s: in the generated defaults = %v", key, found)
|
|
}
|
|
}
|
|
if shown["sources.eu_supply.base_url"] != "https://uk.eu-supply.com" {
|
|
t.Errorf("eu_supply missing: %v", shown)
|
|
}
|
|
}
|
|
|
|
func TestEUSupplySection(t *testing.T) {
|
|
conf := defaultConfig(t)
|
|
got := conf.Sources.EUSupply
|
|
if got.Enabled || got.Interval != 10*time.Minute ||
|
|
got.BaseURL != "https://uk.eu-supply.com" ||
|
|
got.RequestMinGap != 2*time.Second ||
|
|
got.RequestMaxGap != 10*time.Second {
|
|
t.Errorf("defaults = %+v", got)
|
|
}
|
|
|
|
c := JSCSourceEUSupply{}
|
|
if err := c.SetValue("request_min_gap", "5s"); err != nil ||
|
|
c.RequestMinGap != 5*time.Second {
|
|
t.Errorf("SetValue(request_min_gap, 5s): %v, %v", err, c.RequestMinGap)
|
|
}
|
|
for _, key := range []string{"request_min_gap", "request_max_gap"} {
|
|
if err := c.SetValue(key, "0s"); err == nil {
|
|
t.Errorf("SetValue(%s, 0s) succeeded", key)
|
|
}
|
|
}
|
|
// Its own validate must still check interval, as JSCSourcesSource's does.
|
|
if err := c.SetValue("interval", "0s"); err == nil {
|
|
t.Error("SetValue(interval, 0s) succeeded")
|
|
}
|
|
if v, err := c.ValueOf("base_url"); err != nil || v != "" {
|
|
t.Errorf("ValueOf(base_url) = %v, %v", v, err)
|
|
}
|
|
}
|
|
|
|
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) != 17 {
|
|
t.Errorf("Flatten has %d keys; want 17: %v", len(flat), flat)
|
|
}
|
|
|
|
if err := c.SetValue("skip_rules_file", "rules.json"); err != nil ||
|
|
c.SkipRulesFile != "rules.json" {
|
|
t.Errorf("SetValue(skip_rules_file): %v, %q", err, c.SkipRulesFile)
|
|
}
|
|
}
|
|
|
|
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()))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
orig.Server.MaxBodyBytes = 2 << 20
|
|
orig.Server.Headers.ExtraResponseHeaders = []JSCServerHeadersExtra{
|
|
{
|
|
Header: "X-Robots-Tag",
|
|
Value: "noindex",
|
|
},
|
|
{
|
|
Header: "Cache-Control",
|
|
Value: "no-store, max-age=0",
|
|
},
|
|
}
|
|
orig.Server.Headers.RemoveResponseHeaders = []string{"Server", "X-Powered-By"}
|
|
|
|
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
|
|
orig.Server.Headers.ExtraResponseHeaders = []JSCServerHeadersExtra{
|
|
{
|
|
Header: "X-Robots-Tag",
|
|
Value: "noindex",
|
|
},
|
|
}
|
|
LoadToViper(orig)
|
|
|
|
var got JobSearchConfig
|
|
LoadFromViper(&got)
|
|
if !reflect.DeepEqual(got, orig) {
|
|
t.Errorf("round trip =\n%+v\nwant\n%+v", got, orig)
|
|
}
|
|
}
|
|
|
|
// A list of sections can be given as a JSON array string, as it would be in
|
|
// an environment variable.
|
|
func TestLoadFromViperSectionListString(t *testing.T) {
|
|
resetViper(t)
|
|
viper.Set(
|
|
"server.headers.extra_response_headers",
|
|
`[{"header": "X-Robots-Tag", "value": "noindex"}]`,
|
|
)
|
|
conf := defaultConfig(t)
|
|
LoadFromViper(&conf)
|
|
|
|
want := []JSCServerHeadersExtra{
|
|
{
|
|
Header: "X-Robots-Tag",
|
|
Value: "noindex",
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(conf.Server.Headers.ExtraResponseHeaders, want) {
|
|
t.Errorf(
|
|
"ExtraResponseHeaders = %+v; want %+v",
|
|
conf.Server.Headers.ExtraResponseHeaders,
|
|
want,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHeadersExtraValidation(t *testing.T) {
|
|
var c JSCServerHeadersExtra
|
|
for _, h := range []string{"X-Robots-Tag", "x-a", "A1!#$%&'*+-.^_`|~"} {
|
|
if err := c.SetValue("header", h); err != nil || c.Header != h {
|
|
t.Errorf("SetValue(header, %q) = %v, Header = %q", h, err, c.Header)
|
|
}
|
|
}
|
|
for _, h := range []string{
|
|
"", "X Robots", "X-A:", "X-A\r\nX-B", "Ünicode", "(x)",
|
|
} {
|
|
if err := c.SetValue("header", h); err == nil ||
|
|
!strings.Contains(err.Error(), "invalid header name") {
|
|
t.Errorf("SetValue(header, %q) = %v; want invalid header name", h, err)
|
|
}
|
|
}
|
|
if err := c.SetValue("value", "no-store, max-age=0"); err != nil {
|
|
t.Errorf("SetValue(value): %v", err)
|
|
}
|
|
for _, v := range []string{"a\nb", "a\rb", "a\r\nX-Evil: 1"} {
|
|
if err := c.SetValue("value", v); err == nil ||
|
|
!strings.Contains(err.Error(), "invalid header value") {
|
|
t.Errorf("SetValue(value, %q) = %v; want invalid header value", v, err)
|
|
}
|
|
}
|
|
|
|
// Items in the list are validated whole, so a missing header is rejected
|
|
// too, and the list is left as it was.
|
|
const key = "server.headers.extra_response_headers"
|
|
conf := defaultConfig(t)
|
|
for _, value := range []any{
|
|
`[{"value": "noindex"}]`,
|
|
`[{"header": "X-A", "value": "1"}, {"header": "bad name", "value": "2"}]`,
|
|
`[{"header": "X-A", "value": "a\nb"}]`,
|
|
[]JSCServerHeadersExtra{
|
|
{
|
|
Value: "noindex",
|
|
},
|
|
},
|
|
} {
|
|
if err := conf.SetValue(key, value); err == nil ||
|
|
!strings.Contains(err.Error(), "invalid header") {
|
|
t.Errorf("SetValue(%s, %#v) = %v; want invalid header", key, value, err)
|
|
}
|
|
if conf.Server.Headers.ExtraResponseHeaders != nil {
|
|
t.Errorf(
|
|
"rejected list was stored: %+v",
|
|
conf.Server.Headers.ExtraResponseHeaders,
|
|
)
|
|
}
|
|
}
|
|
|
|
// From Viper, an invalid list is skipped with a warning.
|
|
resetViper(t)
|
|
logs := captureLogs(t, slog.LevelWarn)
|
|
viper.Set(key, []any{map[string]any{"value": "noindex"}})
|
|
LoadFromViper(&conf)
|
|
if conf.Server.Headers.ExtraResponseHeaders != nil ||
|
|
!strings.Contains(logs.String(), "key="+key) {
|
|
t.Errorf(
|
|
"invalid list from Viper: stored %+v, logs %s",
|
|
conf.Server.Headers.ExtraResponseHeaders,
|
|
logs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
ChangeBatchRetention: 48 * time.Hour,
|
|
}
|
|
|
|
keys := []string{"file", "change_batch_retention"}
|
|
if got := c.Keys(); !reflect.DeepEqual(got, keys) {
|
|
t.Errorf("Keys = %v; want %v", got, keys)
|
|
}
|
|
if got, want := c.Map(), map[string]any{
|
|
"file": "./jobsearch.db",
|
|
"change_batch_retention": "48h0m0s",
|
|
}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("Map = %v; want %v", got, want)
|
|
}
|
|
want := map[string]any{
|
|
"database.file": "./jobsearch.db",
|
|
"database.change_batch_retention": "48h0m0s",
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 30 days by default.
|
|
retention := defaultConfig(t).Database.ChangeBatchRetention
|
|
if retention != 720*time.Hour {
|
|
t.Errorf("default change_batch_retention = %v; want 720h", retention)
|
|
}
|
|
err := conf.SetValue("database.change_batch_retention", "0s")
|
|
if err != nil || conf.Database.ChangeBatchRetention != 0 {
|
|
t.Errorf(
|
|
"SetValue(change_batch_retention) = %v, retention = %v",
|
|
err, conf.Database.ChangeBatchRetention,
|
|
)
|
|
}
|
|
}
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
|
|
// server.timeouts.read_header must be greater than 0; the other server
|
|
// timeouts accept 0 (no limit).
|
|
func TestServerReadHeaderTimeoutMustBePositive(t *testing.T) {
|
|
conf := defaultConfig(t)
|
|
for _, value := range []string{"0s", "-1s"} {
|
|
err := conf.SetValue("server.timeouts.read_header", value)
|
|
if err == nil || !strings.Contains(err.Error(), "greater than 0") {
|
|
t.Errorf("SetValue(read_header, %s) = %v; want rejected", value, err)
|
|
}
|
|
}
|
|
if conf.Server.Timeouts.ReadHeaderTimeout != 5*time.Second {
|
|
t.Errorf(
|
|
"rejected read_header was stored: %v",
|
|
conf.Server.Timeouts.ReadHeaderTimeout,
|
|
)
|
|
}
|
|
for _, key := range []string{"read", "write", "idle", "shutdown"} {
|
|
if err := conf.SetValue("server.timeouts."+key, "0s"); err != nil {
|
|
t.Errorf("SetValue(%s, 0s): %v", key, err)
|
|
}
|
|
}
|
|
}
|