Add single-source loading and version checks
This commit is contained in:
300
load_test.go
Normal file
300
load_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
input := []byte("name: configured\n")
|
||||
temporary := t.TempDir()
|
||||
filename := filepath.Join(temporary, "config.yml")
|
||||
if err := os.WriteFile(filename, input, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
source func() Source
|
||||
want string
|
||||
wantSources int
|
||||
}{
|
||||
{name: "file", source: func() Source { return File(filename) }, want: "configured", wantSources: 1},
|
||||
{name: "bytes", source: func() Source { return Bytes("memory.yml", input) }, want: "configured", wantSources: 1},
|
||||
{name: "reader", source: func() Source { return Reader("reader.yml", strings.NewReader(string(input))) }, want: "configured", wantSources: 1},
|
||||
{name: "no source", source: NoSource, want: "default", wantSources: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var defaultsCalls atomic.Int32
|
||||
result, err := Load(test.source(), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
defaultsCalls.Add(1)
|
||||
return config{Name: "default"}
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Name != test.want || defaultsCalls.Load() != 1 || len(result.Sources()) != test.wantSources {
|
||||
t.Fatalf("result = %#v, calls = %d, sources = %#v", result.Config, defaultsCalls.Load(), result.Sources())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
SchemaVersion *string `yaml:"schema_version"`
|
||||
}
|
||||
versionPath := Keys("schema_version")
|
||||
tests := []struct {
|
||||
name string
|
||||
source Source
|
||||
want Version
|
||||
wantLine int
|
||||
wantErr Code
|
||||
returnOnly bool
|
||||
}{
|
||||
{name: "absent", source: Bytes("absent.yml", []byte("{}\n")), want: Version{}, wantLine: 1},
|
||||
{name: "no source absent", source: NoSource(), want: Version{}},
|
||||
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
|
||||
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType},
|
||||
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
called := false
|
||||
rule := &VersionRule{Path: versionPath, Check: func(version Version, validation *Validation) error {
|
||||
called = true
|
||||
if test.returnOnly {
|
||||
return errors.New("CANARY_VERSION_ERROR")
|
||||
}
|
||||
if version != test.want {
|
||||
t.Errorf("version = %#v, want %#v", version, test.want)
|
||||
}
|
||||
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
if test.wantErr != "" {
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error type = %T", err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantErr || strings.Contains(diagnostics[0].Message, "CANARY") {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if test.wantErr == CodeVersionType && called {
|
||||
t.Fatal("checker ran for a non-string version")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostics := result.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning || diagnostics[0].Severity != SeverityWarning {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if test.wantLine == 0 && (diagnostics[0].Primary != nil || len(diagnostics[0].Related) != 0) {
|
||||
t.Fatalf("synthetic diagnostic locations = %#v", diagnostics[0])
|
||||
}
|
||||
if test.wantLine != 0 && (diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != test.wantLine || len(diagnostics[0].Related) != 1) {
|
||||
t.Fatalf("diagnostic locations = %#v", diagnostics[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
var defaultsCalls atomic.Int32
|
||||
result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
defaultsCalls.Add(1)
|
||||
return config{Name: "default"}
|
||||
},
|
||||
Version: &VersionRule{Path: Keys("schema_version"), Check: func(_ Version, validation *Validation) error {
|
||||
validation.Error(Keys("schema_version"), "schema_version is required")
|
||||
return errors.New("CANARY_RETURNED_ERROR")
|
||||
}},
|
||||
}})
|
||||
if result != nil || err == nil || defaultsCalls.Load() != 0 {
|
||||
t.Fatalf("result = %#v, err = %v, calls = %d", result, err, defaultsCalls.Load())
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error type = %T", err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionError || strings.Contains(diagnostics[0].Message, "CANARY") {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nested struct {
|
||||
Kept string `yaml:"kept"`
|
||||
Set string `yaml:"set"`
|
||||
}
|
||||
type config struct {
|
||||
Nested nested `yaml:"nested"`
|
||||
Nullable map[string]string `yaml:"nullable"`
|
||||
}
|
||||
result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
kept, ok := metadata.Lookup(Keys("nested", "kept"))
|
||||
if !ok || kept.Explicit || kept.Origin.Layer != LayerDefault {
|
||||
t.Fatalf("kept = %#v, %v", kept, ok)
|
||||
}
|
||||
set, ok := metadata.Lookup(Keys("nested", "set"))
|
||||
if !ok || !set.Explicit || set.Origin.Layer != LayerBase || len(set.Origin.Locations) != 1 {
|
||||
t.Fatalf("set = %#v, %v", set, ok)
|
||||
}
|
||||
nullable, ok := metadata.Lookup(Keys("nullable"))
|
||||
if !ok || !nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerBase {
|
||||
t.Fatalf("nullable = %#v, %v", nullable, ok)
|
||||
}
|
||||
if metadata.IsExplicit(Keys("nested", "kept")) || !metadata.IsExplicit(Keys("nested", "set")) {
|
||||
t.Fatal("explicit presence disagrees with lookup")
|
||||
}
|
||||
if history := metadata.History(Keys("nested", "set")); len(history) != 2 || history[0].Origin.Layer != LayerDefault || history[1].Origin.Layer != LayerBase || len(history[1].Previous) != 1 {
|
||||
t.Fatalf("history = %#v", history)
|
||||
}
|
||||
|
||||
set.Origin.Locations[0].Line = 99
|
||||
history := metadata.History(Keys("nested", "set"))
|
||||
history[1].Origin.Locations[0].Line = 99
|
||||
sources := result.Sources()
|
||||
sources[0].Label = "changed"
|
||||
diagnostics := result.Diagnostics()
|
||||
if diagnostics != nil {
|
||||
diagnostics[0].Message = "changed"
|
||||
}
|
||||
fresh, _ := result.Metadata().Lookup(Keys("nested", "set"))
|
||||
if fresh.Origin.Locations[0].Line == 99 || result.Sources()[0].Label != "metadata.yml" || result.Diagnostics() != nil {
|
||||
t.Fatal("result inspection aliases internal records")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
validSource := func() Source { return Bytes("options.yml", []byte("name: value\n")) }
|
||||
tests := []struct {
|
||||
name string
|
||||
source Source
|
||||
options []Options[config]
|
||||
code Code
|
||||
}{
|
||||
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
|
||||
{name: "resolver", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Resolve: func(*config, *Resolution) error { return nil }}}}, code: CodeOptionsUnavailable},
|
||||
{name: "validator", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Validate: func(*config, *Validation) {}}}}, code: CodeOptionsUnavailable},
|
||||
{name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable},
|
||||
{name: "imports", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsUnavailable},
|
||||
{name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable},
|
||||
{name: "filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsUnavailable},
|
||||
{name: "overrides", source: validSource(), options: []Options[config]{{Overrides: []Override{{Path: Keys("name"), Value: "other", Flag: "--name"}}}}, code: CodeOptionsUnavailable},
|
||||
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},
|
||||
{name: "no-source filesystem", source: NoSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := Load(test.source, test.options...)
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n _configkit: {}\n")))
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type invalidSchema struct {
|
||||
Channel chan int `yaml:"channel"`
|
||||
}
|
||||
type validConfig struct {
|
||||
Known int `yaml:"known"`
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() (any, error)
|
||||
code Code
|
||||
}{
|
||||
{name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
|
||||
{name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
|
||||
{name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := test.run()
|
||||
if !isNilInterface(result) || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != test.code {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isNilInterface(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
return reflected.Kind() == reflect.Pointer && reflected.IsNil()
|
||||
}
|
||||
Reference in New Issue
Block a user