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