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

#### Type of change

- [x] 🌻 Feature

#### Issue(s)

* #3989

#### Test Plan

- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Keepers 2023-09-05 13:54:02 -06:00 committed by GitHub
parent ccdb672026
commit 5aee3cc2ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 567 additions and 145 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

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