GC: Use of graph.ContainerResolver for directory structure and data retrieval (#1134)

## Description

`graph.ContainerResolver` has the capacity to keep the directory structure of the m365 objects that are helpful within a user's account. Leveraging this abstraction allows for a better flow of data from M365 into storage. 

## Type of change


- [x]  : Optimization

## Issue(s)

* closes #1125<issue>
* closes #1122
## Test Plan


- [x]  Unit test
This commit is contained in:
Danny 2022-10-20 08:49:09 -04:00 committed by GitHub
parent d30f6963c0
commit bb7e48a82e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 441 additions and 890 deletions

View File

@ -17,7 +17,6 @@ import (
"github.com/alcionai/corso/src/pkg/account"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors"
)
type ExchangeServiceSuite struct {
@ -233,37 +232,6 @@ func (suite *ExchangeServiceSuite) TestOptionsForContacts() {
}
}
// TestSetupExchangeCollection ensures SetupExchangeCollectionVars returns a non-nil variable for
// the following selector types:
// - Mail
// - Contacts
// - Events
func (suite *ExchangeServiceSuite) TestSetupExchangeCollection() {
userID := tester.M365UserID(suite.T())
sel := selectors.NewExchangeBackup()
// Exchange mail uses a different system to fetch items. Right now the old
// function for it will return an error so we know if it gets called.
sel.Include(
sel.ContactFolders([]string{userID}, selectors.Any()),
sel.EventCalendars([]string{userID}, selectors.Any()),
)
eb, err := sel.ToExchangeBackup()
require.NoError(suite.T(), err)
scopes := eb.Scopes()
for _, test := range scopes {
suite.T().Run(test.Category().String(), func(t *testing.T) {
discriminateFunc, graphQuery, iterFunc, err := SetupExchangeCollectionVars(test)
assert.NoError(t, err)
assert.NotNil(t, discriminateFunc)
assert.NotNil(t, graphQuery)
assert.NotNil(t, iterFunc)
})
}
}
// TestGraphQueryFunctions verifies if Query functions APIs
// through Microsoft Graph are functional
func (suite *ExchangeServiceSuite) TestGraphQueryFunctions() {

View File

@ -68,10 +68,12 @@ func loadService(t *testing.T) *exchangeService {
return service
}
// TestIterativeFunctions verifies that GraphQuery to Iterate
// functions are valid for current versioning of msgraph-go-sdk.
// TestCollectionFunctions verifies ability to gather
// containers functions are valid for current versioning of msgraph-go-sdk.
// Tests for mail have been moved to graph_connector_test.go.
func (suite *ExchangeIteratorSuite) TestIterativeFunctions() {
// exchange.Mail uses a sequential delta function.
// TODO: Add exchange.Mail when delta iterator functionality implemented
func (suite *ExchangeIteratorSuite) TestCollectionFunctions() {
ctx, flush := tester.NewContext()
defer flush()
@ -93,63 +95,39 @@ func (suite *ExchangeIteratorSuite) TestIterativeFunctions() {
tests := []struct {
name string
queryFunction GraphQuery
iterativeFunction GraphIterateFunc
queryFunc GraphQuery
scope selectors.ExchangeScope
transformer absser.ParsableFactory
folderNames map[string]struct{}
iterativeFunction func(
container map[string]graph.Container,
aFilter string,
errUpdater func(string, error)) func(any) bool
transformer absser.ParsableFactory
}{
{
name: "Contacts Iterative Check",
queryFunction: GetAllContactFolderNamesForUser,
iterativeFunction: IterateSelectAllContactsForCollections,
scope: contactScope[0],
queryFunc: GetAllContactFolderNamesForUser,
transformer: models.CreateContactFolderCollectionResponseFromDiscriminatorValue,
}, {
name: "Contact Folder Traversal",
queryFunction: GetAllContactFolderNamesForUser,
iterativeFunction: IterateSelectAllContactsForCollections,
scope: contactScope[0],
transformer: models.CreateContactFolderCollectionResponseFromDiscriminatorValue,
}, {
iterativeFunction: IterativeCollectContactContainers,
},
{
name: "Events Iterative Check",
queryFunction: GetAllCalendarNamesForUser,
iterativeFunction: IterateSelectAllEventsFromCalendars,
scope: eventScope[0],
queryFunc: GetAllCalendarNamesForUser,
transformer: models.CreateCalendarCollectionResponseFromDiscriminatorValue,
}, {
name: "Folder Iterative Check Contacts",
queryFunction: GetAllContactFolderNamesForUser,
iterativeFunction: IterateFilterContainersForCollections,
scope: contactScope[0],
transformer: models.CreateContactFolderCollectionResponseFromDiscriminatorValue,
}, {
name: "Default Contacts Folder",
queryFunction: GetDefaultContactFolderForUser,
iterativeFunction: IterateSelectAllContactsForCollections,
scope: contactScope[0],
transformer: models.CreateContactFolderCollectionResponseFromDiscriminatorValue,
iterativeFunction: IterativeCollectCalendarContainers,
},
}
for _, test := range tests {
suite.T().Run(test.name, func(t *testing.T) {
service := loadService(t)
response, err := test.queryFunction(ctx, service, userID)
response, err := test.queryFunc(ctx, service, userID)
require.NoError(t, err)
// Create Iterator
// Iterator Creation
pageIterator, err := msgraphgocore.NewPageIterator(response,
&service.adapter,
test.transformer)
require.NoError(t, err)
qp := graph.QueryParams{
User: userID,
Scope: test.scope,
Credentials: service.credentials,
FailFast: false,
}
// Create collection for iterate test
collections := make(map[string]*Collection)
collections := make(map[string]graph.Container)
var errs error
errUpdater := func(id string, err error) {
errs = support.WrapAndAppend(id, err, errs)
@ -157,14 +135,7 @@ func (suite *ExchangeIteratorSuite) TestIterativeFunctions() {
// callbackFunc iterates through all models.Messageable and fills exchange.Collection.jobs[]
// with corresponding item IDs. New collections are created for each directory
callbackFunc := test.iterativeFunction(
ctx,
qp,
errUpdater,
collections,
nil,
nil,
)
collections, "", errUpdater)
iterateError := pageIterator.Iterate(ctx, callbackFunc)
assert.NoError(t, iterateError)
assert.NoError(t, errs)

View File

@ -101,7 +101,7 @@ func (mc *mailFolderCache) Populate(
for _, f := range resp.GetValue() {
if err := mc.AddToCache(ctx, f); err != nil {
errs = multierror.Append(errs, err)
errs = multierror.Append(errs, errors.Wrap(err, "delta fetch"))
continue
}
}

View File

@ -133,7 +133,7 @@ func (suite *MailFolderCacheUnitSuite) TestCheckRequiredValues() {
for _, test := range table {
suite.T().Run(test.name, func(t *testing.T) {
test.check(t, checkRequiredValues(test.c))
test.check(t, graph.CheckRequiredValues(test.c))
})
}
}
@ -366,6 +366,7 @@ func (suite *MailFolderCacheIntegrationSuite) TestDeltaFetch() {
require.NoError(t, mfc.Populate(ctx, test.root, test.path...))
p, err := mfc.IDToPath(ctx, testFolderID)
t.Logf("Path: %s\n", p.String())
require.NoError(t, err)
expectedPath := stdpath.Join(append(test.path, expectedFolderPath)...)

View File

@ -19,7 +19,6 @@ import (
"github.com/pkg/errors"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors"
)
//-----------------------------------------------------------------------
@ -119,19 +118,6 @@ func categoryToOptionIdentifier(category path.CategoryType) optionIdentifier {
}
}
func scopeToOptionIdentifier(selector selectors.ExchangeScope) optionIdentifier {
switch selector.Category() {
case selectors.ExchangeMailFolder, selectors.ExchangeMail:
return messages
case selectors.ExchangeContactFolder, selectors.ExchangeContact:
return contacts
case selectors.ExchangeEventCalendar, selectors.ExchangeEvent:
return events
default:
return unknown
}
}
//---------------------------------------------------------------------------
// exchange.Query Option Section
// These functions can be used to filter a response on M365

View File

@ -5,7 +5,6 @@ import (
"fmt"
"github.com/hashicorp/go-multierror"
absser "github.com/microsoft/kiota-abstractions-go/serialization"
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
"github.com/microsoftgraph/msgraph-sdk-go/models"
@ -144,18 +143,18 @@ func GetAllMailFolders(
) ([]graph.CachedContainer, error) {
containers := make([]graph.CachedContainer, 0)
resolver, err := MaybeGetAndPopulateFolderResolver(ctx, qp, path.EmailCategory)
resolver, err := PopulateExchangeContainerResolver(ctx, qp, path.EmailCategory)
if err != nil {
return nil, err
}
for _, c := range resolver.Items() {
directories := c.Path().Elements()
if len(directories) == 0 {
directory := c.Path().String()
if len(directory) == 0 {
continue
}
if qp.Scope.Matches(selectors.ExchangeMailFolder, directories[len(directories)-1]) {
if qp.Scope.Matches(selectors.ExchangeMailFolder, directory) {
containers = append(containers, c)
}
}
@ -173,15 +172,15 @@ func GetAllCalendars(
) ([]graph.CachedContainer, error) {
containers := make([]graph.CachedContainer, 0)
resolver, err := MaybeGetAndPopulateFolderResolver(ctx, qp, path.EventsCategory)
resolver, err := PopulateExchangeContainerResolver(ctx, qp, path.EventsCategory)
if err != nil {
return nil, err
}
for _, c := range resolver.Items() {
directories := c.Path().Elements()
directory := c.Path().String()
if qp.Scope.Matches(selectors.ExchangeEventCalendar, directories[len(directories)-1]) {
if qp.Scope.Matches(selectors.ExchangeEventCalendar, directory) {
containers = append(containers, c)
}
}
@ -198,23 +197,22 @@ func GetAllContactFolders(
qp graph.QueryParams,
gs graph.Service,
) ([]graph.CachedContainer, error) {
var (
query string
containers = make([]graph.CachedContainer, 0)
)
var query string
resolver, err := MaybeGetAndPopulateFolderResolver(ctx, qp, path.ContactsCategory)
containers := make([]graph.CachedContainer, 0)
resolver, err := PopulateExchangeContainerResolver(ctx, qp, path.ContactsCategory)
if err != nil {
return nil, err
}
for _, c := range resolver.Items() {
directories := c.Path().Elements()
directory := c.Path().String()
if len(directories) == 0 {
if len(directory) == 0 {
query = DefaultContactFolder
} else {
query = directories[len(directories)-1]
query = directory
}
if qp.Scope.Matches(selectors.ExchangeContactFolder, query) {
@ -225,42 +223,30 @@ func GetAllContactFolders(
return containers, err
}
// SetupExchangeCollectionVars is a helper function returns a sets
// Exchange.Type specific functions based on scope.
// The []GraphQuery slice provides fallback queries in the event that
// initial queries provide zero results.
func SetupExchangeCollectionVars(scope selectors.ExchangeScope) (
absser.ParsableFactory,
[]GraphQuery,
GraphIterateFunc,
error,
) {
if scope.IncludesCategory(selectors.ExchangeMail) {
return nil, nil, nil, errors.New("mail no longer supported this way")
}
func GetContainers(
ctx context.Context,
qp graph.QueryParams,
gs graph.Service,
) ([]graph.CachedContainer, error) {
category := graph.ScopeToPathCategory(qp.Scope)
if scope.IncludesCategory(selectors.ExchangeContact) {
return models.CreateContactFolderCollectionResponseFromDiscriminatorValue,
[]GraphQuery{GetAllContactFolderNamesForUser, GetDefaultContactFolderForUser},
IterateSelectAllContactsForCollections,
nil
switch category {
case path.ContactsCategory:
return GetAllContactFolders(ctx, qp, gs)
case path.EmailCategory:
return GetAllMailFolders(ctx, qp, gs)
case path.EventsCategory:
return GetAllCalendars(ctx, qp, gs)
default:
return nil, fmt.Errorf("path.Category %s not supported", category)
}
if scope.IncludesCategory(selectors.ExchangeEvent) {
return models.CreateCalendarCollectionResponseFromDiscriminatorValue,
[]GraphQuery{GetAllCalendarNamesForUser},
IterateSelectAllEventsFromCalendars,
nil
}
return nil, nil, nil, errors.New("exchange scope option not supported")
}
// MaybeGetAndPopulateFolderResolver gets a folder resolver if one is available for
// PopulateExchangeContainerResolver gets a folder resolver if one is available for
// this category of data. If one is not available, returns nil so that other
// logic in the caller can complete as long as they check if the resolver is not
// nil. If an error occurs populating the resolver, returns an error.
func MaybeGetAndPopulateFolderResolver(
func PopulateExchangeContainerResolver(
ctx context.Context,
qp graph.QueryParams,
category path.CategoryType,
@ -298,7 +284,7 @@ func MaybeGetAndPopulateFolderResolver(
cacheRoot = DefaultCalendar
default:
return nil, nil
return nil, fmt.Errorf("ContainerResolver not present for %s type", category)
}
if err := res.Populate(ctx, cacheRoot); err != nil {
@ -308,70 +294,43 @@ func MaybeGetAndPopulateFolderResolver(
return res, nil
}
func resolveCollectionPath(
ctx context.Context,
resolver graph.ContainerResolver,
tenantID, user, folderID string,
category path.CategoryType,
) (path.Path, error) {
if resolver == nil {
// Allows caller to default to old-style path.
return nil, errors.WithStack(errNilResolver)
func pathAndMatch(qp graph.QueryParams, category path.CategoryType, c graph.CachedContainer) (path.Path, bool) {
var (
directory string
pb = c.Path()
)
// Clause ensures that DefaultContactFolder is inspected properly
if category == path.ContactsCategory && *c.GetDisplayName() == DefaultContactFolder {
pb = c.Path().Append(DefaultContactFolder)
}
p, err := resolver.IDToPath(ctx, folderID)
if err != nil {
return nil, errors.Wrap(err, "resolving folder ID")
}
return p.ToDataLayerExchangePathForCategory(
tenantID,
user,
dirPath, err := pb.ToDataLayerExchangePathForCategory(
qp.Credentials.AzureTenantID,
qp.User,
category,
false,
)
}
func getCollectionPath(
ctx context.Context,
qp graph.QueryParams,
resolver graph.ContainerResolver,
directory string,
category path.CategoryType,
) (path.Path, error) {
returnPath, err := resolveCollectionPath(
ctx,
resolver,
qp.Credentials.AzureTenantID,
qp.User,
directory,
category,
)
if err == nil {
return returnPath, nil
if err != nil {
return nil, false
}
aPath, err1 := path.Builder{}.Append(directory).
ToDataLayerExchangePathForCategory(
qp.Credentials.AzureTenantID,
qp.User,
category,
false,
)
if err1 == nil {
return aPath, nil
if dirPath == nil && category == path.EmailCategory {
return nil, false // Only true for root mail folder
}
return nil,
support.WrapAndAppend(
fmt.Sprintf(
"both path generate functions failed for %s:%s:%s",
qp.User,
category,
directory),
err,
err1,
)
directory = pb.String()
switch category {
case path.EmailCategory:
return dirPath, qp.Scope.Matches(selectors.ExchangeMailFolder, directory)
case path.ContactsCategory:
return dirPath, qp.Scope.Matches(selectors.ExchangeContactFolder, directory)
case path.EventsCategory:
return dirPath, qp.Scope.Matches(selectors.ExchangeEventCalendar, directory)
default:
return nil, false
}
}
func AddItemsToCollection(

View File

@ -301,6 +301,7 @@ func (suite *ServiceFunctionsIntegrationSuite) TestCollectContainers() {
t := suite.T()
user := tester.M365UserID(t)
a := tester.NewM365Account(t)
service := loadService(t)
credentials, err := a.M365Config()
require.NoError(t, err)
@ -314,39 +315,27 @@ func (suite *ServiceFunctionsIntegrationSuite) TestCollectContainers() {
contains: "Birthdays",
expectedCount: assert.Greater,
getScope: func() selectors.ExchangeScope {
sel := selectors.NewExchangeBackup()
sel.Include(sel.EventCalendars([]string{user}, selectors.Any()))
scopes := sel.Scopes()
assert.Equal(t, len(scopes), 1)
return scopes[0]
return selectors.
NewExchangeBackup().
EventCalendars([]string{user}, selectors.Any())[0]
},
}, {
name: "Default Calendar",
contains: DefaultCalendar,
expectedCount: assert.Equal,
getScope: func() selectors.ExchangeScope {
sel := selectors.NewExchangeBackup()
sel.Include(sel.EventCalendars([]string{user}, []string{DefaultCalendar}))
scopes := sel.Scopes()
assert.Equal(t, len(scopes), 1)
return scopes[0]
return selectors.
NewExchangeBackup().
EventCalendars([]string{user}, []string{DefaultCalendar})[0]
},
}, {
name: "Default Mail",
contains: DefaultMailFolder,
expectedCount: assert.Equal,
getScope: func() selectors.ExchangeScope {
sel := selectors.NewExchangeBackup()
sel.Include(sel.MailFolders([]string{user}, []string{DefaultMailFolder}))
scopes := sel.Scopes()
assert.Equal(t, len(scopes), 1)
return scopes[0]
return selectors.
NewExchangeBackup().
MailFolders([]string{user}, []string{DefaultMailFolder})[0]
},
},
}
@ -359,16 +348,14 @@ func (suite *ServiceFunctionsIntegrationSuite) TestCollectContainers() {
FailFast: failFast,
Credentials: credentials,
}
collections := make(map[string]*Collection)
err := CollectFolders(ctx, qp, collections, nil, nil)
collections, err := GetContainers(ctx, qp, service)
assert.NoError(t, err)
test.expectedCount(t, len(collections), containerCount)
keys := make([]string, 0, len(collections))
for k := range collections {
keys = append(keys, k)
for _, k := range collections {
keys = append(keys, *k.GetDisplayName())
}
t.Logf("Collections Made: %v\n", keys)
assert.Contains(t, keys, test.contains)
})
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"strings"
multierror "github.com/hashicorp/go-multierror"
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/pkg/errors"
@ -12,436 +13,81 @@ import (
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/support"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors"
)
var errNilResolver = errors.New("nil resolver")
// GraphIterateFuncs are iterate functions to be used with the M365 iterators (e.g. msgraphgocore.NewPageIterator)
// @returns a callback func that works with msgraphgocore.PageIterator.Iterate function
type GraphIterateFunc func(
// FilterContainersAndFillCollections is a utility function
// that places the M365 object ids belonging to specific directories
// into a Collection. Messages outside of those directories are omitted.
// @param collection is filled with during this function.
// Supports all exchange applications: Contacts, Events, and Mail
func FilterContainersAndFillCollections(
ctx context.Context,
qp graph.QueryParams,
errUpdater func(string, error),
collections map[string]*Collection,
statusUpdater support.StatusUpdater,
resolver graph.ContainerResolver,
) func(any) bool
// IterateSelectAllEventsForCollections
// utility function for iterating through events
// and storing events in collections based on
// the calendarID which originates from M365.
// @param pageItem is a CalendarCollectionResponse possessing two populated fields:
// - id - M365 ID
// - Name - Calendar Name
func IterateSelectAllEventsFromCalendars(
ctx context.Context,
qp graph.QueryParams,
errUpdater func(string, error),
collections map[string]*Collection,
statusUpdater support.StatusUpdater,
resolver graph.ContainerResolver,
) func(any) bool {
) error {
var (
isEnabled bool
service graph.Service
category = graph.ScopeToPathCategory(qp.Scope)
collectionType = categoryToOptionIdentifier(category)
errs error
)
return func(pageItem any) bool {
if !isEnabled {
// Create Collections based on qp.Scope
err := CollectFolders(ctx, qp, collections, statusUpdater, resolver)
for _, c := range resolver.Items() {
dirPath, ok := pathAndMatch(qp, category, c)
if ok {
// Create only those that match
service, err := createService(qp.Credentials, qp.FailFast)
if err != nil {
errUpdater(
qp.User,
errors.Wrap(err, support.ConnectorStackErrorTrace(err)),
)
errs = support.WrapAndAppend(
qp.User+" failed to create service during FilterContainerAndFillCollection",
err,
errs)
return false
if qp.FailFast {
return errs
}
}
service, err = createService(qp.Credentials, qp.FailFast)
if err != nil {
errUpdater(qp.User, err)
return false
}
isEnabled = true
}
pageItem = CreateCalendarDisplayable(pageItem)
calendar, ok := pageItem.(graph.Displayable)
if !ok {
errUpdater(
edc := NewCollection(
qp.User,
fmt.Errorf("unable to parse pageItem into CalendarDisplayable: %T", pageItem),
dirPath,
collectionType,
service,
statusUpdater,
)
collections[*c.GetId()] = &edc
}
if calendar.GetDisplayName() == nil {
return true
}
collection, ok := collections[*calendar.GetDisplayName()]
if !ok {
return true
}
eventIDs, err := ReturnEventIDsFromCalendar(ctx, service, qp.User, *calendar.GetId())
if err != nil {
errUpdater(
qp.User,
errors.Wrap(err, support.ConnectorStackErrorTrace(err)))
return true
}
collection.jobs = append(collection.jobs, eventIDs...)
return true
}
}
// CollectionsFromResolver returns the set of collections that match the
// selector parameters.
func CollectionsFromResolver(
ctx context.Context,
qp graph.QueryParams,
resolver graph.ContainerResolver,
statusUpdater support.StatusUpdater,
collections map[string]*Collection,
) error {
option, category, notMatcher := getCategoryAndValidation(qp.Scope)
for directoryID, col := range collections {
fetchFunc, err := getFetchIDFunc(category)
if err != nil {
errs = support.WrapAndAppend(
qp.User,
err,
errs)
if qp.FailFast {
return errs
}
for _, item := range resolver.Items() {
pathString := item.Path().String()
// Skip the root folder for mail which has an empty path.
if len(pathString) == 0 || notMatcher(&pathString) {
continue
}
completePath, err := item.Path().ToDataLayerExchangePathForCategory(
qp.Credentials.AzureTenantID,
qp.User,
category,
false,
)
jobs, err := fetchFunc(ctx, col.service, qp.User, directoryID)
if err != nil {
return errors.Wrap(err, "resolving collection item path")
}
service, err := createService(qp.Credentials, qp.FailFast)
if err != nil {
return errors.Wrap(err, "making service instance")
}
tmp := NewCollection(
qp.User,
completePath,
option,
service,
statusUpdater,
)
collections[*item.GetId()] = &tmp
}
return nil
}
func getCategoryAndValidation(es selectors.ExchangeScope) (
optionIdentifier,
path.CategoryType,
func(namePtr *string) bool,
) {
var (
option = scopeToOptionIdentifier(es)
category path.CategoryType
validate func(namePtr *string) bool
)
switch option {
case messages:
category = path.EmailCategory
validate = func(namePtr *string) bool {
if namePtr == nil {
return true
}
return !es.Matches(selectors.ExchangeMailFolder, *namePtr)
}
case contacts:
category = path.ContactsCategory
validate = func(namePtr *string) bool {
if namePtr == nil {
return true
}
return !es.Matches(selectors.ExchangeContactFolder, *namePtr)
}
case events:
category = path.EventsCategory
validate = func(namePtr *string) bool {
if namePtr == nil {
return true
}
return !es.Matches(selectors.ExchangeEventCalendar, *namePtr)
}
}
return option, category, validate
}
func IterateFilterContainersForCollections(
ctx context.Context,
qp graph.QueryParams,
errUpdater func(string, error),
collections map[string]*Collection,
statusUpdater support.StatusUpdater,
resolver graph.ContainerResolver,
) func(any) bool {
var (
isSet bool
collectPath string
option optionIdentifier
category path.CategoryType
validate func(*string) bool
)
return func(folderItem any) bool {
if !isSet {
option, category, validate = getCategoryAndValidation(qp.Scope)
isSet = true
}
if option == events {
folderItem = CreateCalendarDisplayable(folderItem)
}
folder, ok := folderItem.(graph.Displayable)
if !ok {
errUpdater(qp.User,
fmt.Errorf("unable to convert input of %T for category: %s", folderItem, category.String()),
)
return true
}
if validate(folder.GetDisplayName()) {
return true
}
if option == messages {
collectPath = *folder.GetId()
} else {
collectPath = *folder.GetDisplayName()
}
dirPath, err := getCollectionPath(
ctx,
qp,
resolver,
collectPath,
category,
)
if err != nil {
errUpdater(
"failure converting path during IterateFilterFolderDirectoriesForCollections",
err,
)
return true
}
service, err := createService(qp.Credentials, qp.FailFast)
if err != nil {
errUpdater(
*folder.GetDisplayName(),
errors.Wrap(err, "creating service to iterate filterFolder directories for user: "+qp.User))
return true
}
temp := NewCollection(
qp.User,
dirPath,
option,
service,
statusUpdater,
)
collections[*folder.GetDisplayName()] = &temp
return true
}
}
func IterateSelectAllContactsForCollections(
ctx context.Context,
qp graph.QueryParams,
errUpdater func(string, error),
collections map[string]*Collection,
statusUpdater support.StatusUpdater,
resolver graph.ContainerResolver,
) func(any) bool {
var (
isPrimarySet bool
service graph.Service
)
return func(folderItem any) bool {
folder, ok := folderItem.(models.ContactFolderable)
if !ok {
errUpdater(
qp.User,
errors.New("casting folderItem to models.ContactFolderable"),
)
}
if !isPrimarySet && folder.GetParentFolderId() != nil {
err := CollectFolders(
ctx,
qp,
collections,
statusUpdater,
resolver,
)
if err != nil {
errUpdater(qp.User, err)
return false
}
service, err = createService(qp.Credentials, qp.FailFast)
if err != nil {
errUpdater(
qp.User,
errors.Wrap(err, "unable to create service during IterateSelectAllContactsForCollections"),
)
return true
}
isPrimarySet = true
// Create and Populate Default Contacts folder Collection if true
if qp.Scope.Matches(selectors.ExchangeContactFolder, DefaultContactFolder) {
dirPath, err := path.Builder{}.Append(DefaultContactFolder).ToDataLayerExchangePathForCategory(
qp.Credentials.AzureTenantID,
qp.User,
path.ContactsCategory,
false,
)
if err != nil {
errUpdater(
qp.User,
err,
)
return false
}
edc := NewCollection(
qp.User,
dirPath,
contacts,
service,
statusUpdater,
)
listOfIDs, err := ReturnContactIDsFromDirectory(ctx, service, qp.User, *folder.GetParentFolderId())
if err != nil {
errUpdater(
qp.User,
err,
)
return false
}
edc.jobs = append(edc.jobs, listOfIDs...)
collections[DefaultContactFolder] = &edc
}
}
if folder.GetDisplayName() == nil {
// This should never happen. Skipping to avoid kernel panic
return true
}
collection, ok := collections[*folder.GetDisplayName()]
if !ok {
return true // Not included
}
listOfIDs, err := ReturnContactIDsFromDirectory(ctx, service, qp.User, *folder.GetId())
if err != nil {
errUpdater(
errs = support.WrapAndAppend(
qp.User,
err,
errs,
)
return true
}
collection.jobs = append(collection.jobs, listOfIDs...)
return true
}
}
// IDistFunc collection of helper functions which return a list of strings
// from a response.
type IDListFunc func(ctx context.Context, gs graph.Service, user, m365ID string) ([]string, error)
// ReturnContactIDsFromDirectory function that returns a list of all the m365IDs of the contacts
// of the targeted directory
func ReturnContactIDsFromDirectory(ctx context.Context, gs graph.Service, user, directoryID string) ([]string, error) {
options, err := optionsForContactFoldersItem([]string{"parentFolderId"})
if err != nil {
return nil, err
col.jobs = append(col.jobs, jobs...)
}
stringArray := []string{}
response, err := gs.Client().
UsersById(user).
ContactFoldersById(directoryID).
Contacts().
Get(ctx, options)
if err != nil {
return nil, err
}
pageIterator, err := msgraphgocore.NewPageIterator(
response,
gs.Adapter(),
models.CreateContactCollectionResponseFromDiscriminatorValue,
)
callbackFunc := func(pageItem any) bool {
entry, ok := pageItem.(models.Contactable)
if !ok {
err = errors.New("casting pageItem to models.Contactable")
return false
}
stringArray = append(stringArray, *entry.GetId())
return true
}
if iterateErr := pageIterator.Iterate(ctx, callbackFunc); iterateErr != nil {
return nil, iterateErr
}
if err != nil {
return nil, err
}
return stringArray, nil
return errs
}
func IterativeCollectContactContainers(
@ -491,8 +137,25 @@ func IterativeCollectCalendarContainers(
}
}
// ReturnEventIDsFromCalendar returns a list of all M365IDs of events of the targeted Calendar.
func ReturnEventIDsFromCalendar(
// FetchIDFunc collection of helper functions which return a list of strings
// from a response.
type FetchIDFunc func(ctx context.Context, gs graph.Service, user, containerID string) ([]string, error)
func getFetchIDFunc(category path.CategoryType) (FetchIDFunc, error) {
switch category {
case path.EmailCategory:
return FetchMessageIDsFromDirectory, nil
case path.EventsCategory:
return FetchEventIDsFromCalendar, nil
case path.ContactsCategory:
return FetchContactIDsFromDirectory, nil
default:
return nil, fmt.Errorf("category %s not supported by getFetchIDFunc", category)
}
}
// FetchEventIDsFromCalendar returns a list of all M365IDs of events of the targeted Calendar.
func FetchEventIDsFromCalendar(
ctx context.Context,
gs graph.Service,
user, calendarID string,
@ -504,11 +167,7 @@ func ReturnEventIDsFromCalendar(
CalendarsById(calendarID).
Events().Get(ctx, nil)
if err != nil {
return nil, err
}
if err != nil {
return nil, err
return nil, errors.Wrap(err, support.ConnectorStackErrorTrace(err))
}
pageIterator, err := msgraphgocore.NewPageIterator(
@ -516,27 +175,158 @@ func ReturnEventIDsFromCalendar(
gs.Adapter(),
models.CreateEventCollectionResponseFromDiscriminatorValue,
)
if err != nil {
return nil, errors.Wrap(err, "iterator creation failure during fetchEventIDs")
}
callbackFunc := func(pageItem any) bool {
entry, ok := pageItem.(models.Eventable)
var errs *multierror.Error
err = pageIterator.Iterate(ctx, func(pageItem any) bool {
entry, ok := pageItem.(graph.Idable)
if !ok {
err = errors.New("casting pageItem to models.Eventable")
return false
errs = multierror.Append(errs, errors.New("item without GetId() call"))
return true
}
if entry.GetId() == nil {
errs = multierror.Append(errs, errors.New("item with nil ID"))
}
ids = append(ids, *entry.GetId())
return true
})
if err != nil {
return nil, errors.Wrap(
err,
support.ConnectorStackErrorTrace(err)+
" :iterateFailure for fetching events from calendar "+calendarID,
)
}
if iterateErr := pageIterator.Iterate(ctx, callbackFunc); iterateErr != nil {
return nil,
errors.Wrap(iterateErr, support.ConnectorStackErrorTrace(err))
}
return ids, errs.ErrorOrNil()
}
// FetchContactIDsFromDirectory function that returns a list of all the m365IDs of the contacts
// of the targeted directory
func FetchContactIDsFromDirectory(ctx context.Context, gs graph.Service, user, directoryID string) ([]string, error) {
options, err := optionsForContactFoldersItem([]string{"parentFolderId"})
if err != nil {
return nil, err
}
return ids, nil
ids := []string{}
response, err := gs.Client().
UsersById(user).
ContactFoldersById(directoryID).
Contacts().
Get(ctx, options)
if err != nil {
return nil, errors.Wrap(err, support.ConnectorStackErrorTrace(err))
}
pageIterator, err := msgraphgocore.NewPageIterator(
response,
gs.Adapter(),
models.CreateContactCollectionResponseFromDiscriminatorValue,
)
if err != nil {
return nil, errors.Wrap(err, "failure to create iterator during FecthContactIDs")
}
var errs *multierror.Error
err = pageIterator.Iterate(ctx, func(pageItem any) bool {
entry, ok := pageItem.(graph.Idable)
if !ok {
errs = multierror.Append(
errs,
errors.New("casting pageItem to models.Contactable"),
)
return true
}
ids = append(ids, *entry.GetId())
return true
})
if err != nil {
return nil,
errors.Wrap(
err,
support.ConnectorStackErrorTrace(err)+
" :iterate failure during fetching contactIDs from directory "+directoryID,
)
}
return ids, errs.ErrorOrNil()
}
// FetchMessageIDsFromDirectory function that returns a list of all the m365IDs of the exchange.Mail
// of the targeted directory
func FetchMessageIDsFromDirectory(
ctx context.Context,
gs graph.Service,
user, directoryID string,
) ([]string, error) {
ids := []string{}
options, err := optionsForFolderMessages([]string{"id"})
if err != nil {
return nil, errors.Wrap(err, "getting query options")
}
response, err := gs.Client().
UsersById(user).
MailFoldersById(directoryID).
Messages().
Get(ctx, options)
if err != nil {
return nil, errors.Wrap(
errors.Wrap(err, support.ConnectorStackErrorTrace(err)),
"initial folder query",
)
}
pageIter, err := msgraphgocore.NewPageIterator(
response,
gs.Adapter(),
models.CreateMessageCollectionResponseFromDiscriminatorValue,
)
if err != nil {
return nil, errors.Wrap(err, "creating graph iterator")
}
var errs *multierror.Error
err = pageIter.Iterate(ctx, func(pageItem any) bool {
item, ok := pageItem.(graph.Idable)
if !ok {
errs = multierror.Append(errs, errors.New("item without ID function"))
return true
}
if item.GetId() == nil {
errs = multierror.Append(errs, errors.New("item with nil ID"))
return true
}
ids = append(ids, *item.GetId())
return true
})
if err != nil {
return nil, errors.Wrap(
err,
support.ConnectorStackErrorTrace(err)+
" :iterateFailure for fetching messages from directory "+directoryID,
)
}
return ids, errs.ErrorOrNil()
}

View File

@ -2,15 +2,10 @@ package exchange
import (
"context"
"fmt"
absser "github.com/microsoft/kiota-abstractions-go/serialization"
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/pkg/errors"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/support"
)
// GraphQuery represents functions which perform exchange-specific queries
@ -125,84 +120,3 @@ func RetrieveEventDataForUser(ctx context.Context, gs graph.Service, user, m365I
func RetrieveMessageDataForUser(ctx context.Context, gs graph.Service, user, m365ID string) (absser.Parsable, error) {
return gs.Client().UsersById(user).MessagesById(m365ID).Get(ctx, nil)
}
// CollectFolders is a utility function for creating Collections based off parameters found
// in the ExchangeScope found in the graph.QueryParams
// TODO(ashmrtn): This may not need to do the query if we decide the cache
// should always:
// 1. be passed in
// 2. be populated with all folders for the user
func CollectFolders(
ctx context.Context,
qp graph.QueryParams,
collections map[string]*Collection,
statusUpdater support.StatusUpdater,
resolver graph.ContainerResolver,
) error {
var (
query GraphQuery
transformer absser.ParsableFactory
queryService, err = createService(qp.Credentials, qp.FailFast)
)
if err != nil {
return errors.Wrapf(
err,
"unable to create graph.Service within CollectFolders service for "+qp.User,
)
}
option := scopeToOptionIdentifier(qp.Scope)
switch option {
case messages:
query = GetAllFolderNamesForUser
transformer = models.CreateMailFolderCollectionResponseFromDiscriminatorValue
case contacts:
query = GetAllContactFolderNamesForUser
transformer = models.CreateContactFolderCollectionResponseFromDiscriminatorValue
case events:
query = GetAllCalendarNamesForUser
transformer = models.CreateCalendarCollectionResponseFromDiscriminatorValue
default:
return fmt.Errorf("unsupported option %s used in CollectFolders", option)
}
response, err := query(ctx, queryService, qp.User)
if err != nil {
return fmt.Errorf(
"unable to query mail folder for %s: details: %s",
qp.User,
support.ConnectorStackErrorTrace(err),
)
}
// Iterator required to ensure all potential folders are inspected
// when the breadth of the folder space is large
pageIterator, err := msgraphgocore.NewPageIterator(
response,
&queryService.adapter,
transformer)
if err != nil {
return errors.Wrap(err, "unable to create iterator during mail folder query service")
}
errUpdater := func(id string, e error) {
err = support.WrapAndAppend(id, e, err)
}
callbackFunc := IterateFilterContainersForCollections(
ctx,
qp,
errUpdater,
collections,
statusUpdater,
resolver,
)
iterateFailure := pageIterator.Iterate(ctx, callbackFunc)
if iterateFailure != nil {
err = support.WrapAndAppend(qp.User+" iterate failure", iterateFailure, err)
}
return err
}

View File

@ -520,8 +520,8 @@ func establishMailRestoreLocation(
}
// establishContactsRestoreLocation creates Contact Folders in sequence
// and updates the container resolver appropriately. Contact Folders
// are displayed in a flat representation. Therefore, only the root can be populated and all content
// and updates the container resolver appropriately. Contact Folders are
// displayed in a flat representation. Therefore, only the root can be populated and all content
// must be restored into the root location.
// @param folders is the list of intended folders from root to leaf (e.g. [root ...])
// @param isNewCache bool representation of whether Populate function needs to be run

View File

@ -0,0 +1,109 @@
package graph
import (
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/pkg/errors"
"github.com/alcionai/corso/src/pkg/path"
)
// CachedContainer is used for local unit tests but also makes it so that this
// code can be broken into generic- and service-specific chunks later on to
// reuse logic in IDToPath.
type CachedContainer interface {
Container
Path() *path.Builder
SetPath(*path.Builder)
}
// checkRequiredValues is a helper function to ensure that
// all the pointers are set prior to being called.
func CheckRequiredValues(c Container) error {
idPtr := c.GetId()
if idPtr == nil || len(*idPtr) == 0 {
return errors.New("folder without ID")
}
ptr := c.GetDisplayName()
if ptr == nil || len(*ptr) == 0 {
return errors.Errorf("folder %s without display name", *idPtr)
}
ptr = c.GetParentFolderId()
if ptr == nil || len(*ptr) == 0 {
return errors.Errorf("folder %s without parent ID", *idPtr)
}
return nil
}
//======================================
// cachedContainer Implementations
//======================================
var _ CachedContainer = &CacheFolder{}
type CacheFolder struct {
Container
p *path.Builder
}
// NewCacheFolder public constructor for struct
func NewCacheFolder(c Container, pb *path.Builder) CacheFolder {
cf := CacheFolder{
Container: c,
p: pb,
}
return cf
}
//=========================================
// Required Functions to satisfy interfaces
//=========================================
func (cf CacheFolder) Path() *path.Builder {
return cf.p
}
func (cf *CacheFolder) SetPath(newPath *path.Builder) {
cf.p = newPath
}
// CalendarDisplayable is a transformative struct that aligns
// models.Calendarable interface with the container interface.
// Calendars do not have the 2 of the
type CalendarDisplayable struct {
models.Calendarable
parentID string
}
// GetDisplayName returns the *string of the calendar name
func (c CalendarDisplayable) GetDisplayName() *string {
return c.GetName()
}
// GetParentFolderId returns the default calendar name address
// EventCalendars have a flat hierarchy and Calendars are rooted
// at the default
//nolint:revive
func (c CalendarDisplayable) GetParentFolderId() *string {
return &c.parentID
}
// CreateCalendarDisplayable helper function to create the
// calendarDisplayable during msgraph-sdk-go iterative process
// @param entry is the input supplied by pageIterator.Iterate()
// @param parentID of Calendar sets. Only populate when used with
// EventCalendarCache
func CreateCalendarDisplayable(entry any, parentID string) *CalendarDisplayable {
calendar, ok := entry.(models.Calendarable)
if !ok {
return nil
}
return &CalendarDisplayable{
Calendarable: calendar,
parentID: parentID,
}
}

View File

@ -53,15 +53,6 @@ type Container interface {
Displayable
}
// CachedContainer is used for local unit tests but also makes it so that this
// code can be broken into generic- and service-specific chunks later on to
// reuse logic in IDToPath.
type CachedContainer interface {
Container
Path() *path.Builder
SetPath(*path.Builder)
}
// ContainerResolver houses functions for getting information about containers
// from remote APIs (i.e. resolve folder paths with Graph API). Resolvers may
// cache information about containers.
@ -83,6 +74,7 @@ type ContainerResolver interface {
PathInCache(pathString string) (string, bool)
AddToCache(ctx context.Context, m365Container Container) error
// Items returns the containers in the cache.
Items() []CachedContainer
}

View File

@ -23,7 +23,6 @@ import (
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/logger"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors"
)
@ -274,112 +273,7 @@ func (gc *GraphConnector) RestoreDataCollections(
return deets, err
}
func scopeToPathCategory(scope selectors.ExchangeScope) path.CategoryType {
if scope.IncludesCategory(selectors.ExchangeMail) {
return path.EmailCategory
}
if scope.IncludesCategory(selectors.ExchangeContact) {
return path.ContactsCategory
}
if scope.IncludesCategory(selectors.ExchangeEvent) {
return path.EventsCategory
}
return path.UnknownCategory
}
func (gc *GraphConnector) fetchItemsByFolder(
ctx context.Context,
qp graph.QueryParams,
resolver graph.ContainerResolver,
) (map[string]*exchange.Collection, error) {
var errs *multierror.Error
collections := map[string]*exchange.Collection{}
// This gets the collections, but does not get the items in the
// collection.
err := exchange.CollectionsFromResolver(
ctx,
qp,
resolver,
gc.UpdateStatus,
collections,
)
if err != nil {
return nil, errors.Wrap(err, "getting target collections")
}
for id, col := range collections {
// Fetch items for said collection.
err := exchange.AddItemsToCollection(ctx, gc.Service(), qp.User, id, col)
if err != nil {
errs = multierror.Append(errs, errors.Wrapf(
err,
"fetching items for collection %s with ID %s",
col.FullPath().String(),
id,
))
}
}
return collections, errs.ErrorOrNil()
}
func (gc *GraphConnector) legacyFetchItems(
ctx context.Context,
scope selectors.ExchangeScope,
qp graph.QueryParams,
resolver graph.ContainerResolver,
) (map[string]*exchange.Collection, error) {
var (
errs error
errUpdater = func(id string, err error) {
errs = support.WrapAndAppend(id, err, errs)
}
collections = map[string]*exchange.Collection{}
)
transformer, queries, gIter, err := exchange.SetupExchangeCollectionVars(scope)
if err != nil {
return nil, support.WrapAndAppend(gc.Service().Adapter().GetBaseUrl(), err, nil)
}
// queries is assumed to provide fallbacks in case of empty results. Any
// non-zero collection production will break out of the loop.
for _, query := range queries {
response, err := query(ctx, &gc.graphService, qp.User)
if err != nil {
return nil, errors.Wrapf(
err,
"user %s M365 query: %s",
qp.User, support.ConnectorStackErrorTrace(err))
}
pageIterator, err := msgraphgocore.NewPageIterator(response, &gc.graphService.adapter, transformer)
if err != nil {
return nil, err
}
// callbackFunc iterates through all M365 object target and fills exchange.Collection.jobs[]
// with corresponding item M365IDs. New collections are created for each directory.
// Each directory used the M365 Identifier. The use of ID stops collisions betweens users
callbackFunc := gIter(ctx, qp, errUpdater, collections, gc.UpdateStatus, resolver)
if err := pageIterator.Iterate(ctx, callbackFunc); err != nil {
return nil, support.WrapAndAppend(gc.graphService.adapter.GetBaseUrl(), err, errs)
}
if len(collections) > 0 {
break
}
}
return collections, errs
}
// createCollection - utility function that retrieves M365
// createCollections - utility function that retrieves M365
// IDs through Microsoft Graph API. The selectors.ExchangeScope
// determines the type of collections that are stored.
// to the GraphConnector struct.
@ -393,7 +287,7 @@ func (gc *GraphConnector) createCollections(
allCollections := make([]*exchange.Collection, 0)
// Create collection of ExchangeDataCollection
for _, user := range users {
var collections map[string]*exchange.Collection
collections := make(map[string]*exchange.Collection)
qp := graph.QueryParams{
User: user,
@ -402,31 +296,24 @@ func (gc *GraphConnector) createCollections(
Credentials: gc.credentials,
}
// Currently only mail has a folder cache implemented.
resolver, err := exchange.MaybeGetAndPopulateFolderResolver(
resolver, err := exchange.PopulateExchangeContainerResolver(
ctx,
qp,
scopeToPathCategory(scope),
graph.ScopeToPathCategory(qp.Scope),
)
if err != nil {
return nil, errors.Wrap(err, "getting folder cache")
}
if scopeToPathCategory(scope) == path.EmailCategory {
if resolver == nil {
return nil, errors.New("unable to create mail folder resolver")
}
err = exchange.FilterContainersAndFillCollections(
ctx,
qp,
collections,
gc.UpdateStatus,
resolver)
collections, err = gc.fetchItemsByFolder(ctx, qp, resolver)
if err != nil {
errs = multierror.Append(errs, err)
}
} else {
collections, err = gc.legacyFetchItems(ctx, scope, qp, resolver)
// Preserving previous behavior.
if err != nil {
return nil, err // return error if snapshot is incomplete
}
if err != nil {
return nil, errors.Wrap(err, "filling collections")
}
for _, collection := range collections {

View File

@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/connector/exchange"
"github.com/alcionai/corso/src/internal/connector/graph"
"github.com/alcionai/corso/src/internal/connector/mockconnector"
"github.com/alcionai/corso/src/internal/connector/support"
"github.com/alcionai/corso/src/internal/data"
@ -193,9 +192,10 @@ func (suite *GraphConnectorIntegrationSuite) TestContactSerializationRegression(
{
name: "Default Contact Folder",
getCollection: func(t *testing.T) []*exchange.Collection {
sel := selectors.NewExchangeBackup()
sel.Include(sel.ContactFolders([]string{suite.user}, []string{exchange.DefaultContactFolder}))
collections, err := connector.createCollections(ctx, sel.Scopes()[0])
scope := selectors.
NewExchangeBackup().
ContactFolders([]string{suite.user}, []string{exchange.DefaultContactFolder})[0]
collections, err := connector.createCollections(ctx, scope)
require.NoError(t, err)
return collections
@ -206,7 +206,7 @@ func (suite *GraphConnectorIntegrationSuite) TestContactSerializationRegression(
for _, test := range tests {
suite.T().Run(test.name, func(t *testing.T) {
edcs := test.getCollection(t)
assert.Equal(t, len(edcs), 1)
require.Equal(t, len(edcs), 1)
edc := edcs[0]
assert.Equal(t, edc.FullPath().Folder(), exchange.DefaultContactFolder)
streamChannel := edc.Items()
@ -325,7 +325,6 @@ func (suite *GraphConnectorIntegrationSuite) TestMailFetch() {
var (
t = suite.T()
userID = tester.M365UserID(t)
sel = selectors.NewExchangeBackup()
)
tests := []struct {
@ -333,11 +332,17 @@ func (suite *GraphConnectorIntegrationSuite) TestMailFetch() {
scope selectors.ExchangeScope
folderNames map[string]struct{}
}{
{
name: "Mail Iterative Check",
scope: selectors.NewExchangeBackup().MailFolders([]string{userID}, selectors.Any())[0],
folderNames: map[string]struct{}{
exchange.DefaultMailFolder: {},
"Sent Items": {},
},
},
{
name: "Folder Iterative Check Mail",
// Only select specific folders so the test doesn't flake when the CI
// cleanup task deletes things.
scope: sel.MailFolders(
scope: selectors.NewExchangeBackup().MailFolders(
[]string{userID},
[]string{exchange.DefaultMailFolder},
)[0],
@ -351,25 +356,7 @@ func (suite *GraphConnectorIntegrationSuite) TestMailFetch() {
for _, test := range tests {
suite.T().Run(test.name, func(t *testing.T) {
qp := graph.QueryParams{
User: userID,
Scope: test.scope,
Credentials: gc.credentials,
FailFast: false,
}
resolver, err := exchange.MaybeGetAndPopulateFolderResolver(
ctx,
qp,
scopeToPathCategory(qp.Scope),
)
require.NoError(t, err)
collections, err := gc.fetchItemsByFolder(
ctx,
qp,
resolver,
)
collections, err := gc.createCollections(ctx, test.scope)
require.NoError(t, err)
for _, c := range collections {