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 "}, } 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) } }