diff --git a/README.md b/README.md index 6e58635..197970c 100644 --- a/README.md +++ b/README.md @@ -558,6 +558,13 @@ func Example_nameInference() { } ``` +#### Testing helpers + +Package [govytest](./pkg/govytest/) provides utilities which aid the process of +writing unit tests for validation rules defined with govy. +Checkout [testable examples](https://pkg.go.dev/github.com/nobl9/govy/pkg/govytest#pkg-examples) +for a concise overview of the package's capabilities. + ## Rationale Why was this library created? diff --git a/cspell.yaml b/cspell.yaml index bcec7fe..666e38a 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -44,6 +44,7 @@ words: - govulncheck - govy - govyconfig + - govytest - ldflags - nobl - pkgs diff --git a/internal/assert/assert.go b/internal/assert/assert.go index 9d2abb2..836c49f 100644 --- a/internal/assert/assert.go +++ b/internal/assert/assert.go @@ -22,7 +22,7 @@ func Require(t *testing.T, isPassing bool) { func Equal(t *testing.T, expected, actual interface{}) bool { t.Helper() if !areEqual(expected, actual) { - return fail(t, "Expected %v, got %v", expected, actual) + return fail(t, "Expected: %v, actual: %v", expected, actual) } return true } @@ -36,11 +36,20 @@ func True(t *testing.T, actual bool) bool { return true } +// True fails the test if the actual value is not false. +func False(t *testing.T, actual bool) bool { + t.Helper() + if actual { + return fail(t, "Should be false") + } + return true +} + // Len fails the test if the object is not of the expected length. func Len(t *testing.T, object interface{}, length int) bool { t.Helper() if actual := getLen(object); actual != length { - return fail(t, "Expected length %d, got %d", length, actual) + return fail(t, "Expected length: %d, actual: %d", length, actual) } return true } @@ -53,7 +62,7 @@ func IsType[T any](t *testing.T, object interface{}) bool { case T: return true default: - return fail(t, "Expected type %T, got %T", *new(T), object) + return fail(t, "Expected type: %T, actual: %T", *new(T), object) } } @@ -61,7 +70,7 @@ func IsType[T any](t *testing.T, object interface{}) bool { func Error(t *testing.T, err error) bool { t.Helper() if err == nil { - return fail(t, "An error is expected but got nil.") + return fail(t, "An error is expected but actual nil.") } return true } @@ -82,7 +91,7 @@ func EqualError(t *testing.T, expected error, actual string) bool { return false } if expected.Error() != actual { - return fail(t, "Expected error message %q, got %q", expected.Error(), actual) + return fail(t, "Expected error message: %q, actual: %q", expected.Error(), actual) } return true } @@ -94,7 +103,7 @@ func ErrorContains(t *testing.T, expected error, contains string) bool { return false } if !strings.Contains(expected.Error(), contains) { - return fail(t, "Expected error message to contain %q, got %q", contains, expected.Error()) + return fail(t, "Expected error message to contain %q, actual %q", contains, expected.Error()) } return true } @@ -103,7 +112,7 @@ func ErrorContains(t *testing.T, expected error, contains string) bool { func ElementsMatch[T comparable](t *testing.T, expected, actual []T) bool { t.Helper() if len(expected) != len(actual) { - return fail(t, "Slices are not equal in length, expected: %d, got: %d", len(expected), len(actual)) + return fail(t, "Slices are not equal in length, expected: %d, actual: %d", len(expected), len(actual)) } actualVisited := make([]bool, len(actual)) diff --git a/internal/errors.go b/internal/errors.go index cd11782..a06a06c 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -5,11 +5,16 @@ import ( "fmt" "reflect" "strings" + "time" ) // JoinErrors joins multiple errors into a single pretty-formatted string. +// JoinErrors assumes the errors are not nil, if this presumption is broken the formatting might not be correct. func JoinErrors[T error](b *strings.Builder, errs []T, indent string) { for i, err := range errs { + if error(err) == nil { + continue + } buildErrorMessage(b, err.Error(), indent) if i < len(errs)-1 { b.WriteString("\n") @@ -32,13 +37,17 @@ func buildErrorMessage(b *strings.Builder, errMsg, indent string) { var newLineReplacer = strings.NewReplacer("\n", "\\n", "\r", "\\r") // PropertyValueString returns the string representation of the given value. -// Structs, interfaces, maps and slices are converted to compacted JSON strings. +// Structs, interfaces, maps and slices are converted to compacted JSON strings (see struct exceptions below). // It tries to improve readability by: -// - limiting the string to 100 characters -// - removing leading and trailing whitespaces -// - escaping newlines -// If value is a struct implementing [fmt.Stringer] String method will be used -// only if the struct does not contain any JSON tags. +// - limiting the string to 100 characters +// - removing leading and trailing whitespaces +// - escaping newlines +// +// If value is a struct implementing [fmt.Stringer] [fmt.Stringer.String] method will be used only if: +// - the struct does not contain any JSON tags +// - the struct is not empty or it is empty but does not have any fields +// +// If a value is a struct of type [time.Time] it will be formatted using [time.RFC3339] layout. func PropertyValueString(v interface{}) string { if v == nil { return "" @@ -48,13 +57,18 @@ func PropertyValueString(v interface{}) string { var s string switch ft.Kind() { case reflect.Interface, reflect.Map, reflect.Slice: - if reflect.ValueOf(v).IsZero() { + if rv.IsZero() { break } raw, _ := json.Marshal(v) s = string(raw) case reflect.Struct: - if reflect.ValueOf(v).IsZero() { + // If the struct is empty and it has. + if rv.IsZero() && rv.NumField() != 0 { + break + } + if timeDate, ok := v.(time.Time); ok { + s = timeDate.Format(time.RFC3339) break } if stringer, ok := v.(fmt.Stringer); ok && !hasJSONTags(v, rv.Kind() == reflect.Pointer) { @@ -63,6 +77,14 @@ func PropertyValueString(v interface{}) string { } raw, _ := json.Marshal(v) s = string(raw) + case reflect.Ptr: + if rv.IsNil() { + return "" + } + deref := rv.Elem().Interface() + return PropertyValueString(deref) + case reflect.Func: + return "func" case reflect.Invalid: return "" default: diff --git a/internal/errors_test.go b/internal/errors_test.go new file mode 100644 index 0000000..cdb8eea --- /dev/null +++ b/internal/errors_test.go @@ -0,0 +1,86 @@ +package internal + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/nobl9/govy/internal/assert" +) + +func TestJoinErrors(t *testing.T) { + tests := []struct { + in []error + out string + }{ + {nil, ""}, + {[]error{nil, nil}, ""}, + // Incorrect formatting, this test case ensures the function does not panic. + {[]error{nil, errors.New("some error"), nil}, " - some error\n"}, + {[]error{errors.New("- some error")}, " - some error"}, + {[]error{errors.New("- some error"), errors.New("some other error")}, " - some error\n - some other error"}, + } + for _, tc := range tests { + b := strings.Builder{} + JoinErrors(&b, tc.in, " ") + assert.Equal(t, tc.out, b.String()) + } + t.Run("custom indent", func(t *testing.T) { + b := strings.Builder{} + JoinErrors(&b, []error{errors.New("some error")}, " ") + assert.Equal(t, " - some error", b.String()) + }) +} + +func TestPropertyValueString(t *testing.T) { + tests := []struct { + in any + out string + }{ + {nil, ""}, + {any(nil), ""}, + {false, "false"}, + {true, "true"}, + {any("this"), "this"}, + {func() {}, "func"}, + {ptr("this"), "this"}, + {struct{ This string }{This: "this"}, `{"This":"this"}`}, + {ptr(struct{ This string }{This: "this"}), `{"This":"this"}`}, + {struct { + This string `json:"this"` + }{This: "this"}, `{"this":"this"}`}, + {map[string]string{"this": "this"}, `{"this":"this"}`}, + {[]string{"this", "that"}, `["this","that"]`}, + {0, "0"}, + {0.0, "0"}, + {2, "2"}, + {0.123, "0.123"}, + {time.Second, "1s"}, + {time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "2024-01-01T00:00:00Z"}, + {mockEmptyStringer{}, "mock"}, + {mockStringerWithTags{}, ""}, + {mockStringerWithTags{Mock: "mock"}, `{"mock":"mock"}`}, + {ptr(mockEmptyStringer{}), "mock"}, + } + for _, tc := range tests { + got := PropertyValueString(tc.in) + assert.Equal(t, tc.out, got) + } +} + +type mockEmptyStringer struct{} + +func (m mockEmptyStringer) String() string { + return "mock" +} + +type mockStringerWithTags struct { + Mock string `json:"mock"` +} + +func (m mockStringerWithTags) String() string { + return "stringer" +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/helpers.go b/internal/helpers.go index c27480d..385e56e 100644 --- a/internal/helpers.go +++ b/internal/helpers.go @@ -12,7 +12,10 @@ const RequiredErrorMessage = "property is required but was empty" const RequiredErrorCodeString = "required" // IsEmptyFunc verifies if the value is zero value of its type. -func IsEmptyFunc(v interface{}) bool { +func IsEmpty(v interface{}) bool { + if v == nil { + return true + } rv := reflect.ValueOf(v) return rv.Kind() == 0 || rv.IsZero() } diff --git a/internal/helpers_test.go b/internal/helpers_test.go new file mode 100644 index 0000000..49323c4 --- /dev/null +++ b/internal/helpers_test.go @@ -0,0 +1,38 @@ +package internal + +import ( + "testing" + + "github.com/nobl9/govy/internal/assert" +) + +func TestIsEmpty(t *testing.T) { + tests := []struct { + in any + out bool + }{ + {nil, true}, + {any(nil), true}, + {any(""), true}, + {"", true}, + {0, true}, + {0.0, true}, + {false, true}, + {struct{}{}, true}, + {map[int]string{}, false}, + {[]int{}, false}, + {ptr(struct{}{}), false}, + {ptr(""), false}, + {make(chan int), false}, + {any("this"), false}, + {0.123, false}, + {true, false}, + {struct{ This string }{This: "this"}, false}, + {map[int]string{0: ""}, false}, + {ptr(struct{ This string }{This: "this"}), false}, + {[]int{0}, false}, + } + for _, tc := range tests { + assert.Equal(t, tc.out, IsEmpty(tc.in)) + } +} diff --git a/pkg/govy/errors.go b/pkg/govy/errors.go index fb04901..0d18f02 100644 --- a/pkg/govy/errors.go +++ b/pkg/govy/errors.go @@ -110,7 +110,7 @@ func NewPropertyError(propertyName string, propertyValue interface{}, errs ...er type PropertyError struct { PropertyName string `json:"propertyName"` - PropertyValue string `json:"propertyValue"` + PropertyValue string `json:"propertyValue,omitempty"` // IsKeyError is set to true if the error was created through map key validation. // PropertyValue in this scenario will be the key value, equal to the last element of PropertyName path. IsKeyError bool `json:"isKeyError,omitempty"` diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 6d5c39a..fc99218 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -51,7 +51,7 @@ func Transform[T, N, S any](getter PropertyGetter[T, S], transform Transformer[T name: inferName(), transformGetter: func(s S) (transformed N, original any, err error) { v := getter(s) - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { return transformed, nil, emptyErr{} } transformed, err = transform(v) @@ -280,7 +280,7 @@ func (r PropertyRules[T, S]) getValue(st S) (v T, skip bool, propErr *PropertyEr } return v, false, NewPropertyError(r.name, propValue, err) } - isEmpty := isEmptyError || (!r.isPointer && internal.IsEmptyFunc(v)) + isEmpty := isEmptyError || (!r.isPointer && internal.IsEmpty(v)) // If the value is not empty we simply return it. if !isEmpty { return v, false, nil diff --git a/pkg/govytest/assert.go b/pkg/govytest/assert.go new file mode 100644 index 0000000..ef1c183 --- /dev/null +++ b/pkg/govytest/assert.go @@ -0,0 +1,220 @@ +package govytest + +import ( + "encoding/json" + "strings" + + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/rules" +) + +// testingT is an interface that is compatible with *testing.T. +// It is used to make the functions in this package testable. +type testingT interface { + Errorf(format string, args ...any) + Error(args ...any) + Helper() +} + +// ExpectedRuleError defines the expectations for the asserted error. +// Its fields are used to find and match an actual [govy.RuleError]. +type ExpectedRuleError struct { + // Required. Matched against [govy.PropertyError.PropertyName]. + PropertyName string `json:"propertyName"` + // Optional. Matched against [govy.RuleError.Code]. + Code govy.ErrorCode `json:"code,omitempty"` + // Optional. Matched against [govy.RuleError.Message]. + Message string `json:"message,omitempty"` + // Optional. Matched against [govy.RuleError.Message] (partial). + ContainsMessage string `json:"containsMessage,omitempty"` + // Optional. Matched against [govy.PropertyError.IsKeyError]. + IsKeyError bool `json:"isKeyError,omitempty"` +} + +// expectedRuleErrorValidation defines the validation rules for [ExpectedRuleError]. +var expectedRuleErrorValidation = govy.New( + govy.For(func(e ExpectedRuleError) string { return e.PropertyName }). + WithName("propertyName"). + Required(), + govy.For(govy.GetSelf[ExpectedRuleError]()). + Rules(rules.OneOfProperties(map[string]func(e ExpectedRuleError) any{ + "code": func(e ExpectedRuleError) any { return e.Code }, + "message": func(e ExpectedRuleError) any { return e.Message }, + "containsMessage": func(e ExpectedRuleError) any { return e.ContainsMessage }, + })), +).InferName() + +// Validate checks if the [ExpectedRuleError] is valid. +func (e ExpectedRuleError) Validate() error { + return expectedRuleErrorValidation.Validate(e) +} + +// AssertNoError asserts that the provided error is nil. +// If the error is not nil and of type [govy.ValidatorError] it will try +// encoding it to JSON and pretty printing the encountered error. +// +// It returns true if the error is nil, false otherwise. +func AssertNoError(t testingT, err error) bool { + t.Helper() + if err == nil { + return true + } + errMsg := err.Error() + if vErr, ok := err.(*govy.ValidatorError); ok { + encErr, _ := json.MarshalIndent(vErr, "", " ") + errMsg = string(encErr) + } + t.Errorf("Received unexpected error:\n%+s", errMsg) + return false +} + +// AssertError asserts that the given error has: +// - type equal to [*govy.ValidatorError] +// - the expected number of [govy.RuleError] +// - at least one error which matches each of the provided [ExpectedRuleError] +// +// [ExpectedRuleError] and actual error are considered equal if their [] to the same property and either: +// - [ExpectedRuleError.Code] is equal to [govy.RuleError.Code] +// - [ExpectedRuleError.Message] is equal to [govy.RuleError.Message] +// - [ExpectedRuleError.ContainsMessage] is part of [govy.RuleError.Message] +// +// If [ExpectedRuleError.IsKeyError] is provided it will be required to match +// the actual [govy.PropertyError.IsKeyError]. +// +// It returns true if the error matches the expectations, false otherwise. +func AssertError( + t testingT, + err error, + expectedErrors ...ExpectedRuleError, +) bool { + t.Helper() + + if !validateExpectedErrors(t, expectedErrors) { + return false + } + validatorErr, ok := assertValidatorError(t, err) + if !ok { + return false + } + if !assertErrorsCount(t, validatorErr, len(expectedErrors)) { + return false + } + matched := make(matchedErrors, len(expectedErrors)) + for _, expected := range expectedErrors { + if !assertErrorMatches(t, validatorErr, expected, matched) { + return false + } + } + return true +} + +func validateExpectedErrors(t testingT, expectedErrors []ExpectedRuleError) bool { + t.Helper() + if len(expectedErrors) == 0 { + t.Errorf("%T must not be empty.", expectedErrors) + return false + } + for _, expected := range expectedErrors { + if err := expected.Validate(); err != nil { + t.Error(err.Error()) + return false + } + } + return true +} + +func assertValidatorError(t testingT, err error) (*govy.ValidatorError, bool) { + t.Helper() + + if err == nil { + t.Errorf("Input error should not be nil.") + return nil, false + } + validatorErr, ok := err.(*govy.ValidatorError) + if !ok { + t.Errorf("Input error should be of type %T.", &govy.ValidatorError{}) + } + return validatorErr, ok +} + +func assertErrorsCount( + t testingT, + validatorErr *govy.ValidatorError, + expectedErrorsCount int, +) bool { + t.Helper() + + actualErrorsCount := 0 + for _, actual := range validatorErr.Errors { + actualErrorsCount += len(actual.Errors) + } + if expectedErrorsCount != actualErrorsCount { + t.Errorf("%T contains different number of errors than expected, expected: %d, actual: %d.", + validatorErr, expectedErrorsCount, actualErrorsCount) + return false + } + return true +} + +type matchedErrors map[int]map[int]struct{} + +func (m matchedErrors) Add(propertyErrorIdx, ruleErrorIdx int) bool { + if _, ok := m[propertyErrorIdx]; !ok { + m[propertyErrorIdx] = make(map[int]struct{}) + } + _, ok := m[propertyErrorIdx][ruleErrorIdx] + m[propertyErrorIdx][ruleErrorIdx] = struct{}{} + return ok +} + +func assertErrorMatches( + t testingT, + validatorErr *govy.ValidatorError, + expected ExpectedRuleError, + matched matchedErrors, +) bool { + t.Helper() + + multiMatch := false + for i, actual := range validatorErr.Errors { + if actual.PropertyName != expected.PropertyName { + continue + } + if expected.IsKeyError != actual.IsKeyError { + continue + } + for j, actualRuleErr := range actual.Errors { + actualMessage := actualRuleErr.Error() + matchedCtr := 0 + if expected.Message == "" || expected.Message == actualMessage { + matchedCtr++ + } + if expected.ContainsMessage == "" || + strings.Contains(actualMessage, expected.ContainsMessage) { + matchedCtr++ + } + if expected.Code == "" || + expected.Code == actualRuleErr.Code || + govy.HasErrorCode(actualRuleErr, expected.Code) { + matchedCtr++ + } + if matchedCtr == 3 { + if matched.Add(i, j) { + multiMatch = true + continue + } + return true + } + } + } + + if multiMatch { + t.Errorf("Actual error was matched multiple times. Consider providing a more specific %T list.", expected) + return false + } + encExpected, _ := json.MarshalIndent(expected, "", " ") + encActual, _ := json.MarshalIndent(validatorErr.Errors, "", " ") + t.Errorf("Expected error was not found.\nEXPECTED:\n%s\nACTUAL:\n%s", + string(encExpected), string(encActual)) + return false +} diff --git a/pkg/govytest/assert_test.go b/pkg/govytest/assert_test.go new file mode 100644 index 0000000..e51c5f8 --- /dev/null +++ b/pkg/govytest/assert_test.go @@ -0,0 +1,299 @@ +package govytest_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/nobl9/govy/internal/assert" + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/govytest" +) + +func TestAssertNoError(t *testing.T) { + t.Run("no error", func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertNoError(mt, nil) + assert.True(t, ok) + }) + t.Run("generic error", func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertNoError(mt, errors.New("this")) + assert.False(t, ok) + assert.Equal(t, "Received unexpected error:\nthis", mt.recordedError) + }) + t.Run("validator error", func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertNoError(mt, &govy.ValidatorError{Name: "Service"}) + assert.False(t, ok) + assert.Equal(t, `Received unexpected error: +{ + "errors": null, + "name": "Service" +}`, mt.recordedError) + }) +} + +func TestAssertError(t *testing.T) { + tests := map[string]struct { + ok bool + inputError error + expectedErrors []govytest.ExpectedRuleError + out string + }{ + "no expected errors": { + ok: false, + out: "[]govytest.ExpectedRuleError must not be empty.", + }, + "invalid input": { + ok: false, + expectedErrors: []govytest.ExpectedRuleError{{}}, + out: `Validation for ExpectedRuleError has failed for the following properties: + - 'propertyName': + - property is required but was empty + - one of [code, containsMessage, message] properties must be set, none was provided`, + }, + "nil error": { + ok: false, + inputError: nil, + expectedErrors: []govytest.ExpectedRuleError{{PropertyName: "this", Message: "test"}}, + out: "Input error should not be nil.", + }, + "wrong type of error": { + ok: false, + inputError: errors.New(""), + expectedErrors: []govytest.ExpectedRuleError{{PropertyName: "this", Message: "test"}}, + out: "Input error should be of type *govy.ValidatorError.", + }, + "errors count mismatch": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + {Errors: []*govy.RuleError{{}, {}}}, + }}, + expectedErrors: []govytest.ExpectedRuleError{{PropertyName: "this", Message: "test"}}, + out: "*govy.ValidatorError contains different number of errors than expected, expected: 1, actual: 2.", + }, + "no matches": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test"}, + }, + out: `Expected error was not found. +EXPECTED: +{ + "propertyName": "this", + "message": "test" +} +ACTUAL: +[ + { + "propertyName": "that", + "errors": [ + { + "error": "test" + } + ] + } +]`, + }, + "match on message": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Message: "test2"}, {Message: "test1"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1"}, + {PropertyName: "this", Message: "test2"}, + {PropertyName: "that", Message: "test3"}, + }, + }, + "match on code": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Code: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Code: "test2"}, {Code: "test1"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Code: "test1"}, + {PropertyName: "this", Code: "test2"}, + {PropertyName: "that", Code: "test3"}, + }, + }, + "match on message contains": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Message: "test2"}, {Message: "test1"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", ContainsMessage: "test"}, + {PropertyName: "this", ContainsMessage: "test"}, + {PropertyName: "that", ContainsMessage: "test"}, + }, + }, + "match on message and code": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3", Code: "code3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{ + {Message: "test2", Code: "code2"}, + {Message: "test1", Code: "code1"}, + }, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1", Code: "code1"}, + {PropertyName: "this", Message: "test2", Code: "code2"}, + {PropertyName: "that", Message: "test3", Code: "code3"}, + }, + }, + "fail to match on message and code": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3", Code: "code3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{ + {Message: "test2", Code: "code2"}, + {Message: "test1", Code: "code1"}, + }, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1", Code: "code1"}, + {PropertyName: "this", Message: "test2", Code: "code2"}, + {PropertyName: "that", Message: "test3", Code: "code4"}, + }, + out: `Expected error was not found. +EXPECTED: +{ + "propertyName": "that", + "code": "code4", + "message": "test3" +} +ACTUAL: +[ + { + "propertyName": "that", + "errors": [ + { + "error": "test3", + "code": "code3" + } + ] + }, + { + "propertyName": "this", + "errors": [ + { + "error": "test2", + "code": "code2" + }, + { + "error": "test1", + "code": "code1" + } + ] + } +]`, + }, + "match on message, code and message contains": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3", Code: "code3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{ + {Message: "test2", Code: "code2"}, + {Message: "test1", Code: "code1"}, + }, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1", Code: "code1", ContainsMessage: "test"}, + {PropertyName: "this", Message: "test2", Code: "code2", ContainsMessage: "test"}, + {PropertyName: "that", Message: "test3", Code: "code3", ContainsMessage: "test"}, + }, + }, + "error was matched multiple times": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Message: "test2"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", ContainsMessage: "test"}, + {PropertyName: "this", ContainsMessage: "test"}, + }, + out: "Actual error was matched multiple times. Consider providing a more specific govytest.ExpectedRuleError list.", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertError(mt, tc.inputError, tc.expectedErrors...) + if tc.ok { + assert.True(t, ok) + } else { + assert.Require(t, assert.False(t, ok)) + assert.Equal(t, tc.out, mt.recordedError) + } + }) + } +} + +type mockTestingT struct { + recordedError string +} + +func (m *mockTestingT) Errorf(format string, args ...any) { + m.recordedError = fmt.Sprintf(format, args...) +} + +func (m *mockTestingT) Error(args ...any) { + m.recordedError = fmt.Sprint(args...) +} + +func (m *mockTestingT) Helper() {} diff --git a/pkg/govytest/doc.go b/pkg/govytest/doc.go new file mode 100644 index 0000000..4dc2705 --- /dev/null +++ b/pkg/govytest/doc.go @@ -0,0 +1,2 @@ +// Package govytest provides utilities for testing validation rules defined with govy. +package govytest diff --git a/pkg/govytest/example_test.go b/pkg/govytest/example_test.go new file mode 100644 index 0000000..3a53c8e --- /dev/null +++ b/pkg/govytest/example_test.go @@ -0,0 +1,169 @@ +package govytest_test + +import ( + "fmt" + + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/govytest" + "github.com/nobl9/govy/pkg/rules" +) + +type Teacher struct { + Name string `json:"name"` + University University `json:"university"` +} + +type University struct { + Name string `json:"name"` + Address string `json:"address"` +} + +// You can use [govytest.AssertNoError] to ensure no error was produced by [govy.Validator.Validate]. +// If an error was produced, it will be printed to the stdout in JSON format. +// +// To demonstrate the erroneous output of [govytest.AssertNoError] we'll fail the assertion. +func ExampleAssertNoError() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We'll use a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertNoError(mt, err) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Received unexpected error: + // { + // "errors": [ + // { + // "propertyName": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of [Jake, George]", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyName": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ], + // "name": "John" + // } +} + +// Verifying that expected errors were produced by [govy.Validator.Validate] can be a tedious task. +// Often times we might only care about [govy.ErrorCode] and not the message or description of the error. +// To help in that process, [govytest.AssertError] can be used to ensure that the expected errors were produced. +// It accepts multiple [govytest.ExpectedRuleError], each being a short and concise +// representation of the error we're expecting to occur. +// For more details on how to use [govytest.ExpectedRuleError], see its code documentation. +// +// To demonstrate the erroneous output of [govytest.AssertError] we'll fail the assertion. +func ExampleAssertError() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We'll use a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertError(mt, err, + govytest.ExpectedRuleError{ + PropertyName: "name", + ContainsMessage: "one of", + }, + govytest.ExpectedRuleError{ + PropertyName: "university.address", + Code: "greater_than", + }, + ) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Expected error was not found. + // EXPECTED: + // { + // "propertyName": "university.address", + // "code": "greater_than" + // } + // ACTUAL: + // [ + // { + // "propertyName": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of [Jake, George]", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyName": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ] +} diff --git a/pkg/rules/error_codes.go b/pkg/rules/error_codes.go index f47e0ed..16a4f46 100644 --- a/pkg/rules/error_codes.go +++ b/pkg/rules/error_codes.go @@ -45,6 +45,7 @@ const ( ErrorCodeMapMinLength govy.ErrorCode = "map_min_length" ErrorCodeMapMaxLength govy.ErrorCode = "map_max_length" ErrorCodeOneOf govy.ErrorCode = "one_of" + ErrorCodeOneOfProperties govy.ErrorCode = "one_of_properties" ErrorCodeMutuallyExclusive govy.ErrorCode = "mutually_exclusive" ErrorCodeSliceUnique govy.ErrorCode = "slice_unique" ErrorCodeURL govy.ErrorCode = "url" diff --git a/pkg/rules/forbidden.go b/pkg/rules/forbidden.go index d45ec4a..c6481c6 100644 --- a/pkg/rules/forbidden.go +++ b/pkg/rules/forbidden.go @@ -11,7 +11,7 @@ import ( func Forbidden[T any]() govy.Rule[T] { msg := "property is forbidden" return govy.NewRule(func(v T) error { - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { return nil } return errors.New(msg) diff --git a/pkg/rules/one_of.go b/pkg/rules/one_of.go index c7e4690..ba5e831 100644 --- a/pkg/rules/one_of.go +++ b/pkg/rules/one_of.go @@ -31,15 +31,39 @@ func OneOf[T comparable](values ...T) govy.Rule[T] { }()) } +// OneOfProperties checks if at least one of the properties is set. +// Property is considered set if its value is not empty (non-zero). +func OneOfProperties[S any](getters map[string]func(s S) any) govy.Rule[S] { + return govy.NewRule(func(s S) error { + for _, getter := range getters { + v := getter(s) + if !internal.IsEmpty(v) { + return nil + } + } + keys := maps.Keys(getters) + slices.Sort(keys) + return fmt.Errorf( + "one of %s properties must be set, none was provided", + prettyOneOfList(keys)) + }). + WithErrorCode(ErrorCodeOneOfProperties). + WithDescription(func() string { + keys := maps.Keys(getters) + return fmt.Sprintf("at least one of the properties must be set: %s", strings.Join(keys, ", ")) + }()) +} + // MutuallyExclusive checks if properties are mutually exclusive. -// This means, exactly one of the properties can be provided. +// This means, exactly one of the properties can be set. +// Property is considered set if its value is not empty (non-zero). // If required is true, then a single non-empty property is required. func MutuallyExclusive[S any](required bool, getters map[string]func(s S) any) govy.Rule[S] { return govy.NewRule(func(s S) error { var nonEmpty []string for name, getter := range getters { v := getter(s) - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { continue } nonEmpty = append(nonEmpty, name) diff --git a/pkg/rules/one_of_test.go b/pkg/rules/one_of_test.go index 7f46a8c..5af4597 100644 --- a/pkg/rules/one_of_test.go +++ b/pkg/rules/one_of_test.go @@ -86,4 +86,46 @@ func TestMutuallyExclusive(t *testing.T) { }) } +func TestOneOfProperties(t *testing.T) { + type PaymentMethod struct { + Cash *string + Card *string + Transfer *string + } + getters := map[string]func(p PaymentMethod) any{ + "Cash": func(p PaymentMethod) any { return p.Cash }, + "Card": func(p PaymentMethod) any { return p.Card }, + "Transfer": func(p PaymentMethod) any { return p.Transfer }, + } + t.Run("passes", func(t *testing.T) { + err := OneOfProperties(getters).Validate(PaymentMethod{ + Cash: nil, + Card: ptr("2$"), + Transfer: nil, + }) + assert.NoError(t, err) + err = OneOfProperties(getters).Validate(PaymentMethod{ + Cash: ptr("1$"), + Card: ptr("2$"), + Transfer: nil, + }) + assert.NoError(t, err) + err = OneOfProperties(getters).Validate(PaymentMethod{ + Cash: ptr("1$"), + Card: ptr("2$"), + Transfer: ptr("3$"), + }) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := OneOfProperties(getters).Validate(PaymentMethod{ + Cash: nil, + Card: nil, + Transfer: nil, + }) + assert.EqualError(t, err, "one of [Card, Cash, Transfer] properties must be set, none was provided") + assert.True(t, govy.HasErrorCode(err, ErrorCodeOneOfProperties)) + }) +} + func ptr[T any](v T) *T { return &v } diff --git a/pkg/rules/required.go b/pkg/rules/required.go index b1fa7b9..acbdf4c 100644 --- a/pkg/rules/required.go +++ b/pkg/rules/required.go @@ -8,7 +8,7 @@ import ( // Required ensures the property's value is not empty (i.e. it's not its type's zero value). func Required[T any]() govy.Rule[T] { return govy.NewRule(func(v T) error { - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { return govy.NewRuleError( internal.RequiredErrorMessage, ErrorCodeRequired,