Added unit tests, documentation and function descriptions across the board. Increased cyclop complexity threshold to 15 which is a bit more sane than 10 and excluded _test.go files from linters.
This commit is contained in:
1 parent
005b335371
commit
7fd321df97
31 files changed
+4106
-1
No files matched your search
@@ -0,0 +1,133 @@
|
||||
# config
|
||||
|
||||
Package config loads the JobSearch configuration into a `JobSearchConfig`
|
||||
struct. Values come from struct-tag defaults, a YAML or JSON config file and
|
||||
environment variables, merged through [Viper](https://github.com/spf13/viper).
|
||||
|
||||
|
||||
## Loading order
|
||||
`Init(cfgFile, &conf)` builds the configuration in this order:
|
||||
|
||||
1. Loads `.env` from the working directory into the environment. A missing
|
||||
`.env` file is an error.
|
||||
2. Sets the defaults from the `default` struct tags as Viper defaults.
|
||||
3. Reads the config file: `cfgFile` if given, otherwise the first
|
||||
`config.yaml` found in `/etc/job-search` then the working directory. If no
|
||||
file is found a warning is logged and the defaults are used. A file that
|
||||
exists but can't be parsed is an error.
|
||||
4. Reads environment variables prefixed with `JOBSEARCH_`. Dots in a key
|
||||
become underscores, so `logging.level` is read from
|
||||
`JOBSEARCH_LOGGING_LEVEL`.
|
||||
5. Copies every known key from Viper into `conf`. A value that fails
|
||||
validation (e.g. an unknown log level) is logged as a warning and the
|
||||
previous value is kept.
|
||||
|
||||
When more than one place sets a key, environment variables win over the
|
||||
config file, which wins over the defaults. A `debug` or `error` log level
|
||||
already set on `conf` before `Init` (from the `--debug` and `--quiet` flags)
|
||||
is kept.
|
||||
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `logging.level` | string | `warn` | One of `debug`, `info`, `warn`, `error` |
|
||||
| `logging.file` | string | | Log file for `fetch service` (unless `--foreground`) |
|
||||
| `sources.<source>.enabled` | bool | `false` | For every source |
|
||||
| `sources.<source>.interval` | duration | `10m` | For every source; e.g. `30s`, `1h` |
|
||||
| `sources.find_a_tender.api_root` | string | URL of the Find A Tender service API. Currently `https://www.find-tender.service.gov.uk/api/1.0` | |
|
||||
| `sources.fts_mock.api_root` | string | | URL of the mock FTS server (see `internal/mock`) |
|
||||
| `sources.sleeper.block_shutdown` | bool | `false` | Test source: if true, its sleep ignores shutdown |
|
||||
|
||||
The current sources are `find_a_tender`, `fts_mock` and `sleeper`. See
|
||||
"Addition of a new opportunities source" in the top level README for how to add
|
||||
a new source.
|
||||
|
||||
Example `config.yaml`:
|
||||
```yaml
|
||||
logging:
|
||||
level: info
|
||||
file: ""
|
||||
sources:
|
||||
find_a_tender:
|
||||
enabled: true
|
||||
interval: 10m
|
||||
fts_mock:
|
||||
enabled: false
|
||||
interval: 1m
|
||||
api_root: http://localhost:8080/api/1.0
|
||||
```
|
||||
|
||||
|
||||
## Sections and struct tags
|
||||
Each section of the configuration is a plain struct. The reflection code in
|
||||
`section.go` reads its struct tags, so a section needs no hand-written code
|
||||
per key:
|
||||
|
||||
- `cfg:"key"` is the field's configuration key. Fields without it are not part
|
||||
of the configuration.
|
||||
- `default:"value"` is the field's default, parsed as if it were a string
|
||||
passed to `SetValue` (e.g. `"10m"`, `"true"`).
|
||||
- A tagged field whose type is a struct is a nested section, addressed as
|
||||
`section.key`. An embedded struct has its fields promoted, as Go does;
|
||||
every source embeds `JobSearchConfigSource` for `enabled` and `interval`.
|
||||
- `time.Duration` fields report `reflect.String` from `TypeOf`, accept either a
|
||||
`time.Duration` or a duration string in `SetValue`, and are written as
|
||||
strings (`"10m0s"`) by `Map`, `JSON` and `YAML`.
|
||||
- A section can reject values by implementing `validate(key, value) error` on
|
||||
its pointer type. `JobSearchConfigLogging` does this for `level`.
|
||||
|
||||
Each section type has the same small set of methods, each a one-line call into
|
||||
`section.go`: `Map`, `Flatten`, `TypeOf`, `ValueOf` and `SetValue`, plus
|
||||
`Keys` on some.
|
||||
|
||||
|
||||
## Adding a key or section
|
||||
To add a key to an existing section, add a tagged field to its struct. Nothing
|
||||
else is needed for it to be loaded, printed, and read or written by the
|
||||
`config` CLI commands.
|
||||
|
||||
To add a new section:
|
||||
1. Create a `conf_*.go` file with the section struct, a `JSCK*` constant for
|
||||
its key, and the standard methods (copy an existing section).
|
||||
2. Add the struct as a tagged field of its parent: `JobSearchConfig` in
|
||||
`config.go`, or `JobSearchConfigSources` in `conf_sources.go` for a source,
|
||||
etc. The field's `cfg` tag must match the `JSCK*` constant.
|
||||
|
||||
|
||||
## API
|
||||
- `Init(cfgFile, &conf)`: load the configuration, as described above.
|
||||
- `conf.TypeOf(key)`, `conf.ValueOf(key)`, `conf.SetValue(key, value)`: read
|
||||
or set one dotted key on the loaded struct. `SetValue` converts and
|
||||
validates the value.
|
||||
- `conf.Map()` (nested) and `conf.Flatten()` (dotted keys): the whole
|
||||
configuration as a map.
|
||||
- `conf.String()`, `conf.JSON()`, `conf.YAML()` and the matching `Print*`
|
||||
methods: the configuration as text.
|
||||
- `StoreValue(&conf, key, value)` / `PurgeValue(&conf, key)`: set or remove a
|
||||
key in the config file Viper loaded. Used by `config set` and
|
||||
`config set --unset`. `StoreValue` validates the value before writing it.
|
||||
- `LoadDefaultConfig`, `LoadToViper`, `LoadFromViper`: move values between
|
||||
the struct defaults, Viper and a `JobSearchConfig`.
|
||||
|
||||
|
||||
## Files
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `config.go` | `JobSearchConfig`, defaults, Viper loading, text output |
|
||||
| `load.go` | `Init`, `.env` and config file loading |
|
||||
| `modify.go` | `StoreValue` and `PurgeValue` |
|
||||
| `section.go` | Struct tag reflection shared by every section |
|
||||
| `conf_logging.go` | `logging` section and log level helpers |
|
||||
| `conf_sources.go` | `sources` section and the shared source fields |
|
||||
| `conf_source_*.go` | One file per source section |
|
||||
| `print.go`, `debug.go` | Printing and debug logging of the configuration |
|
||||
|
||||
|
||||
## Testing
|
||||
Viper keeps its state in package globals, so tests that use it call
|
||||
`resetViper(t)` to reset it before and after the test. Tests of `Init`
|
||||
use `initDir`, which changes into a temp directory with its own `.env` and
|
||||
`config.yaml`, so the real files in the repository are never read.
|
||||
@@ -67,6 +67,9 @@ func ConfigLevelToLogLevel(level string) slog.Level {
|
||||
return 99
|
||||
}
|
||||
}
|
||||
|
||||
// LogLevel returns the configured level as an slog level. An unknown level
|
||||
// gives 99, above every real level, so nothing is logged.
|
||||
func (c *JobSearchConfigLogging) LogLevel() slog.Level {
|
||||
return ConfigLevelToLogLevel(c.Level)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,9 @@ func setValue(ptr any, key string, value any) error {
|
||||
return setField(section, f, value)
|
||||
}
|
||||
|
||||
// setField converts value to the field's type and, if the section has a
|
||||
// validator, checks it, before setting the field. On error the field is left
|
||||
// unchanged.
|
||||
func setField(section reflect.Value, f field, value any) error {
|
||||
newValue, err := convert(value, f.typ)
|
||||
if err != nil {
|
||||
@@ -234,6 +237,8 @@ func convert(value any, t reflect.Type) (reflect.Value, error) {
|
||||
return reflect.Value{}, fmt.Errorf("unsupported type %T", value)
|
||||
}
|
||||
|
||||
// parseString parses s as a value of type t. It supports strings, bools,
|
||||
// signed and unsigned integers, floats and time.Duration.
|
||||
func parseString(s string, t reflect.Type) (reflect.Value, error) {
|
||||
v := reflect.New(t).Elem()
|
||||
if t == durationType {
|
||||
@@ -280,6 +285,9 @@ func applyDefaults(ptr any) error {
|
||||
return applyDefaultsTo("", structValue(ptr))
|
||||
}
|
||||
|
||||
// applyDefaultsTo sets every field in section that has a default tag,
|
||||
// recursing into nested sections. prefix is the section's dotted key, used in
|
||||
// error messages.
|
||||
func applyDefaultsTo(prefix string, section reflect.Value) error {
|
||||
for _, f := range fieldsOf(section.Type()) {
|
||||
key := joinKey(prefix, f.key)
|
||||
|
||||
Reference in new issue
Block a user