Second attempt at directory cache/resolver for a user (#724)
## Description Directory cache capable of converting a folder ID to a complete path to the folder ## Type of change Please check the type of change your PR introduces: - [x] 🌻 Feature - [ ] 🐛 Bugfix - [ ] 🗺️ Documentation - [ ] 🤖 Test - [ ] 🐹 Trivial/Minor ## Issue(s) <!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. --> #456 ## Test Plan <!-- How will this be tested prior to merging.--> - [ ] 💪 Manual - [x] ⚡ Unit test - [ ] 💚 E2E
This commit is contained in:
parent
784f006da5
commit
4a96f2571d
179
src/internal/connector/exchange/mail_folder_cache.go
Normal file
179
src/internal/connector/exchange/mail_folder_cache.go
Normal file
@ -0,0 +1,179 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
multierror "github.com/hashicorp/go-multierror"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
msfolderdelta "github.com/microsoftgraph/msgraph-sdk-go/users/item/mailfolders/delta"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/alcionai/corso/internal/connector/graph"
|
||||
"github.com/alcionai/corso/internal/path"
|
||||
)
|
||||
|
||||
const (
|
||||
// rootFolderAlias is the per-user root container alias for the exchange email
|
||||
// hierarchy.
|
||||
rootFolderAlias = "msgfolderroot"
|
||||
// nextDataLink is a random map key so we can iterate through delta results.
|
||||
nextDataLink = "@odata.nextLink"
|
||||
)
|
||||
|
||||
type container interface {
|
||||
descendable
|
||||
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)
|
||||
}
|
||||
|
||||
type mailFolder struct {
|
||||
models.MailFolderable
|
||||
p *path.Builder
|
||||
}
|
||||
|
||||
func (mf mailFolder) Path() *path.Builder {
|
||||
return mf.p
|
||||
}
|
||||
|
||||
func (mf *mailFolder) SetPath(newPath *path.Builder) {
|
||||
mf.p = newPath
|
||||
}
|
||||
|
||||
type mailFolderCache struct {
|
||||
cache map[string]cachedContainer
|
||||
gs graph.Service
|
||||
userID string
|
||||
}
|
||||
|
||||
// populateRoot fetches and populates the root folder in the cache so the cache
|
||||
// knows when to stop resolving the path.
|
||||
func (mc *mailFolderCache) populateRoot(context.Context) error {
|
||||
wantedOpts := []string{"displayName", "parentFolderId"}
|
||||
|
||||
opts, err := optionsForMailFoldersItem(wantedOpts)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "getting options for mail folders %v", wantedOpts)
|
||||
}
|
||||
|
||||
f, err := mc.
|
||||
gs.
|
||||
Client().
|
||||
UsersById(mc.userID).
|
||||
MailFoldersById(rootFolderAlias).
|
||||
GetWithRequestConfigurationAndResponseHandler(opts, nil)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "fetching root folder")
|
||||
}
|
||||
|
||||
// Root only needs the ID because we hide it's name for Mail.
|
||||
idPtr := f.GetId()
|
||||
if idPtr == nil || len(*idPtr) == 0 {
|
||||
return errors.New("root folder has no ID")
|
||||
}
|
||||
|
||||
mc.cache[*idPtr] = &mailFolder{
|
||||
MailFolderable: f,
|
||||
p: &path.Builder{},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (mc *mailFolderCache) Populate(ctx context.Context) error {
|
||||
if mc.cache == nil {
|
||||
mc.cache = map[string]cachedContainer{}
|
||||
}
|
||||
|
||||
if err := mc.populateRoot(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
builder := mc.
|
||||
gs.
|
||||
Client().
|
||||
UsersById(mc.userID).
|
||||
MailFolders().
|
||||
Delta()
|
||||
|
||||
var errs *multierror.Error
|
||||
|
||||
for {
|
||||
resp, err := builder.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range resp.GetValue() {
|
||||
if err := checkRequiredValues(f); err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
mc.cache[*f.GetId()] = &mailFolder{MailFolderable: f}
|
||||
}
|
||||
|
||||
r := resp.GetAdditionalData()
|
||||
|
||||
n, ok := r[nextDataLink]
|
||||
if !ok || n == nil {
|
||||
break
|
||||
}
|
||||
|
||||
link := *(n.(*string))
|
||||
builder = msfolderdelta.NewDeltaRequestBuilder(link, mc.gs.Adapter())
|
||||
}
|
||||
|
||||
return errs.ErrorOrNil()
|
||||
}
|
||||
|
||||
func (mc *mailFolderCache) IDToPath(
|
||||
ctx context.Context,
|
||||
folderID string,
|
||||
) (*path.Builder, error) {
|
||||
c, ok := mc.cache[folderID]
|
||||
if !ok {
|
||||
return nil, errors.Errorf("folder %s not cached", folderID)
|
||||
}
|
||||
|
||||
p := c.Path()
|
||||
if p != nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
parentPath, err := mc.IDToPath(ctx, *c.GetParentFolderId())
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "retrieving parent folder")
|
||||
}
|
||||
|
||||
fullPath := parentPath.Append(*c.GetDisplayName())
|
||||
c.SetPath(fullPath)
|
||||
|
||||
return fullPath, nil
|
||||
}
|
||||
321
src/internal/connector/exchange/mail_folder_cache_test.go
Normal file
321
src/internal/connector/exchange/mail_folder_cache_test.go
Normal file
@ -0,0 +1,321 @@
|
||||
package exchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
stdpath "path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/internal/connector/graph"
|
||||
"github.com/alcionai/corso/internal/path"
|
||||
"github.com/alcionai/corso/internal/tester"
|
||||
)
|
||||
|
||||
const (
|
||||
// Need to use a hard-coded ID because GetAllFolderNamesForUser only gets
|
||||
// top-level folders right now.
|
||||
//nolint:lll
|
||||
testFolderID = "AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwAuAAAAAADCNgjhM9QmQYWNcI7hCpPrAQDSEBNbUIB9RL6ePDeF3FIYAABl7AqpAAA="
|
||||
|
||||
// Full folder path for the folder above.
|
||||
expectedFolderPath = "toplevel/subFolder/subsubfolder"
|
||||
)
|
||||
|
||||
type mockContainer struct {
|
||||
id *string
|
||||
name *string
|
||||
parentID *string
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (m mockContainer) GetId() *string {
|
||||
return m.id
|
||||
}
|
||||
|
||||
func (m mockContainer) GetDisplayName() *string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (m mockContainer) GetParentFolderId() *string {
|
||||
return m.parentID
|
||||
}
|
||||
|
||||
type MailFolderCacheUnitSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestMailFolderCacheUnitSuite(t *testing.T) {
|
||||
suite.Run(t, new(MailFolderCacheUnitSuite))
|
||||
}
|
||||
|
||||
func (suite *MailFolderCacheUnitSuite) TestCheckRequiredValues() {
|
||||
id := uuid.NewString()
|
||||
name := "foo"
|
||||
parentID := uuid.NewString()
|
||||
emptyString := ""
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
c mockContainer
|
||||
check assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "NilID",
|
||||
c: mockContainer{
|
||||
id: nil,
|
||||
name: &name,
|
||||
parentID: &parentID,
|
||||
},
|
||||
check: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "NilDisplayName",
|
||||
c: mockContainer{
|
||||
id: &id,
|
||||
name: nil,
|
||||
parentID: &parentID,
|
||||
},
|
||||
check: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "NilParentFolderID",
|
||||
c: mockContainer{
|
||||
id: &id,
|
||||
name: &name,
|
||||
parentID: nil,
|
||||
},
|
||||
check: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "EmptyID",
|
||||
c: mockContainer{
|
||||
id: &emptyString,
|
||||
name: &name,
|
||||
parentID: &parentID,
|
||||
},
|
||||
check: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "EmptyDisplayName",
|
||||
c: mockContainer{
|
||||
id: &id,
|
||||
name: &emptyString,
|
||||
parentID: &parentID,
|
||||
},
|
||||
check: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "EmptyParentFolderID",
|
||||
c: mockContainer{
|
||||
id: &id,
|
||||
name: &name,
|
||||
parentID: &emptyString,
|
||||
},
|
||||
check: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "AllValues",
|
||||
c: mockContainer{
|
||||
id: &id,
|
||||
name: &name,
|
||||
parentID: &parentID,
|
||||
},
|
||||
check: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
suite.T().Run(test.name, func(t *testing.T) {
|
||||
test.check(t, checkRequiredValues(test.c))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newMockCachedContainer(name string) *mockCachedContainer {
|
||||
return &mockCachedContainer{
|
||||
id: uuid.NewString(),
|
||||
parentID: uuid.NewString(),
|
||||
displayName: name,
|
||||
}
|
||||
}
|
||||
|
||||
type mockCachedContainer struct {
|
||||
id string
|
||||
parentID string
|
||||
displayName string
|
||||
p *path.Builder
|
||||
expectedPath string
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (m mockCachedContainer) GetId() *string {
|
||||
return &m.id
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func (m mockCachedContainer) GetParentFolderId() *string {
|
||||
return &m.parentID
|
||||
}
|
||||
|
||||
func (m mockCachedContainer) GetDisplayName() *string {
|
||||
return &m.displayName
|
||||
}
|
||||
|
||||
func (m mockCachedContainer) Path() *path.Builder {
|
||||
return m.p
|
||||
}
|
||||
|
||||
func (m *mockCachedContainer) SetPath(newPath *path.Builder) {
|
||||
m.p = newPath
|
||||
}
|
||||
|
||||
// TestConfiguredMailFolderCacheUnitSuite cannot run its tests in parallel.
|
||||
type ConfiguredMailFolderCacheUnitSuite struct {
|
||||
suite.Suite
|
||||
|
||||
mc mailFolderCache
|
||||
|
||||
allContainers []*mockCachedContainer
|
||||
}
|
||||
|
||||
func (suite *ConfiguredMailFolderCacheUnitSuite) SetupTest() {
|
||||
suite.allContainers = []*mockCachedContainer{}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
suite.allContainers = append(
|
||||
suite.allContainers,
|
||||
newMockCachedContainer(strings.Repeat("sub", i)+"folder"),
|
||||
)
|
||||
}
|
||||
|
||||
// Base case for the recursive lookup.
|
||||
suite.allContainers[0].p = path.Builder{}.Append(suite.allContainers[0].displayName)
|
||||
suite.allContainers[0].expectedPath = suite.allContainers[0].displayName
|
||||
|
||||
for i := 1; i < len(suite.allContainers); i++ {
|
||||
suite.allContainers[i].parentID = suite.allContainers[i-1].id
|
||||
suite.allContainers[i].expectedPath = stdpath.Join(
|
||||
suite.allContainers[i-1].expectedPath,
|
||||
suite.allContainers[i].displayName,
|
||||
)
|
||||
}
|
||||
|
||||
suite.mc = mailFolderCache{cache: map[string]cachedContainer{}}
|
||||
|
||||
for _, c := range suite.allContainers {
|
||||
suite.mc.cache[c.id] = c
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredMailFolderCacheUnitSuite(t *testing.T) {
|
||||
suite.Run(t, new(ConfiguredMailFolderCacheUnitSuite))
|
||||
}
|
||||
|
||||
func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderNoPathsCached() {
|
||||
ctx := context.Background()
|
||||
|
||||
for _, c := range suite.allContainers {
|
||||
suite.T().Run(*c.GetDisplayName(), func(t *testing.T) {
|
||||
p, err := suite.mc.IDToPath(ctx, c.id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, c.expectedPath, p.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderCachesPaths() {
|
||||
t := suite.T()
|
||||
ctx := context.Background()
|
||||
c := suite.allContainers[len(suite.allContainers)-1]
|
||||
|
||||
p, err := suite.mc.IDToPath(ctx, c.id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, c.expectedPath, p.String())
|
||||
|
||||
c.parentID = "foo"
|
||||
|
||||
p, err = suite.mc.IDToPath(ctx, c.id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, c.expectedPath, p.String())
|
||||
}
|
||||
|
||||
func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderErrorsParentNotFound() {
|
||||
t := suite.T()
|
||||
ctx := context.Background()
|
||||
last := suite.allContainers[len(suite.allContainers)-1]
|
||||
almostLast := suite.allContainers[len(suite.allContainers)-2]
|
||||
|
||||
delete(suite.mc.cache, almostLast.id)
|
||||
|
||||
_, err := suite.mc.IDToPath(ctx, last.id)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderErrorsNotFound() {
|
||||
t := suite.T()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := suite.mc.IDToPath(ctx, "foo")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
type MailFolderCacheIntegrationSuite struct {
|
||||
suite.Suite
|
||||
gs graph.Service
|
||||
}
|
||||
|
||||
func (suite *MailFolderCacheIntegrationSuite) SetupSuite() {
|
||||
t := suite.T()
|
||||
|
||||
_, err := tester.GetRequiredEnvVars(tester.M365AcctCredEnvs...)
|
||||
require.NoError(t, err)
|
||||
|
||||
a := tester.NewM365Account(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
m365, err := a.M365Config()
|
||||
require.NoError(t, err)
|
||||
|
||||
service, err := createService(m365, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
suite.gs = service
|
||||
}
|
||||
|
||||
func TestMailFolderCacheIntegrationSuite(t *testing.T) {
|
||||
if err := tester.RunOnAny(
|
||||
tester.CorsoCITests,
|
||||
tester.CorsoGraphConnectorTests,
|
||||
); err != nil {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
suite.Run(t, new(MailFolderCacheIntegrationSuite))
|
||||
}
|
||||
|
||||
func (suite *MailFolderCacheIntegrationSuite) TestDeltaFetch() {
|
||||
ctx := context.Background()
|
||||
t := suite.T()
|
||||
userID := tester.M365UserID(t)
|
||||
|
||||
mfc := mailFolderCache{
|
||||
userID: userID,
|
||||
gs: suite.gs,
|
||||
}
|
||||
|
||||
require.NoError(t, mfc.Populate(ctx))
|
||||
|
||||
p, err := mfc.IDToPath(ctx, testFolderID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedFolderPath, p.String())
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
mscontacts "github.com/microsoftgraph/msgraph-sdk-go/users/item/contacts"
|
||||
msevents "github.com/microsoftgraph/msgraph-sdk-go/users/item/events"
|
||||
msfolder "github.com/microsoftgraph/msgraph-sdk-go/users/item/mailfolders"
|
||||
msfolderitem "github.com/microsoftgraph/msgraph-sdk-go/users/item/mailfolders/item"
|
||||
msmessage "github.com/microsoftgraph/msgraph-sdk-go/users/item/messages"
|
||||
msitem "github.com/microsoftgraph/msgraph-sdk-go/users/item/messages/item"
|
||||
"github.com/pkg/errors"
|
||||
@ -224,6 +225,27 @@ func optionsForMailFolders(moreOps []string) (*msfolder.MailFoldersRequestBuilde
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// optionsForMailFoldersItem transforms the options into a more dynamic call for MailFoldersById.
|
||||
// moreOps is a []string of options(e.g. "displayName", "isHidden")
|
||||
// Returns first call in MailFoldersById().GetWithRequestConfigurationAndResponseHandler(options, handler)
|
||||
func optionsForMailFoldersItem(
|
||||
moreOps []string,
|
||||
) (*msfolderitem.MailFolderItemRequestBuilderGetRequestConfiguration, error) {
|
||||
selecting, err := buildOptions(moreOps, folders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestParameters := &msfolderitem.MailFolderItemRequestBuilderGetQueryParameters{
|
||||
Select: selecting,
|
||||
}
|
||||
options := &msfolderitem.MailFolderItemRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: requestParameters,
|
||||
}
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// optionsForEvents ensures valid option inputs for exchange.Events
|
||||
// @return is first call in Events().GetWithRequestConfigurationAndResponseHandler(options, handler)
|
||||
func optionsForEvents(moreOps []string) (*msevents.EventsRequestBuilderGetRequestConfiguration, error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user