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:
ashmrtn 2022-09-01 16:03:35 -07:00 committed by GitHub
parent 784f006da5
commit 4a96f2571d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 522 additions and 0 deletions

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

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

View File

@ -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) {