Files
ConfigKit/internal/engine/validation_test.go

215 lines
7.8 KiB
Go

package engine
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)
}
}