Complete single-source semantic validation
This commit is contained in:
214
validation_test.go
Normal file
214
validation_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Primary string `yaml:"primary"`
|
||||
Missing string `yaml:"missing"`
|
||||
Dynamic map[string]any `yaml:"dynamic"`
|
||||
}
|
||||
result, err := Load(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{
|
||||
Validate: func(_ *config, validation *Validation) {
|
||||
validation.Error(Keys("missing"), "is required", Keys("primary"))
|
||||
validation.Error(Keys("dynamic", "missing"), "dynamic entry is required")
|
||||
validation.Warning(Keys("primary"), "primary value is deprecated")
|
||||
},
|
||||
}})
|
||||
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) != 3 {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
byPath := make(map[string]Diagnostic)
|
||||
for _, diagnostic := range diagnostics {
|
||||
byPath[diagnostic.Path.String()] = diagnostic
|
||||
}
|
||||
missing := byPath["/missing"]
|
||||
if missing.Code != CodeValidateError || missing.Primary == nil || missing.Primary.Line != 1 || len(missing.Related) != 1 || missing.Related[0].Line != 1 {
|
||||
t.Fatalf("missing diagnostic = %#v", missing)
|
||||
}
|
||||
dynamic := byPath["/dynamic/missing"]
|
||||
if dynamic.Code != CodeValidateError || dynamic.Primary == nil || dynamic.Primary.Line != 2 {
|
||||
t.Fatalf("dynamic diagnostic = %#v", dynamic)
|
||||
}
|
||||
if warning := byPath["/primary"]; warning.Code != CodeValidateWarning || warning.Severity != SeverityWarning {
|
||||
t.Fatalf("warning = %#v", warning)
|
||||
}
|
||||
if len(loadError.Sources()) != 1 {
|
||||
t.Fatalf("sources = %#v", loadError.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRejectsUnknownStructPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Known string `yaml:"known"`
|
||||
}
|
||||
result, err := Load(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{
|
||||
Validate: func(_ *config, validation *Validation) {
|
||||
validation.Error(Keys("unknown"), "must be configured")
|
||||
},
|
||||
}})
|
||||
assertLoadDiagnostic(t, result, err, CodeValidateInvalidPath)
|
||||
}
|
||||
|
||||
func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Value int `yaml:"value"`
|
||||
Items map[string]int `yaml:"items"`
|
||||
}
|
||||
result, err := Load(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{
|
||||
Validate: func(configuration *config, _ *Validation) {
|
||||
configuration.Value = 2
|
||||
configuration.Items["one"] = 2
|
||||
},
|
||||
}})
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paths []string
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if diagnostic.Code != CodeValidateMutation || diagnostic.Message != "application validation mutated the configuration" {
|
||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||
}
|
||||
paths = append(paths, diagnostic.Path.String())
|
||||
}
|
||||
if !reflect.DeepEqual(paths, []string{"/items/one", "/value"}) {
|
||||
t.Fatalf("mutation paths = %#v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
Value int `yaml:"value"`
|
||||
}
|
||||
t.Run("warnings continue", func(t *testing.T) {
|
||||
var events []string
|
||||
definition := Definition[config]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
||||
events = append(events, "version")
|
||||
validation.Warning(Keys("version"), "version warning")
|
||||
return nil
|
||||
}},
|
||||
Defaults: func() config {
|
||||
events = append(events, "defaults")
|
||||
return config{Value: 1}
|
||||
},
|
||||
Resolve: func(_ *config, resolution *Resolution) error {
|
||||
events = append(events, "resolve")
|
||||
resolution.Warning(Keys("value"), "resolution warning")
|
||||
return nil
|
||||
},
|
||||
Validate: func(_ *config, validation *Validation) {
|
||||
events = append(events, "validate")
|
||||
validation.Warning(Keys("value"), "validation warning")
|
||||
},
|
||||
}
|
||||
result, err := Load(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(events, []string{"version", "defaults", "resolve", "validate"}) || len(result.Diagnostics()) != 3 {
|
||||
t.Fatalf("events = %#v, diagnostics = %#v", events, result.Diagnostics())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version error stops later hooks", func(t *testing.T) {
|
||||
var events []string
|
||||
definition := Definition[config]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
|
||||
events = append(events, "version")
|
||||
validation.Error(Keys("version"), "unsupported version")
|
||||
return nil
|
||||
}},
|
||||
Defaults: func() config { events = append(events, "defaults"); return config{} },
|
||||
Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil },
|
||||
Validate: func(_ *config, _ *Validation) { events = append(events, "validate") },
|
||||
}
|
||||
result, err := Load(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition})
|
||||
assertLoadDiagnostic(t, result, err, CodeVersionError)
|
||||
if !reflect.DeepEqual(events, []string{"version"}) {
|
||||
t.Fatalf("events = %#v", events)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolution error stops validation", func(t *testing.T) {
|
||||
var validated bool
|
||||
result, err := Load(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{
|
||||
Resolve: func(_ *config, resolution *Resolution) error {
|
||||
resolution.Error(Keys("value"), "cannot resolve")
|
||||
return nil
|
||||
},
|
||||
Validate: func(_ *config, _ *Validation) { validated = true },
|
||||
}})
|
||||
assertLoadDiagnostic(t, result, err, CodeResolveError)
|
||||
if validated {
|
||||
t.Fatal("validation ran after resolution error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode error stops application hooks", func(t *testing.T) {
|
||||
var resolved, validated bool
|
||||
result, err := Load(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{
|
||||
Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil },
|
||||
Validate: func(_ *config, _ *Validation) { validated = true },
|
||||
}})
|
||||
assertLoadDiagnostic(t, result, err, CodeDecodeType)
|
||||
if resolved || validated {
|
||||
t.Fatalf("later hooks ran: resolve=%v validate=%v", resolved, validated)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Input int `yaml:"input"`
|
||||
Derived int `yaml:"derived"`
|
||||
}
|
||||
result, err := Load(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Derived: 1} },
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(configuration *config, validation *Validation) {
|
||||
if configuration.Derived != 6 {
|
||||
validation.Error(Keys("derived"), "derived value is inconsistent", Keys("input"))
|
||||
}
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input, _ := result.Metadata().Lookup(Keys("input"))
|
||||
derived, _ := result.Metadata().Lookup(Keys("derived"))
|
||||
history := result.Metadata().History(Keys("derived"))
|
||||
if result.Config != (config{Input: 3, Derived: 6}) || !input.Explicit || input.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault || history[len(history)-1].Dependencies[0].String() != "/input" {
|
||||
t.Fatalf("config/metadata = %#v / %#v / %#v / %#v", result.Config, input, derived, history)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user