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

408 lines
11 KiB
Go

package config
import (
"errors"
"reflect"
"strings"
"testing"
"time"
"git.avsdev.uk/Go/job-search/internal/util"
)
// errRejected is the error testValidated.validate rejects "bad" with.
var errRejected = errors.New("rejected")
// Test sections exercising every field shape section.go supports.
type testInner struct {
Name string `cfg:"name" default:"inner"`
}
// testItem is the item type of a list of sections.
type testItem struct {
Name string `cfg:"name" default:"item"`
N int `cfg:"n"`
Wait time.Duration `cfg:"wait"`
}
type testEmbedded struct {
On bool `cfg:"on" default:"true"`
}
type testSection struct {
testEmbedded
Str string `cfg:"str" default:"hello"`
Int int `cfg:"int" default:"-42"`
Int8 int8 `cfg:"int8"`
Uint uint `cfg:"uint" default:"7"`
Uint16 uint16 `cfg:"uint16"`
Float float64 `cfg:"float" default:"1.5"`
Float32 float32 `cfg:"float32"`
Dur time.Duration `cfg:"dur" default:"90s"`
Size util.ByteSize `cfg:"size" default:"32KiB"`
When time.Time `cfg:"when"`
Slice []string `cfg:"slice"`
Items []testItem `cfg:"items"`
Inner testInner `cfg:"inner"`
NoTag string
Skipped string `cfg:"-"`
Empty string `cfg:""`
unexport string `cfg:"unexported"` //nolint:unused // checks unexported fields are ignored
}
type testValidated struct {
Value string `cfg:"value"`
}
func (c *testValidated) validate(key string, value any) error {
if key == "value" && value.(string) == "bad" {
return errRejected
}
return nil
}
func TestKeysOf(t *testing.T) {
got := keysOf(&testSection{})
want := []string{"on", "str", "int", "int8", "uint", "uint16", "float",
"float32", "dur", "size", "when", "slice", "items", "inner"}
if !reflect.DeepEqual(got, want) {
t.Errorf("keysOf = %v; want %v", got, want)
}
}
func TestFieldsOfSkipsUntagged(t *testing.T) {
for _, f := range fieldsOf(reflect.TypeOf(testSection{})) {
switch f.key {
case "", "-", "unexported":
t.Errorf("fieldsOf included field with key %q", f.key)
}
if f.key == "when" && f.section {
t.Error("time.Time field treated as a section")
}
if f.key == "inner" && !f.section {
t.Error("struct field not treated as a section")
}
if f.key == "items" && f.section {
t.Error("list of sections treated as a section")
}
}
}
func TestMapOf(t *testing.T) {
var s testSection
if err := applyDefaults(&s); err != nil {
t.Fatalf("applyDefaults: %v", err)
}
m := mapOf(s)
if got := m["str"]; got != "hello" {
t.Errorf("m[str] = %#v", got)
}
if got := m["on"]; got != true {
t.Errorf("m[on] (promoted) = %#v", got)
}
if got := m["dur"]; got != "1m30s" {
t.Errorf("m[dur] = %#v; want the duration as a string", got)
}
inner, ok := m["inner"].(map[string]any)
if !ok {
t.Fatalf("m[inner] = %#v; want nested map", m["inner"])
}
if inner["name"] != "inner" {
t.Errorf("m[inner][name] = %#v", inner["name"])
}
if _, ok := m["inner.name"]; ok {
t.Error("mapOf produced a flattened key")
}
// Pointer and value give the same result.
if !reflect.DeepEqual(m, mapOf(&s)) {
t.Error("mapOf(&s) != mapOf(s)")
}
}
func TestFlattenOfPrefix(t *testing.T) {
m := flattenOf("p", testSection{
Dur: 90 * time.Second,
})
for _, k := range []string{"p.str", "p.on", "p.inner.name"} {
if _, ok := m[k]; !ok {
t.Errorf("flattenOf missing %q in %v", k, m)
}
}
if got := m["p.dur"]; got != "1m30s" {
t.Errorf("m[p.dur] = %#v; want the duration as a string", got)
}
}
func TestStructValuePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("structValue(int) did not panic")
}
}()
structValue(42)
}
func TestApplyDefaults(t *testing.T) {
var s testSection
if err := applyDefaults(&s); err != nil {
t.Fatalf("applyDefaults: %v", err)
}
want := testSection{
testEmbedded: testEmbedded{
On: true,
},
Str: "hello",
Int: -42,
Uint: 7,
Float: 1.5,
Dur: 90 * time.Second,
Size: 32 << 10,
Inner: testInner{
Name: "inner",
},
}
if !reflect.DeepEqual(s, want) {
t.Errorf("applyDefaults = %+v; want %+v", s, want)
}
}
func TestApplyDefaultsInvalid(t *testing.T) {
var bad struct {
N int `cfg:"n" default:"nope"`
}
err := applyDefaults(&bad)
if err == nil || !strings.Contains(err.Error(), "invalid default for n") {
t.Errorf("applyDefaults = %v; want invalid default error", err)
}
var badNested struct {
Sub struct {
D time.Duration `cfg:"d" default:"forever"`
} `cfg:"sub"`
}
err = applyDefaults(&badNested)
if err == nil || !strings.Contains(err.Error(), "sub.d") {
t.Errorf("applyDefaults nested = %v; want error naming sub.d", err)
}
}
func TestTypeOf(t *testing.T) {
tests := []struct {
key string
want reflect.Kind
}{
{"str", reflect.String},
{"int", reflect.Int},
{"uint", reflect.Uint},
{"float", reflect.Float64},
{"on", reflect.Bool},
{"dur", reflect.String}, // durations are read as strings
{"size", reflect.String}, // byte sizes are read as strings
{"when", reflect.Struct},
{"slice", reflect.Slice},
{"items", reflect.Slice},
{"inner.name", reflect.String},
}
for _, tt := range tests {
got, err := typeOf(testSection{}, tt.key)
if err != nil || got != tt.want {
t.Errorf("typeOf(%q) = %v, %v; want %v", tt.key, got, err, tt.want)
}
}
}
func TestUnknownKeys(t *testing.T) {
keys := []string{
"missing",
"inner", // section without sub-key
"str.sub", // value with a sub-key
"inner.missing", // unknown nested key
"NoTag", // untagged field
"unexported",
"",
}
var s testSection
for _, key := range keys {
if k, err := typeOf(s, key); !errors.Is(err, errUnknownKey) ||
k != reflect.Invalid {
t.Errorf("typeOf(%q) = %v, %v; want Invalid, errUnknownKey", key, k, err)
}
if v, err := valueOf(s, key); !errors.Is(err, errUnknownKey) || v != nil {
t.Errorf("valueOf(%q) = %v, %v; want nil, errUnknownKey", key, v, err)
}
if err := setValue(&s, key, "x"); !errors.Is(err, errUnknownKey) {
t.Errorf("setValue(%q) = %v; want errUnknownKey", key, err)
}
}
}
func TestSetValue(t *testing.T) {
tests := []struct {
key string
value any
want any
}{
// strings are parsed
{"str", "world", "world"},
{"on", "false", false},
{"int", "123", 123},
{"int8", "-128", int8(-128)},
{"uint", "9", uint(9)},
{"uint16", "65535", uint16(65535)},
{"float", "2.25", 2.25},
{"float32", "0.5", float32(0.5)},
{"dur", "1h30m", "1h30m0s"}, // durations are given out as strings
{"size", "1 mb", "1MB"}, // byte sizes are given out as strings
{"size", "1.5KiB", "1536B"},
{"size", "4096", "4KiB"},
{"size", "-1", "-1"}, // no limit
{"slice", " a, b ,, c ", []string{"a", "b", "c"}},
{"slice", "", []string(nil)},
{"inner.name", "nested", "nested"},
// values of the field's type are used as-is
{"int", 5, 5},
{"on", true, true},
{"dur", 3 * time.Second, "3s"},
{"size", util.ByteSize(2048), "2KiB"},
{"slice", []string{"a", "b"}, []string{"a", "b"}},
{"slice", []string{}, []string(nil)}, // empty lists are stored as nil
// lists of sections are given out as lists of maps; items start
// from their defaults and their values are parsed
{
"items",
`[{"n": "1", "wait": "2s"}, {"name": "b", "n": 2}]`,
[]map[string]any{
{"name": "item", "n": 1, "wait": "2s"},
{"name": "b", "n": 2, "wait": "0s"},
},
},
{"items", []any{map[string]any{"name": "a", "n": 3}}, []map[string]any{
{"name": "a", "n": 3, "wait": "0s"},
}},
{"items", []map[string]any{{"name": "a"}}, []map[string]any{
{"name": "a", "n": 0, "wait": "0s"},
}},
{"items", []testItem{{
Name: "a",
N: 4,
}}, []map[string]any{
{"name": "a", "n": 4, "wait": "0s"},
}},
{"items", "", []map[string]any(nil)},
{"items", "[]", []map[string]any(nil)},
{"items", []testItem{}, []map[string]any(nil)},
{"items", nil, []map[string]any(nil)},
{"when", time.Unix(0, 0).UTC(), time.Unix(0, 0).UTC()},
}
for _, tt := range tests {
var s testSection
if err := setValue(&s, tt.key, tt.value); err != nil {
t.Errorf("setValue(%q, %#v): %v", tt.key, tt.value, err)
continue
}
got, err := valueOf(s, tt.key)
if err != nil || !reflect.DeepEqual(got, tt.want) {
t.Errorf(
"after setValue(%q, %#v): valueOf = %#v, %v; want %#v",
tt.key,
tt.value,
got,
err,
tt.want,
)
}
}
}
func TestSetValueErrors(t *testing.T) {
tests := []struct {
key string
value any
want string
}{
{"int", "abc", "unable to convert"},
{"int8", "200", "unable to convert"}, // out of range
{"uint", "-1", "unable to convert"},
{"float", "x", "unable to convert"},
{"on", "maybe", "unable to convert"},
{"dur", "soon", "unable to convert to duration"},
{"items", "not json", "unable to convert to a list of config.testItem"},
{"items", `[{"bogus": 1}]`, `item 0: config: unknown configuration key "bogus"`},
{"items", `[{"n": "x"}]`, "item 0: n: unable to convert"},
{"items", []any{"a"}, "item 0: config: unsupported type string"},
{"items", 5, "unsupported type int"},
{"size", "lots", "unable to convert to byte size"},
{"size", "-1KB", "unable to convert to byte size"},
{"size", "16EiB", "too large"},
{"when", "2024-01-01", "unsupported type string"},
{"int", 1.5, "unsupported type float64"},
{"int", int64(1), "unsupported type int64"},
{"str", nil, "unsupported type <nil>"},
}
for _, tt := range tests {
var s testSection
before := s
err := setValue(&s, tt.key, tt.value)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf(
"setValue(%q, %#v) = %v; want error containing %q",
tt.key,
tt.value,
err,
tt.want,
)
}
if !reflect.DeepEqual(s, before) {
t.Errorf(
"setValue(%q, %#v) modified the section on error", tt.key, tt.value,
)
}
}
}
// A []string is given out as a copy, so changing it leaves the section as it
// was.
func TestValueOfSliceIsACopy(t *testing.T) {
s := testSection{
Slice: []string{"a", "b"},
}
got, err := valueOf(s, "slice")
if err != nil {
t.Fatal(err)
}
got.([]string)[0] = "changed"
if s.Slice[0] != "a" {
t.Errorf("changing valueOf's slice changed the section: %v", s.Slice)
}
}
func TestSetValueValidator(t *testing.T) {
var s testValidated
if err := setValue(&s, "value", "good"); err != nil || s.Value != "good" {
t.Errorf("setValue(good) = %v, Value = %q", err, s.Value)
}
if err := setValue(&s, "value", "bad"); !errors.Is(err, errRejected) {
t.Errorf("setValue(bad) = %v; want errRejected", err)
}
if s.Value != "good" {
t.Errorf("rejected value was stored: %q", s.Value)
}
// The validator of the section holding the field is used for nested keys.
var outer struct {
V testValidated `cfg:"v"`
}
if err := setValue(&outer, "v.value", "bad"); !errors.Is(err, errRejected) {
t.Errorf("nested setValue(bad) = %v; want errRejected", err)
}
}
// parseString handles string fields too, although convert assigns a string
// to a string field before reaching it.
func TestParseStringString(t *testing.T) {
v, err := parseString("text", reflect.TypeOf(""))
if err != nil || v.Interface() != "text" {
t.Errorf("parseString(text, string) = %v, %v", v, err)
}
}