From 5aee3cc2ae99513f989580f3fe1b79d151761bcb Mon Sep 17 00:00:00 2001 From: Keepers Date: Tue, 5 Sep 2023 13:54:02 -0600 Subject: [PATCH] use channels api to run backups (#4136) This is the first in a series of PRs to get v0 backups working for channels. In this change, the current api enumerators get plugged into the collections handler to produce backup data. Follow-up PRs will: * hook backup to the CLI * swap full-item enumeration for id-first-get-later pattern * populate each message with all its replies on the get-later * turn on integration testing at the operations and ci layers --- #### Does this PR need a docs update or release note? - [x] :no_entry: No #### Type of change - [x] :sunflower: Feature #### Issue(s) * #3989 #### Test Plan - [x] :zap: Unit test - [x] :green_heart: E2E --- src/internal/m365/collection/groups/backup.go | 126 +----- .../m365/collection/groups/backup_test.go | 392 ++++++++++++++++++ .../m365/collection/groups/channel_handler.go | 65 +++ .../m365/collection/groups/handlers.go | 49 +-- .../collection/groups/testdata/channels.go | 40 ++ src/internal/m365/service/groups/backup.go | 15 + src/pkg/selectors/groups.go | 21 +- src/pkg/selectors/testdata/groups.go | 4 +- 8 files changed, 567 insertions(+), 145 deletions(-) create mode 100644 src/internal/m365/collection/groups/backup_test.go create mode 100644 src/internal/m365/collection/groups/channel_handler.go create mode 100644 src/internal/m365/collection/groups/testdata/channels.go diff --git a/src/internal/m365/collection/groups/backup.go b/src/internal/m365/collection/groups/backup.go index 4624dd942..550eb649e 100644 --- a/src/internal/m365/collection/groups/backup.go +++ b/src/internal/m365/collection/groups/backup.go @@ -17,7 +17,6 @@ import ( "github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/selectors" - "github.com/alcionai/corso/src/pkg/services/m365/api" ) // TODO: incremental support @@ -31,7 +30,7 @@ import ( func CreateCollections( ctx context.Context, bpc inject.BackupProducerConfig, - handler BackupHandler, + bh backupHandler, tenantID string, scope selectors.GroupsScope, // dps DeltaPaths, @@ -55,19 +54,15 @@ func CreateCollections( observe.Bulletf("%s", qp.Category)) defer close(catProgress) - // TODO(keepers): probably shouldn't call out channels here specifically. - // This should be a generic container handler. But we don't need - // to worry about that until if/when we use this code to get email - // conversations as well. - // Also, this should be produced by the Handler. - // chanPager := handler.NewChannelsPager(qp.ProtectedResource.ID()) - // TODO(neha): enumerate channels - channels := []graph.Displayable{} + channels, err := bh.getChannels(ctx) + if err != nil { + return nil, clues.Stack(err) + } collections, err := populateCollections( ctx, qp, - handler, + bh, su, channels, scope, @@ -88,9 +83,9 @@ func CreateCollections( func populateCollections( ctx context.Context, qp graph.QueryParams, - bh BackupHandler, + bh backupHandler, statusUpdater support.StatusUpdater, - channels []graph.Displayable, + channels []models.Channelable, scope selectors.GroupsScope, // dps DeltaPaths, ctrlOpts control.Options, @@ -100,7 +95,6 @@ func populateCollections( channelCollections := map[string]data.BackupCollection{} // channel ID -> delta url or folder path lookups - // TODO(neha/keepers): figure out if deltas are stored per channel, or per group. // deltaURLs = map[string]string{} // currPaths = map[string]string{} // copy of previousPaths. every channel present in the slice param @@ -118,11 +112,12 @@ func populateCollections( return nil, el.Failure() } - cID := ptr.Val(c.GetId()) // delete(tombstones, cID) var ( - err error + cID = ptr.Val(c.GetId()) + cName = ptr.Val(c.GetDisplayName()) + err error // dp = dps[cID] // prevDelta = dp.Delta // prevPathStr = dp.Path // do not log: pii; log prevPath instead @@ -137,10 +132,8 @@ func populateCollections( // }) ) - // currPath, locPath - // TODO(rkeepers): the handler should provide this functionality. // Only create a collection if the path matches the scope. - if !includeContainer(ictx, qp, c, scope, qp.Category) { + if !bh.includeContainer(ictx, qp, c, scope) { continue } @@ -154,10 +147,7 @@ func populateCollections( // ictx = clues.Add(ictx, "previous_path", prevPath) - // TODO: the handler should provide this implementation. - items, err := collectItems( - ctx, - bh.NewMessagePager(qp.ProtectedResource.ID(), ptr.Val(c.GetId()))) + items, _, err := bh.getChannelMessagesDelta(ctx, cID, "") if err != nil { el.AddRecoverable(ctx, clues.Stack(err)) continue @@ -171,15 +161,7 @@ func populateCollections( var prevPath path.Path - // TODO: retrieve from handler - currPath, err := path.Builder{}. - Append(ptr.Val(c.GetId())). - ToDataLayerPath( - qp.TenantID, - qp.ProtectedResource.ID(), - path.GroupsService, - qp.Category, - true) + currPath, err := bh.canonicalPath(path.Builder{}.Append(cID), qp.TenantID) if err != nil { el.AddRecoverable(ctx, clues.Stack(err)) continue @@ -189,7 +171,7 @@ func populateCollections( qp.ProtectedResource.ID(), currPath, prevPath, - path.Builder{}.Append(ptr.Val(c.GetDisplayName())), + path.Builder{}.Append(cName), qp.Category, statusUpdater, ctrlOpts) @@ -209,7 +191,7 @@ func populateCollections( // // as the "previous path", for reference in case of a rename or relocation. // currPaths[cID] = currPath.String() - // FIXME: normally this goes before removal, but linters + // FIXME: normally this goes before removal, but the linters require no bottom comments for _, item := range items { edc.added[ptr.Val(item.GetId())] = struct{}{} } @@ -240,79 +222,3 @@ func populateCollections( return channelCollections, el.Failure() } - -func collectItems( - ctx context.Context, - pager api.DeltaPager[models.ChatMessageable], -) ([]models.ChatMessageable, error) { - items := []models.ChatMessageable{} - - for { - // assume delta urls here, which allows single-token consumption - page, err := pager.GetPage(graph.ConsumeNTokens(ctx, graph.SingleGetOrDeltaLC)) - if err != nil { - return nil, graph.Wrap(ctx, err, "getting page") - } - - // if graph.IsErrInvalidDelta(err) { - // logger.Ctx(ctx).Infow("Invalid previous delta link", "link", prevDelta) - - // invalidPrevDelta = true - // newPaths = map[string]string{} - - // pager.Reset() - - // continue - // } - - vals, err := pager.ValuesIn(page) - if err != nil { - return nil, graph.Wrap(ctx, err, "getting items in page") - } - - items = append(items, vals...) - - nextLink, _ := api.NextAndDeltaLink(page) - - // if len(deltaLink) > 0 { - // newDeltaURL = deltaLink - // } - - // Check if there are more items - if len(nextLink) == 0 { - break - } - - logger.Ctx(ctx).Debugw("found nextLink", "next_link", nextLink) - pager.SetNext(nextLink) - } - - return items, nil -} - -// Returns true if the container passes the scope comparison and should be included. -// Returns: -// - the path representing the directory as it should be stored in the repository. -// - the human-readable path using display names. -// - true if the path passes the scope comparison. -func includeContainer( - ctx context.Context, - qp graph.QueryParams, - gd graph.Displayable, - scope selectors.GroupsScope, - category path.CategoryType, -) bool { - // assume a single-level hierarchy - directory := ptr.Val(gd.GetDisplayName()) - - // TODO(keepers): awaiting parent branch to update to main - ok := scope.Matches(selectors.GroupsCategoryUnknown, directory) - - logger.Ctx(ctx).With( - "included", ok, - "scope", scope, - "match_target", directory, - ).Debug("backup folder selection filter") - - return ok -} diff --git a/src/internal/m365/collection/groups/backup_test.go b/src/internal/m365/collection/groups/backup_test.go new file mode 100644 index 000000000..d604e65a2 --- /dev/null +++ b/src/internal/m365/collection/groups/backup_test.go @@ -0,0 +1,392 @@ +package groups + +import ( + "context" + "fmt" + "testing" + + "github.com/alcionai/clues" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + inMock "github.com/alcionai/corso/src/internal/common/idname/mock" + "github.com/alcionai/corso/src/internal/data" + "github.com/alcionai/corso/src/internal/m365/collection/groups/testdata" + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/internal/m365/support" + "github.com/alcionai/corso/src/internal/operations/inject" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" + "github.com/alcionai/corso/src/internal/version" + "github.com/alcionai/corso/src/pkg/account" + "github.com/alcionai/corso/src/pkg/control" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/selectors" + selTD "github.com/alcionai/corso/src/pkg/selectors/testdata" + "github.com/alcionai/corso/src/pkg/services/m365/api" +) + +// --------------------------------------------------------------------------- +// mocks +// --------------------------------------------------------------------------- + +var _ backupHandler = &mockBackupHandler{} + +type mockBackupHandler struct { + channels []models.Channelable + channelsErr error + messages []models.ChatMessageable + messagesErr error + doNotInclude bool +} + +func (bh mockBackupHandler) getChannels(context.Context) ([]models.Channelable, error) { + return bh.channels, bh.channelsErr +} + +func (bh mockBackupHandler) getChannelMessagesDelta( + _ context.Context, + _, _ string, +) ([]models.ChatMessageable, api.DeltaUpdate, error) { + return bh.messages, api.DeltaUpdate{}, bh.messagesErr +} + +func (bh mockBackupHandler) includeContainer( + context.Context, + graph.QueryParams, + models.Channelable, + selectors.GroupsScope, +) bool { + return !bh.doNotInclude +} + +func (bh mockBackupHandler) canonicalPath( + folders *path.Builder, + tenantID string, +) (path.Path, error) { + return folders. + ToDataLayerPath( + tenantID, + "protectedResource", + path.GroupsService, + path.ChannelMessagesCategory, + false) +} + +// --------------------------------------------------------------------------- +// Unit Suite +// --------------------------------------------------------------------------- + +type BackupUnitSuite struct { + tester.Suite + creds account.M365Config +} + +func TestServiceIteratorsUnitSuite(t *testing.T) { + suite.Run(t, &BackupUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *BackupUnitSuite) SetupSuite() { + a := tconfig.NewFakeM365Account(suite.T()) + m365, err := a.M365Config() + require.NoError(suite.T(), err, clues.ToCore(err)) + suite.creds = m365 +} + +func (suite *BackupUnitSuite) TestPopulateCollections() { + var ( + qp = graph.QueryParams{ + Category: path.ChannelMessagesCategory, // doesn't matter which one we use. + ProtectedResource: inMock.NewProvider("group_id", "user_name"), + TenantID: suite.creds.AzureTenantID, + } + statusUpdater = func(*support.ControllerOperationStatus) {} + allScope = selectors.NewGroupsBackup(nil).Channels(selectors.Any())[0] + ) + + table := []struct { + name string + mock mockBackupHandler + scope selectors.GroupsScope + failFast control.FailurePolicy + expectErr require.ErrorAssertionFunc + expectColls int + expectNewColls int + expectMetadataColls int + expectDoNotMergeColls int + }{ + { + name: "happy path, one container", + mock: mockBackupHandler{ + channels: testdata.StubChannels("one"), + messages: testdata.StubChatMessages("msg-one"), + }, + scope: allScope, + expectErr: require.NoError, + expectColls: 1, + expectNewColls: 1, + expectMetadataColls: 0, + expectDoNotMergeColls: 1, + }, + { + name: "happy path, many containers", + mock: mockBackupHandler{ + channels: testdata.StubChannels("one", "two"), + messages: testdata.StubChatMessages("msg-one"), + }, + scope: allScope, + expectErr: require.NoError, + expectColls: 2, + expectNewColls: 2, + expectMetadataColls: 0, + expectDoNotMergeColls: 2, + }, + { + name: "no containers pass scope", + mock: mockBackupHandler{ + channels: testdata.StubChannels("one"), + doNotInclude: true, + }, + scope: selectors.NewGroupsBackup(nil).Channels(selectors.None())[0], + expectErr: require.NoError, + expectColls: 0, + expectNewColls: 0, + expectMetadataColls: 0, + expectDoNotMergeColls: 0, + }, + { + name: "no channels", + mock: mockBackupHandler{}, + scope: allScope, + expectErr: require.NoError, + expectColls: 0, + expectNewColls: 0, + expectMetadataColls: 0, + expectDoNotMergeColls: 0, + }, + { + name: "no channel messages", + mock: mockBackupHandler{ + channels: testdata.StubChannels("one"), + }, + scope: allScope, + expectErr: require.NoError, + expectColls: 1, + expectNewColls: 1, + expectMetadataColls: 0, + expectDoNotMergeColls: 1, + }, + { + name: "err: deleted in flight", + mock: mockBackupHandler{ + channels: testdata.StubChannels("one"), + messagesErr: graph.ErrDeletedInFlight, + }, + scope: allScope, + expectErr: require.Error, + expectColls: 0, + expectNewColls: 0, + expectMetadataColls: 0, + expectDoNotMergeColls: 0, + }, + { + name: "err: other error", + mock: mockBackupHandler{ + channels: testdata.StubChannels("one"), + messagesErr: assert.AnError, + }, + scope: allScope, + expectErr: require.Error, + expectColls: 0, + expectNewColls: 0, + expectMetadataColls: 0, + expectDoNotMergeColls: 0, + }, + } + for _, test := range table { + // for _, canMakeDeltaQueries := range []bool{true, false} { + name := test.name + + // if canMakeDeltaQueries { + // name += "-delta" + // } else { + // name += "-non-delta" + // } + + suite.Run(name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + ctrlOpts := control.Options{FailureHandling: test.failFast} + // ctrlOpts.ToggleFeatures.DisableDelta = !canMakeDeltaQueries + + collections, err := populateCollections( + ctx, + qp, + test.mock, + statusUpdater, + test.mock.channels, + test.scope, + ctrlOpts, + fault.New(true)) + test.expectErr(t, err, clues.ToCore(err)) + assert.Len(t, collections, test.expectColls, "number of collections") + + // collection assertions + + deleteds, news, metadatas, doNotMerges := 0, 0, 0, 0 + for _, c := range collections { + if c.FullPath().Service() == path.GroupsMetadataService { + metadatas++ + continue + } + + if c.State() == data.DeletedState { + deleteds++ + } + + if c.State() == data.NewState { + news++ + } + + if c.DoNotMergeItems() { + doNotMerges++ + } + } + + assert.Zero(t, deleteds, "deleted collections") + assert.Equal(t, test.expectNewColls, news, "new collections") + assert.Equal(t, test.expectMetadataColls, metadatas, "metadata collections") + assert.Equal(t, test.expectDoNotMergeColls, doNotMerges, "doNotMerge collections") + }) + } +} + +// } + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- + +type BackupIntgSuite struct { + tester.Suite + resource string + tenantID string + ac api.Client +} + +func TestBackupIntgSuite(t *testing.T) { + suite.Run(t, &BackupIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *BackupIntgSuite) SetupSuite() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + graph.InitializeConcurrencyLimiter(ctx, true, 4) + + suite.resource = tconfig.M365TeamID(t) + + acct := tconfig.NewM365Account(t) + creds, err := acct.M365Config() + require.NoError(t, err, clues.ToCore(err)) + + suite.ac, err = api.NewClient(creds, control.DefaultOptions()) + require.NoError(t, err, clues.ToCore(err)) + + suite.tenantID = creds.AzureTenantID +} + +func (suite *BackupIntgSuite) TestCreateCollections() { + var ( + protectedResource = tconfig.M365GroupID(suite.T()) + resources = []string{protectedResource} + handler = NewChannelBackupHandler(protectedResource, suite.ac.Channels()) + ) + + tests := []struct { + name string + scope selectors.GroupsScope + channelNames map[string]struct{} + canMakeDeltaQueries bool + }{ + { + name: "channel messages non-delta", + scope: selTD.GroupsBackupChannelScope(selectors.NewGroupsBackup(resources))[0], + channelNames: map[string]struct{}{ + selTD.TestChannelName: {}, + }, + canMakeDeltaQueries: false, + }, + } + + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + ctrlOpts := control.DefaultOptions() + ctrlOpts.ToggleFeatures.DisableDelta = !test.canMakeDeltaQueries + + sel := selectors.NewGroupsBackup([]string{protectedResource}) + sel.Include(selTD.GroupsBackupChannelScope(sel)) + + bpc := inject.BackupProducerConfig{ + LastBackupVersion: version.NoBackup, + Options: ctrlOpts, + ProtectedResource: inMock.NewProvider(protectedResource, protectedResource), + Selector: sel.Selector, + } + + collections, err := CreateCollections( + ctx, + bpc, + handler, + suite.tenantID, + test.scope, + func(status *support.ControllerOperationStatus) {}, + fault.New(true)) + require.NoError(t, err, clues.ToCore(err)) + require.NotEmpty(t, collections, "must have at least one collection") + + for _, c := range collections { + if c.FullPath().Service() == path.GroupsMetadataService { + continue + } + + require.NotEmpty(t, c.FullPath().Folder(false)) + + fmt.Printf("\n-----\nfolder %+v\n-----\n", c.FullPath().Folder(false)) + + // TODO(ashmrtn): Remove when LocationPath is made part of BackupCollection + // interface. + if !assert.Implements(t, (*data.LocationPather)(nil), c) { + continue + } + + loc := c.(data.LocationPather).LocationPath().String() + + fmt.Printf("\n-----\nloc %+v\n-----\n", c.(data.LocationPather).LocationPath().String()) + + require.NotEmpty(t, loc) + + delete(test.channelNames, loc) + } + + assert.Empty(t, test.channelNames) + }) + } +} diff --git a/src/internal/m365/collection/groups/channel_handler.go b/src/internal/m365/collection/groups/channel_handler.go new file mode 100644 index 000000000..43c82d15b --- /dev/null +++ b/src/internal/m365/collection/groups/channel_handler.go @@ -0,0 +1,65 @@ +package groups + +import ( + "context" + + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/selectors" + "github.com/alcionai/corso/src/pkg/services/m365/api" +) + +var _ backupHandler = &channelsBackupHandler{} + +type channelsBackupHandler struct { + ac api.Channels + protectedResource string +} + +func NewChannelBackupHandler( + protectedResource string, + ac api.Channels, +) channelsBackupHandler { + return channelsBackupHandler{ + ac: ac, + protectedResource: protectedResource, + } +} + +func (bh channelsBackupHandler) getChannels( + ctx context.Context, +) ([]models.Channelable, error) { + return bh.ac.GetChannels(ctx, bh.protectedResource) +} + +func (bh channelsBackupHandler) getChannelMessagesDelta( + ctx context.Context, + channelID, prevDelta string, +) ([]models.ChatMessageable, api.DeltaUpdate, error) { + return bh.ac.GetChannelMessagesDelta(ctx, bh.protectedResource, channelID, prevDelta) +} + +func (bh channelsBackupHandler) includeContainer( + ctx context.Context, + qp graph.QueryParams, + ch models.Channelable, + scope selectors.GroupsScope, +) bool { + return scope.Matches(selectors.GroupsChannel, ptr.Val(ch.GetDisplayName())) +} + +func (bh channelsBackupHandler) canonicalPath( + folders *path.Builder, + tenantID string, +) (path.Path, error) { + return folders. + ToDataLayerPath( + tenantID, + bh.protectedResource, + path.GroupsService, + path.ChannelMessagesCategory, + false) +} diff --git a/src/internal/m365/collection/groups/handlers.go b/src/internal/m365/collection/groups/handlers.go index 120b167d9..6f9b19c3d 100644 --- a/src/internal/m365/collection/groups/handlers.go +++ b/src/internal/m365/collection/groups/handlers.go @@ -3,38 +3,39 @@ package groups import ( "context" - "github.com/microsoft/kiota-abstractions-go/serialization" "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/alcionai/corso/src/internal/m365/graph" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/selectors" "github.com/alcionai/corso/src/pkg/services/m365/api" ) -type BackupHandler interface { - GetChannelByID( +type backupHandler interface { + // gets all channels for the group + getChannels( ctx context.Context, - teamID, channelID string, - ) (models.Channelable, error) - NewChannelsPager( - teamID string, - ) api.Pager[models.Channelable] + ) ([]models.Channelable, error) - GetMessageByID( + // gets all messages by delta in the channel. + getChannelMessagesDelta( ctx context.Context, - teamID, channelID, itemID string, - ) (models.ChatMessageable, error) - NewMessagePager( - teamID, channelID string, - ) api.DeltaPager[models.ChatMessageable] + channelID, prevDelta string, + ) ([]models.ChatMessageable, api.DeltaUpdate, error) - GetMessageReplies( + // includeContainer evaluates whether the channel is included + // in the provided scope. + includeContainer( ctx context.Context, - teamID, channelID, messageID string, - ) (serialization.Parsable, error) -} - -type BackupMessagesHandler interface { - GetMessage(ctx context.Context, teamID, channelID, itemID string) (models.ChatMessageable, error) - NewMessagePager(teamID, channelID string) api.DeltaPager[models.ChatMessageable] - GetChannel(ctx context.Context, teamID, channelID string) (models.Channelable, error) - GetReply(ctx context.Context, teamID, channelID, messageID string) (serialization.Parsable, error) + qp graph.QueryParams, + ch models.Channelable, + scope selectors.GroupsScope, + ) bool + + // canonicalPath constructs the service and category specific path for + // the given builder. + canonicalPath( + folders *path.Builder, + tenantID string, + ) (path.Path, error) } diff --git a/src/internal/m365/collection/groups/testdata/channels.go b/src/internal/m365/collection/groups/testdata/channels.go new file mode 100644 index 000000000..af7fcb239 --- /dev/null +++ b/src/internal/m365/collection/groups/testdata/channels.go @@ -0,0 +1,40 @@ +package testdata + +import ( + "github.com/google/uuid" + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/common/ptr" +) + +func StubChannels(names ...string) []models.Channelable { + sl := make([]models.Channelable, 0, len(names)) + + for _, name := range names { + ch := models.NewChannel() + ch.SetDisplayName(ptr.To(name)) + ch.SetId(ptr.To(uuid.NewString())) + + sl = append(sl, ch) + } + + return sl +} + +func StubChatMessages(names ...string) []models.ChatMessageable { + sl := make([]models.ChatMessageable, 0, len(names)) + + for _, name := range names { + cm := models.NewChatMessage() + cm.SetId(ptr.To(uuid.NewString())) + + body := models.NewItemBody() + body.SetContent(ptr.To(name)) + + cm.SetBody(body) + + sl = append(sl, cm) + } + + return sl +} diff --git a/src/internal/m365/service/groups/backup.go b/src/internal/m365/service/groups/backup.go index b9431bdbe..fa6361768 100644 --- a/src/internal/m365/service/groups/backup.go +++ b/src/internal/m365/service/groups/backup.go @@ -10,6 +10,7 @@ import ( "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/m365/collection/drive" + "github.com/alcionai/corso/src/internal/m365/collection/groups" "github.com/alcionai/corso/src/internal/m365/collection/site" "github.com/alcionai/corso/src/internal/m365/graph" "github.com/alcionai/corso/src/internal/m365/support" @@ -94,6 +95,20 @@ func ProduceBackupCollections( el.AddRecoverable(ctx, err) continue } + + case path.ChannelMessagesCategory: + dbcs, err = groups.CreateCollections( + ctx, + bpc, + groups.NewChannelBackupHandler(bpc.ProtectedResource.ID(), ac.Channels()), + creds.AzureTenantID, + scope, + su, + errs) + if err != nil { + el.AddRecoverable(ctx, err) + continue + } } collections = append(collections, dbcs...) diff --git a/src/pkg/selectors/groups.go b/src/pkg/selectors/groups.go index a6e186da5..1bbd75f08 100644 --- a/src/pkg/selectors/groups.go +++ b/src/pkg/selectors/groups.go @@ -222,21 +222,24 @@ func (s *groups) AllData() []GroupsScope { return scopes } -// Channel produces one or more SharePoint channel scopes, where the channel +// Channels produces one or more SharePoint channel scopes, where the channel // matches upon a given channel by ID or Name. In order to ensure channel selection // this should always be embedded within the Filter() set; include(channel()) will // select all items in the channel without further filtering. // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] -func (s *groups) Channel(channel string) []GroupsScope { - return []GroupsScope{ - makeInfoScope[GroupsScope]( - GroupsChannel, - GroupsInfoChannel, - []string{channel}, - filters.Equal), - } +func (s *groups) Channels(channels []string, opts ...option) []GroupsScope { + var ( + scopes = []GroupsScope{} + os = append([]option{pathComparator()}, opts...) + ) + + scopes = append( + scopes, + makeScope[GroupsScope](GroupsChannel, channels, os...)) + + return scopes } // ChannelMessages produces one or more Groups channel message scopes. diff --git a/src/pkg/selectors/testdata/groups.go b/src/pkg/selectors/testdata/groups.go index bfe97e05e..9613d4dbe 100644 --- a/src/pkg/selectors/testdata/groups.go +++ b/src/pkg/selectors/testdata/groups.go @@ -4,7 +4,7 @@ import ( "github.com/alcionai/corso/src/pkg/selectors" ) -const TestChannelName = "test" +const TestChannelName = "Test" // GroupsBackupFolderScope is the standard folder scope that should be used // in integration backups with groups when interacting with libraries. @@ -15,5 +15,5 @@ func GroupsBackupLibraryFolderScope(sel *selectors.GroupsBackup) []selectors.Gro // GroupsBackupChannelScope is the standard folder scope that should be used // in integration backups with groups when interacting with channels. func GroupsBackupChannelScope(sel *selectors.GroupsBackup) []selectors.GroupsScope { - return sel.Channel(TestChannelName) + return sel.Channels([]string{TestChannelName}) }