From f63a6e9b4fa65d49d68e0e94de991a5c8b86f4c3 Mon Sep 17 00:00:00 2001 From: ashmrtn <3891298+ashmrtn@users.noreply.github.com> Date: Tue, 26 Dec 2023 10:52:50 -0800 Subject: [PATCH] Some testing QoL functions (#4927) Functions help check things like * are two items equal when taking into account nil, empty slices, and other zero-values? * is something fully populated? --- #### Does this PR need a docs update or release note? - [ ] :white_check_mark: Yes, it's included - [ ] :clock1: Yes, but in a later PR - [x] :no_entry: No #### Type of change - [ ] :sunflower: Feature - [ ] :bug: Bugfix - [ ] :world_map: Documentation - [x] :robot: Supportability/Tests - [ ] :computer: CI/Deployment - [ ] :broom: Tech Debt/Cleanup #### Test Plan - [x] :muscle: Manual - [ ] :zap: Unit test - [ ] :green_heart: E2E --- src/internal/m365/helper_test.go | 63 +++++++++---- src/internal/operations/backup_test.go | 26 +----- src/internal/tester/check.go | 121 +++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 src/internal/tester/check.go diff --git a/src/internal/m365/helper_test.go b/src/internal/m365/helper_test.go index 17ea870f0..fc27d2f2e 100644 --- a/src/internal/m365/helper_test.go +++ b/src/internal/m365/helper_test.go @@ -21,6 +21,7 @@ import ( "github.com/alcionai/corso/src/internal/m365/collection/drive/metadata" odStub "github.com/alcionai/corso/src/internal/m365/service/onedrive/stub" m365Stub "github.com/alcionai/corso/src/internal/m365/stub" + "github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester/tconfig" "github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/count" @@ -160,6 +161,10 @@ func recipientEqual( expected models.Recipientable, got models.Recipientable, ) bool { + if expected == nil { + return tester.NilOrZero(got) + } + // Don't compare names as M365 will override the name if the address is known. return reflect.DeepEqual( ptr.Val(expected.GetEmailAddress().GetAddress()), @@ -173,9 +178,9 @@ func checkMessage( ) { testElementsMatch(t, expected.GetAttachments(), got.GetAttachments(), false, attachmentEqual) - assert.Equal(t, expected.GetBccRecipients(), got.GetBccRecipients(), "BccRecipients") + tester.AssertEmptyOrEqual(t, expected.GetBccRecipients(), got.GetBccRecipients(), "BccRecipients") - assert.Equal( + tester.AssertEmptyOrEqual( t, ptr.Val(expected.GetBody().GetContentType()), ptr.Val(got.GetBody().GetContentType()), @@ -187,9 +192,9 @@ func checkMessage( // always just the first 255 characters if the message is HTML and has // multiple paragraphs. - assert.Equal(t, expected.GetCategories(), got.GetCategories(), "Categories") + tester.AssertEmptyOrEqual(t, expected.GetCategories(), got.GetCategories(), "Categories") - assert.Equal(t, expected.GetCcRecipients(), got.GetCcRecipients(), "CcRecipients") + tester.AssertEmptyOrEqual(t, expected.GetCcRecipients(), got.GetCcRecipients(), "CcRecipients") // Skip ChangeKey as it's tied to this specific instance of the item. @@ -202,33 +207,41 @@ func checkMessage( checkFlags(t, expected.GetFlag(), got.GetFlag()) checkRecipentables(t, expected.GetFrom(), got.GetFrom()) - assert.Equal(t, ptr.Val(expected.GetHasAttachments()), ptr.Val(got.GetHasAttachments()), "HasAttachments") + tester.AssertEmptyOrEqual(t, ptr.Val(expected.GetHasAttachments()), ptr.Val(got.GetHasAttachments()), "HasAttachments") // Skip Id as it's tied to this specific instance of the item. - assert.Equal(t, ptr.Val(expected.GetImportance()), ptr.Val(got.GetImportance()), "Importance") + tester.AssertEmptyOrEqual(t, ptr.Val(expected.GetImportance()), ptr.Val(got.GetImportance()), "Importance") - assert.Equal( + tester.AssertEmptyOrEqual( t, ptr.Val(expected.GetInferenceClassification()), ptr.Val(got.GetInferenceClassification()), "InferenceClassification") - assert.Equal(t, expected.GetInternetMessageHeaders(), got.GetInternetMessageHeaders(), "InternetMessageHeaders") + tester.AssertEmptyOrEqual( + t, + expected.GetInternetMessageHeaders(), + got.GetInternetMessageHeaders(), + "InternetMessageHeaders") - assert.Equal(t, ptr.Val(expected.GetInternetMessageId()), ptr.Val(got.GetInternetMessageId()), "InternetMessageId") + tester.AssertEmptyOrEqual( + t, + ptr.Val(expected.GetInternetMessageId()), + ptr.Val(got.GetInternetMessageId()), + "InternetMessageId") - assert.Equal( + tester.AssertEmptyOrEqual( t, ptr.Val(expected.GetIsDeliveryReceiptRequested()), ptr.Val(got.GetIsDeliveryReceiptRequested()), "IsDeliverReceiptRequested") - assert.Equal(t, ptr.Val(expected.GetIsDraft()), ptr.Val(got.GetIsDraft()), "IsDraft") + tester.AssertEmptyOrEqual(t, ptr.Val(expected.GetIsDraft()), ptr.Val(got.GetIsDraft()), "IsDraft") - assert.Equal(t, ptr.Val(expected.GetIsRead()), ptr.Val(got.GetIsRead()), "IsRead") + tester.AssertEmptyOrEqual(t, ptr.Val(expected.GetIsRead()), ptr.Val(got.GetIsRead()), "IsRead") - assert.Equal( + tester.AssertEmptyOrEqual( t, ptr.Val(expected.GetIsReadReceiptRequested()), ptr.Val(got.GetIsReadReceiptRequested()), @@ -238,21 +251,25 @@ func checkMessage( // Skip ParentFolderId as we restore to a different folder by default. - assert.Equal(t, ptr.Val(expected.GetReceivedDateTime()), ptr.Val(got.GetReceivedDateTime()), "ReceivedDateTime") + tester.AssertEmptyOrEqual( + t, + ptr.Val(expected.GetReceivedDateTime()), + ptr.Val(got.GetReceivedDateTime()), + "ReceivedDateTime") - assert.Equal(t, expected.GetReplyTo(), got.GetReplyTo(), "ReplyTo") + tester.AssertEmptyOrEqual(t, expected.GetReplyTo(), got.GetReplyTo(), "ReplyTo") checkRecipentables(t, expected.GetSender(), got.GetSender()) - assert.Equal(t, ptr.Val(expected.GetSentDateTime()), ptr.Val(got.GetSentDateTime()), "SentDateTime") + tester.AssertEmptyOrEqual(t, ptr.Val(expected.GetSentDateTime()), ptr.Val(got.GetSentDateTime()), "SentDateTime") - assert.Equal(t, ptr.Val(expected.GetSubject()), ptr.Val(got.GetSubject()), "Subject") + tester.AssertEmptyOrEqual(t, ptr.Val(expected.GetSubject()), ptr.Val(got.GetSubject()), "Subject") testElementsMatch(t, expected.GetToRecipients(), got.GetToRecipients(), false, recipientEqual) // Skip WebLink as it's tied to this specific instance of the item. - assert.Equal(t, expected.GetUniqueBody(), got.GetUniqueBody(), "UniqueBody") + tester.AssertEmptyOrEqual(t, expected.GetUniqueBody(), got.GetUniqueBody(), "UniqueBody") } // checkFlags is a helper function to check equality of models.FollowupFlabables @@ -261,6 +278,11 @@ func checkFlags( t *testing.T, expected, got models.FollowupFlagable, ) { + if expected == nil { + assert.True(t, tester.NilOrZero(got)) + return + } + assert.Equal(t, expected.GetCompletedDateTime(), got.GetCompletedDateTime()) assert.Equal(t, expected.GetDueDateTime(), got.GetDueDateTime()) assert.Equal(t, expected.GetFlagStatus(), got.GetFlagStatus()) @@ -274,6 +296,11 @@ func checkRecipentables( t *testing.T, expected, got models.Recipientable, ) { + if expected == nil { + assert.True(t, tester.NilOrZero(got)) + return + } + checkEmailAddressables(t, expected.GetEmailAddress(), got.GetEmailAddress()) assert.Equal(t, expected.GetAdditionalData(), got.GetAdditionalData()) } diff --git a/src/internal/operations/backup_test.go b/src/internal/operations/backup_test.go index c0ecdec5c..710b010f1 100644 --- a/src/internal/operations/backup_test.go +++ b/src/internal/operations/backup_test.go @@ -3,9 +3,7 @@ package operations import ( "context" "encoding/json" - "fmt" stdpath "path" - "reflect" "testing" "time" @@ -369,33 +367,11 @@ func TestBackupOpUnitSuite(t *testing.T) { suite.Run(t, &BackupOpUnitSuite{Suite: tester.NewUnitSuite(t)}) } -func checkPopulatedInner(v reflect.Value) error { - if v.IsZero() { - return clues.New("zero-valued field") - } - - if v.Kind() != reflect.Struct { - return nil - } - - var errs *clues.Err - - for i := 0; i < v.NumField(); i++ { - f := v.Field(i) - - if err := checkPopulatedInner(f); err != nil { - errs = clues.Stack(errs, clues.Wrap(err, fmt.Sprintf("field at index %d", i))) - } - } - - return errs.OrNil() -} - // checkPopulated ensures that input has no zero-valued fields. That helps // ensure that even as future updates to input happen in other files the changes // are propagated here due to test failures. func checkPopulated(t *testing.T, input control.Options) { - err := checkPopulatedInner(reflect.ValueOf(input)) + err := tester.CheckPopulated(input) require.NoError(t, err, clues.ToCore(err)) } diff --git a/src/internal/tester/check.go b/src/internal/tester/check.go new file mode 100644 index 000000000..ab708acf0 --- /dev/null +++ b/src/internal/tester/check.go @@ -0,0 +1,121 @@ +package tester + +import ( + "fmt" + "reflect" + "testing" + + "github.com/alcionai/clues" + "github.com/stretchr/testify/assert" +) + +func checkPopulated(v reflect.Value) error { + if v.IsZero() { + return clues.New("zero-valued field") + } + + if v.Kind() != reflect.Struct { + return nil + } + + var errs *clues.Err + + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + + if err := checkPopulated(f); err != nil { + errs = clues.Stack(errs, clues.Wrap(err, fmt.Sprintf("field at index %d", i))) + } + } + + return errs.OrNil() +} + +func isEmptyContainer(v reflect.Value) bool { + // Handle pointers to things. + deref := v + + for k := deref.Kind(); k == reflect.Pointer; k = deref.Kind() { + deref = deref.Elem() + } + + // Check for empty maps, slices, or arrays. + if (deref.Kind() == reflect.Slice && deref.Len() == 0) || + (deref.Kind() == reflect.Map && deref.Len() == 0) || + (deref.Kind() == reflect.Array && deref.Len() == 0) { + return true + } + + return false +} + +// CheckPopulated returns an error if input is not fully populated. To be +// considered fully populated it must be non-zero-valued. For basic types this +// just means it isn't the zero value. For structs this means that every field +// is not zero-valued. This check is recursive for structs. +func CheckPopulated(input any) error { + return checkPopulated(reflect.ValueOf(input)) +} + +func checkNotPopulated(v reflect.Value) error { + if isEmptyContainer(v) { + return nil + } + + if !v.IsZero() { + return clues.New("non-zero-valued field") + } + + if v.Kind() != reflect.Struct { + return nil + } + + var errs *clues.Err + + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + + if err := checkNotPopulated(f); err != nil { + errs = clues.Stack(errs, clues.Wrap(err, fmt.Sprintf("field at index %d", i))) + } + } + + return errs.OrNil() +} + +// NilOrZero return true if the input is nil or if it's the zero value for the +// type. If the input is a struct then all fields are recursively checked to +// see if they're the zero value for their type. +func NilOrZero(input any) bool { + if input == nil { + return true + } + + if isEmptyContainer(reflect.ValueOf(input)) { + return true + } + + err := checkNotPopulated(reflect.ValueOf(input)) + + return err != nil +} + +// AssertEmptyOrEqual checks either: +// - got is nil or the zero value if expected is nil +// - expected and got are equal if expected is not nil +func AssertEmptyOrEqual( + t *testing.T, + expect any, + got any, + msgAndArgs ...any, +) bool { + if expect == nil { + return assert.True(t, NilOrZero(got), "empty got value: %+v", got) + } + + if isEmptyContainer(reflect.ValueOf(expect)) { + return assert.True(t, NilOrZero(got), "empty got value: %+v", got) + } + + return assert.Equal(t, expect, got, msgAndArgs...) +}