Compare commits
23 Commits
main
...
chats-afte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29c7235d92 | ||
|
|
e1ed5275dc | ||
|
|
befec6d341 | ||
|
|
a7679554ab | ||
|
|
b9f71280bf | ||
|
|
7ab1276d61 | ||
|
|
3912a64963 | ||
|
|
e298881a6c | ||
|
|
14817de592 | ||
|
|
4cba77343a | ||
|
|
94b02ed6f3 | ||
|
|
7e3d5d2074 | ||
|
|
bd50d8eeaa | ||
|
|
7d9d0e04cc | ||
|
|
9831fb8f91 | ||
|
|
db14816ac8 | ||
|
|
8344fe60be | ||
|
|
a17ed85220 | ||
|
|
c999a3072f | ||
|
|
0a6355eeb1 | ||
|
|
07997b0987 | ||
|
|
eefce75f1d | ||
|
|
f61045f5ea |
@ -46,6 +46,7 @@ var serviceCommands = []func(cmd *cobra.Command) *cobra.Command{
|
||||
addOneDriveCommands,
|
||||
addSharePointCommands,
|
||||
addGroupsCommands,
|
||||
addTeamsChatsCommands,
|
||||
}
|
||||
|
||||
// AddCommands attaches all `corso backup * *` commands to the parent.
|
||||
|
||||
@ -310,7 +310,7 @@ func groupsBackupCreateSelectors(
|
||||
group, cats []string,
|
||||
) *selectors.GroupsBackup {
|
||||
if filters.PathContains(group).Compare(flags.Wildcard) {
|
||||
return includeAllGroupWithCategories(ins, cats)
|
||||
return includeAllGroupsWithCategories(ins, cats)
|
||||
}
|
||||
|
||||
sel := selectors.NewGroupsBackup(slices.Clone(group))
|
||||
@ -318,6 +318,6 @@ func groupsBackupCreateSelectors(
|
||||
return utils.AddGroupsCategories(sel, cats)
|
||||
}
|
||||
|
||||
func includeAllGroupWithCategories(ins idname.Cacher, categories []string) *selectors.GroupsBackup {
|
||||
func includeAllGroupsWithCategories(ins idname.Cacher, categories []string) *selectors.GroupsBackup {
|
||||
return utils.AddGroupsCategories(selectors.NewGroupsBackup(ins.IDs()), categories)
|
||||
}
|
||||
|
||||
304
src/cli/backup/teamschats.go
Normal file
304
src/cli/backup/teamschats.go
Normal file
@ -0,0 +1,304 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/alcionai/corso/src/cli/flags"
|
||||
. "github.com/alcionai/corso/src/cli/print"
|
||||
"github.com/alcionai/corso/src/cli/utils"
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/filters"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// setup and globals
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
teamschatsServiceCommand = "chats"
|
||||
teamschatsServiceCommandCreateUseSuffix = "--user <userEmail> | '" + flags.Wildcard + "'"
|
||||
teamschatsServiceCommandDeleteUseSuffix = "--backups <backupId>"
|
||||
teamschatsServiceCommandDetailsUseSuffix = "--backup <backupId>"
|
||||
)
|
||||
|
||||
const (
|
||||
teamschatsServiceCommandCreateExamples = `# Backup all chats with bob@company.hr
|
||||
corso backup create chats --user bob@company.hr
|
||||
|
||||
# Backup all chats for all users
|
||||
corso backup create chats --user '*'`
|
||||
|
||||
teamschatsServiceCommandDeleteExamples = `# Delete chats backup with ID 1234abcd-12ab-cd34-56de-1234abcd \
|
||||
and 1234abcd-12ab-cd34-56de-1234abce
|
||||
corso backup delete chats --backups 1234abcd-12ab-cd34-56de-1234abcd,1234abcd-12ab-cd34-56de-1234abce`
|
||||
|
||||
teamschatsServiceCommandDetailsExamples = `# Explore chats in Bob's latest backup (1234abcd...)
|
||||
corso backup details chats --backup 1234abcd-12ab-cd34-56de-1234abcd`
|
||||
)
|
||||
|
||||
// called by backup.go to map subcommands to provider-specific handling.
|
||||
func addTeamsChatsCommands(cmd *cobra.Command) *cobra.Command {
|
||||
var c *cobra.Command
|
||||
|
||||
switch cmd.Use {
|
||||
case createCommand:
|
||||
c, _ = utils.AddCommand(cmd, teamschatsCreateCmd(), utils.MarkPreReleaseCommand())
|
||||
|
||||
c.Use = c.Use + " " + teamschatsServiceCommandCreateUseSuffix
|
||||
c.Example = teamschatsServiceCommandCreateExamples
|
||||
|
||||
// Flags addition ordering should follow the order we want them to appear in help and docs:
|
||||
flags.AddUserFlag(c)
|
||||
flags.AddDataFlag(c, []string{flags.DataChats}, false)
|
||||
flags.AddGenericBackupFlags(c)
|
||||
|
||||
case listCommand:
|
||||
c, _ = utils.AddCommand(cmd, teamschatsListCmd(), utils.MarkPreReleaseCommand())
|
||||
|
||||
flags.AddBackupIDFlag(c, false)
|
||||
flags.AddAllBackupListFlags(c)
|
||||
|
||||
case detailsCommand:
|
||||
c, _ = utils.AddCommand(cmd, teamschatsDetailsCmd(), utils.MarkPreReleaseCommand())
|
||||
|
||||
c.Use = c.Use + " " + teamschatsServiceCommandDetailsUseSuffix
|
||||
c.Example = teamschatsServiceCommandDetailsExamples
|
||||
|
||||
flags.AddSkipReduceFlag(c)
|
||||
|
||||
// Flags addition ordering should follow the order we want them to appear in help and docs:
|
||||
// More generic (ex: --user) and more frequently used flags take precedence.
|
||||
flags.AddBackupIDFlag(c, true)
|
||||
flags.AddTeamsChatsDetailsAndRestoreFlags(c)
|
||||
|
||||
case deleteCommand:
|
||||
c, _ = utils.AddCommand(cmd, teamschatsDeleteCmd(), utils.MarkPreReleaseCommand())
|
||||
|
||||
c.Use = c.Use + " " + teamschatsServiceCommandDeleteUseSuffix
|
||||
c.Example = teamschatsServiceCommandDeleteExamples
|
||||
|
||||
flags.AddMultipleBackupIDsFlag(c, false)
|
||||
flags.AddBackupIDFlag(c, false)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// backup create
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
// `corso backup create chats [<flag>...]`
|
||||
func teamschatsCreateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: teamschatsServiceCommand,
|
||||
Aliases: []string{teamsServiceCommand},
|
||||
Short: "Backup M365 Chats data",
|
||||
RunE: createTeamsChatsCmd,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
}
|
||||
|
||||
// processes a teamschats service backup.
|
||||
func createTeamsChatsCmd(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if utils.HasNoFlagsAndShownHelp(cmd) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if flags.RunModeFV == flags.RunModeFlagTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateTeamsChatsBackupCreateFlags(flags.UserFV, flags.CategoryDataFV); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, acct, err := utils.AccountConnectAndWriteRepoConfig(
|
||||
ctx,
|
||||
cmd,
|
||||
path.TeamsChatsService)
|
||||
if err != nil {
|
||||
return Only(ctx, err)
|
||||
}
|
||||
|
||||
defer utils.CloseRepo(ctx, r)
|
||||
|
||||
// TODO: log/print recoverable errors
|
||||
errs := fault.New(false)
|
||||
|
||||
svcCli, err := m365.NewM365Client(ctx, *acct)
|
||||
if err != nil {
|
||||
return Only(ctx, clues.Stack(err))
|
||||
}
|
||||
|
||||
ins, err := svcCli.AC.Users().GetAllIDsAndNames(ctx, errs)
|
||||
if err != nil {
|
||||
return Only(ctx, clues.Wrap(err, "Failed to retrieve M365 teamschats"))
|
||||
}
|
||||
|
||||
sel := teamschatsBackupCreateSelectors(ctx, ins, flags.UserFV, flags.CategoryDataFV)
|
||||
selectorSet := []selectors.Selector{}
|
||||
|
||||
for _, discSel := range sel.SplitByResourceOwner(ins.IDs()) {
|
||||
selectorSet = append(selectorSet, discSel.Selector)
|
||||
}
|
||||
|
||||
return genericCreateCommand(
|
||||
ctx,
|
||||
r,
|
||||
"Chats",
|
||||
selectorSet,
|
||||
ins)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// backup list
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
// `corso backup list teamschats [<flag>...]`
|
||||
func teamschatsListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: teamschatsServiceCommand,
|
||||
Short: "List the history of M365 Chats backups",
|
||||
RunE: listTeamsChatsCmd,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
}
|
||||
|
||||
// lists the history of backup operations
|
||||
func listTeamsChatsCmd(cmd *cobra.Command, args []string) error {
|
||||
return genericListCommand(cmd, flags.BackupIDFV, path.TeamsChatsService, args)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// backup details
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
// `corso backup details teamschats [<flag>...]`
|
||||
func teamschatsDetailsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: teamschatsServiceCommand,
|
||||
Short: "Shows the details of a M365 Chats backup",
|
||||
RunE: detailsTeamsChatsCmd,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
}
|
||||
|
||||
// processes a teamschats service backup.
|
||||
func detailsTeamsChatsCmd(cmd *cobra.Command, args []string) error {
|
||||
if utils.HasNoFlagsAndShownHelp(cmd) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if flags.RunModeFV == flags.RunModeFlagTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
return runDetailsTeamsChatsCmd(cmd)
|
||||
}
|
||||
|
||||
func runDetailsTeamsChatsCmd(cmd *cobra.Command) error {
|
||||
ctx := cmd.Context()
|
||||
opts := utils.MakeTeamsChatsOpts(cmd)
|
||||
|
||||
sel := utils.IncludeTeamsChatsRestoreDataSelectors(ctx, opts)
|
||||
utils.FilterTeamsChatsRestoreInfoSelectors(sel, opts)
|
||||
|
||||
ds, err := genericDetailsCommand(cmd, flags.BackupIDFV, sel.Selector)
|
||||
if err != nil {
|
||||
return Only(ctx, err)
|
||||
}
|
||||
|
||||
if len(ds.Entries) > 0 {
|
||||
ds.PrintEntries(ctx)
|
||||
} else {
|
||||
Info(ctx, selectors.ErrorNoMatchingItems)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// backup delete
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
// `corso backup delete teamschats [<flag>...]`
|
||||
func teamschatsDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: teamschatsServiceCommand,
|
||||
Short: "Delete backed-up M365 Chats data",
|
||||
RunE: deleteTeamsChatsCmd,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
}
|
||||
|
||||
// deletes an teamschats service backup.
|
||||
func deleteTeamsChatsCmd(cmd *cobra.Command, args []string) error {
|
||||
backupIDValue := []string{}
|
||||
|
||||
if len(flags.BackupIDsFV) > 0 {
|
||||
backupIDValue = flags.BackupIDsFV
|
||||
} else if len(flags.BackupIDFV) > 0 {
|
||||
backupIDValue = append(backupIDValue, flags.BackupIDFV)
|
||||
} else {
|
||||
return clues.New("either --backup or --backups flag is required")
|
||||
}
|
||||
|
||||
return genericDeleteCommand(cmd, path.TeamsChatsService, "TeamsChats", backupIDValue, args)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func validateTeamsChatsBackupCreateFlags(teamschats, cats []string) error {
|
||||
if len(teamschats) == 0 {
|
||||
return clues.New(
|
||||
"requires one or more --" +
|
||||
flags.UserFN + " ids, or the wildcard --" +
|
||||
flags.UserFN + " *")
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
" is an unrecognized data type; only %s is supported",
|
||||
flags.DataChats)
|
||||
|
||||
allowedCats := utils.TeamsChatsAllowedCategories()
|
||||
|
||||
for _, d := range cats {
|
||||
if _, ok := allowedCats[d]; !ok {
|
||||
return clues.New(d + msg)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func teamschatsBackupCreateSelectors(
|
||||
ctx context.Context,
|
||||
ins idname.Cacher,
|
||||
users, cats []string,
|
||||
) *selectors.TeamsChatsBackup {
|
||||
if filters.PathContains(users).Compare(flags.Wildcard) {
|
||||
return includeAllTeamsChatsWithCategories(ins, cats)
|
||||
}
|
||||
|
||||
sel := selectors.NewTeamsChatsBackup(slices.Clone(users))
|
||||
|
||||
return utils.AddTeamsChatsCategories(sel, cats)
|
||||
}
|
||||
|
||||
func includeAllTeamsChatsWithCategories(ins idname.Cacher, categories []string) *selectors.TeamsChatsBackup {
|
||||
return utils.AddTeamsChatsCategories(selectors.NewTeamsChatsBackup(ins.IDs()), categories)
|
||||
}
|
||||
631
src/cli/backup/teamschats_e2e_test.go
Normal file
631
src/cli/backup/teamschats_e2e_test.go
Normal file
@ -0,0 +1,631 @@
|
||||
package backup_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/cli"
|
||||
"github.com/alcionai/corso/src/cli/flags"
|
||||
"github.com/alcionai/corso/src/cli/print"
|
||||
cliTD "github.com/alcionai/corso/src/cli/testdata"
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/operations"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/internal/tester/tconfig"
|
||||
"github.com/alcionai/corso/src/pkg/config"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
selTD "github.com/alcionai/corso/src/pkg/selectors/testdata"
|
||||
storeTD "github.com/alcionai/corso/src/pkg/storage/testdata"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests that require no existing backups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NoBackupTeamsChatsE2ESuite struct {
|
||||
tester.Suite
|
||||
dpnd dependencies
|
||||
its intgTesterSetup
|
||||
}
|
||||
|
||||
func TestNoBackupTeamsChatsE2ESuite(t *testing.T) {
|
||||
suite.Run(t, &BackupTeamsChatsE2ESuite{Suite: tester.NewE2ESuite(
|
||||
t,
|
||||
[][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs})})
|
||||
}
|
||||
|
||||
func (suite *NoBackupTeamsChatsE2ESuite) SetupSuite() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
suite.its = newIntegrationTesterSetup(t)
|
||||
suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService)
|
||||
}
|
||||
|
||||
func (suite *NoBackupTeamsChatsE2ESuite) TestTeamsChatsBackupListCmd_noBackups() {
|
||||
t := suite.T()
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "list", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath)
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
cmd.SetErr(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
result := suite.dpnd.recorder.String()
|
||||
|
||||
// as an offhand check: the result should contain the m365 teamschat id
|
||||
assert.True(t, strings.HasSuffix(result, "No backups available\n"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests with no prior backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BackupTeamsChatsE2ESuite struct {
|
||||
tester.Suite
|
||||
dpnd dependencies
|
||||
its intgTesterSetup
|
||||
}
|
||||
|
||||
func TestBackupTeamsChatsE2ESuite(t *testing.T) {
|
||||
suite.Run(t, &BackupTeamsChatsE2ESuite{Suite: tester.NewE2ESuite(
|
||||
t,
|
||||
[][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs})})
|
||||
}
|
||||
|
||||
func (suite *BackupTeamsChatsE2ESuite) SetupSuite() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
suite.its = newIntegrationTesterSetup(t)
|
||||
suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService)
|
||||
}
|
||||
|
||||
func (suite *BackupTeamsChatsE2ESuite) TestTeamsChatsBackupCmd_chats() {
|
||||
runTeamsChatsBackupCategoryTest(suite, flags.DataChats)
|
||||
}
|
||||
|
||||
func runTeamsChatsBackupCategoryTest(suite *BackupTeamsChatsE2ESuite, category string) {
|
||||
recorder := strings.Builder{}
|
||||
recorder.Reset()
|
||||
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd, ctx := buildTeamsChatsBackupCmd(
|
||||
ctx,
|
||||
suite.dpnd.configFilePath,
|
||||
suite.its.user.ID,
|
||||
category,
|
||||
&recorder)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
result := recorder.String()
|
||||
t.Log("backup results", result)
|
||||
}
|
||||
|
||||
func (suite *BackupTeamsChatsE2ESuite) TestTeamsChatsBackupCmd_teamschatNotFound_chats() {
|
||||
runTeamsChatsBackupTeamsChatNotFoundTest(suite, flags.DataChats)
|
||||
}
|
||||
|
||||
func runTeamsChatsBackupTeamsChatNotFoundTest(suite *BackupTeamsChatsE2ESuite, category string) {
|
||||
recorder := strings.Builder{}
|
||||
recorder.Reset()
|
||||
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd, ctx := buildTeamsChatsBackupCmd(
|
||||
ctx,
|
||||
suite.dpnd.configFilePath,
|
||||
"foo@not-there.com",
|
||||
category,
|
||||
&recorder)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
assert.Contains(
|
||||
t,
|
||||
err.Error(),
|
||||
"not found",
|
||||
"error missing user not found")
|
||||
assert.NotContains(t, err.Error(), "runtime error", "panic happened")
|
||||
|
||||
t.Logf("backup error message: %s", err.Error())
|
||||
|
||||
result := recorder.String()
|
||||
t.Log("backup results", result)
|
||||
}
|
||||
|
||||
func (suite *BackupTeamsChatsE2ESuite) TestBackupCreateTeamsChats_badAzureClientIDFlag() {
|
||||
t := suite.T()
|
||||
ctx, flush := tester.NewContext(t)
|
||||
|
||||
defer flush()
|
||||
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "create", "chats",
|
||||
"--teamschat", suite.its.user.ID,
|
||||
"--azure-client-id", "invalid-value")
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
cmd.SetErr(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
func (suite *BackupTeamsChatsE2ESuite) TestBackupCreateTeamsChats_fromConfigFile() {
|
||||
t := suite.T()
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "create", "chats",
|
||||
"--teamschat", suite.its.user.ID,
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath)
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
cmd.SetOut(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
// AWS flags
|
||||
func (suite *BackupTeamsChatsE2ESuite) TestBackupCreateTeamsChats_badAWSFlags() {
|
||||
t := suite.T()
|
||||
ctx, flush := tester.NewContext(t)
|
||||
|
||||
defer flush()
|
||||
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "create", "chats",
|
||||
"--teamschat", suite.its.user.ID,
|
||||
"--aws-access-key", "invalid-value",
|
||||
"--aws-secret-access-key", "some-invalid-value")
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
cmd.SetOut(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
// since invalid aws creds are explicitly set, should see a failure
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests prepared with a previous backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PreparedBackupTeamsChatsE2ESuite struct {
|
||||
tester.Suite
|
||||
dpnd dependencies
|
||||
backupOps map[path.CategoryType]string
|
||||
its intgTesterSetup
|
||||
}
|
||||
|
||||
func TestPreparedBackupTeamsChatsE2ESuite(t *testing.T) {
|
||||
suite.Run(t, &PreparedBackupTeamsChatsE2ESuite{
|
||||
Suite: tester.NewE2ESuite(
|
||||
t,
|
||||
[][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs}),
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *PreparedBackupTeamsChatsE2ESuite) SetupSuite() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
suite.its = newIntegrationTesterSetup(t)
|
||||
suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService)
|
||||
suite.backupOps = make(map[path.CategoryType]string)
|
||||
|
||||
var (
|
||||
teamschats = []string{suite.its.user.ID}
|
||||
ins = idname.NewCache(map[string]string{suite.its.user.ID: suite.its.user.ID})
|
||||
cats = []path.CategoryType{
|
||||
path.ChatsCategory,
|
||||
}
|
||||
)
|
||||
|
||||
for _, set := range cats {
|
||||
var (
|
||||
sel = selectors.NewTeamsChatsBackup(teamschats)
|
||||
scopes []selectors.TeamsChatsScope
|
||||
)
|
||||
|
||||
switch set {
|
||||
case path.ChatsCategory:
|
||||
scopes = selTD.TeamsChatsBackupChatScope(sel)
|
||||
}
|
||||
|
||||
sel.Include(scopes)
|
||||
|
||||
bop, err := suite.dpnd.repo.NewBackupWithLookup(ctx, sel.Selector, ins)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
err = bop.Run(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
bIDs := string(bop.Results.BackupID)
|
||||
|
||||
// sanity check, ensure we can find the backup and its details immediately
|
||||
b, err := suite.dpnd.repo.Backup(ctx, string(bop.Results.BackupID))
|
||||
require.NoError(t, err, "retrieving recent backup by ID")
|
||||
require.Equal(t, bIDs, string(b.ID), "repo backup matches results id")
|
||||
|
||||
_, b, errs := suite.dpnd.repo.GetBackupDetails(ctx, bIDs)
|
||||
require.NoError(t, errs.Failure(), "retrieving recent backup details by ID")
|
||||
require.Empty(t, errs.Recovered(), "retrieving recent backup details by ID")
|
||||
require.Equal(t, bIDs, string(b.ID), "repo details matches results id")
|
||||
|
||||
suite.backupOps[set] = string(b.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsListCmd_chats() {
|
||||
runTeamsChatsListCmdTest(suite, path.ChatsCategory)
|
||||
}
|
||||
|
||||
func runTeamsChatsListCmdTest(suite *PreparedBackupTeamsChatsE2ESuite, category path.CategoryType) {
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "list", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath)
|
||||
cli.BuildCommandTree(cmd)
|
||||
cmd.SetOut(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// compare the output
|
||||
result := suite.dpnd.recorder.String()
|
||||
assert.Contains(t, result, suite.backupOps[category])
|
||||
}
|
||||
|
||||
func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsListCmd_singleID_chats() {
|
||||
runTeamsChatsListSingleCmdTest(suite, path.ChatsCategory)
|
||||
}
|
||||
|
||||
func runTeamsChatsListSingleCmdTest(suite *PreparedBackupTeamsChatsE2ESuite, category path.CategoryType) {
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
bID := suite.backupOps[category]
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "list", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--backup", string(bID))
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
cmd.SetOut(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// compare the output
|
||||
result := suite.dpnd.recorder.String()
|
||||
assert.Contains(t, result, bID)
|
||||
}
|
||||
|
||||
func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsListCmd_badID() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "list", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--backup", "smarfs")
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
func (suite *PreparedBackupTeamsChatsE2ESuite) TestTeamsChatsDetailsCmd_chats() {
|
||||
runTeamsChatsDetailsCmdTest(suite, path.ChatsCategory)
|
||||
}
|
||||
|
||||
func runTeamsChatsDetailsCmdTest(suite *PreparedBackupTeamsChatsE2ESuite, category path.CategoryType) {
|
||||
suite.dpnd.recorder.Reset()
|
||||
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
bID := suite.backupOps[category]
|
||||
|
||||
// fetch the details from the repo first
|
||||
deets, _, errs := suite.dpnd.repo.GetBackupDetails(ctx, string(bID))
|
||||
require.NoError(t, errs.Failure(), clues.ToCore(errs.Failure()))
|
||||
require.Empty(t, errs.Recovered())
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "details", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--"+flags.BackupFN, string(bID))
|
||||
cli.BuildCommandTree(cmd)
|
||||
cmd.SetOut(&suite.dpnd.recorder)
|
||||
|
||||
ctx = print.SetRootCmd(ctx, cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// compare the output
|
||||
result := suite.dpnd.recorder.String()
|
||||
|
||||
i := 0
|
||||
foundFolders := 0
|
||||
|
||||
for _, ent := range deets.Entries {
|
||||
// Skip folders as they don't mean anything to the end teamschat.
|
||||
if ent.Folder != nil {
|
||||
foundFolders++
|
||||
continue
|
||||
}
|
||||
|
||||
suite.Run(fmt.Sprintf("detail %d", i), func() {
|
||||
assert.Contains(suite.T(), result, ent.ShortRef)
|
||||
})
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
// We only backup the default folder for each category so there should be at
|
||||
// least that folder (we don't make details entries for prefix folders).
|
||||
assert.GreaterOrEqual(t, foundFolders, 1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests for deleting backups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type BackupDeleteTeamsChatsE2ESuite struct {
|
||||
tester.Suite
|
||||
dpnd dependencies
|
||||
backupOps [3]operations.BackupOperation
|
||||
}
|
||||
|
||||
func TestBackupDeleteTeamsChatsE2ESuite(t *testing.T) {
|
||||
suite.Run(t, &BackupDeleteTeamsChatsE2ESuite{
|
||||
Suite: tester.NewE2ESuite(
|
||||
t,
|
||||
[][]string{storeTD.AWSStorageCredEnvs, tconfig.M365AcctCredEnvs}),
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *BackupDeleteTeamsChatsE2ESuite) SetupSuite() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
suite.dpnd = prepM365Test(t, ctx, path.TeamsChatsService)
|
||||
|
||||
m365TeamsChatID := tconfig.M365TeamID(t)
|
||||
teamschats := []string{m365TeamsChatID}
|
||||
|
||||
// some tests require an existing backup
|
||||
sel := selectors.NewTeamsChatsBackup(teamschats)
|
||||
sel.Include(selTD.TeamsChatsBackupChatScope(sel))
|
||||
|
||||
for i := 0; i < cap(suite.backupOps); i++ {
|
||||
backupOp, err := suite.dpnd.repo.NewBackup(ctx, sel.Selector)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
suite.backupOps[i] = backupOp
|
||||
|
||||
err = suite.backupOps[i].Run(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "delete", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--"+flags.BackupIDsFN,
|
||||
fmt.Sprintf("%s,%s",
|
||||
string(suite.backupOps[0].Results.BackupID),
|
||||
string(suite.backupOps[1].Results.BackupID)))
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// a follow-up details call should fail, due to the backup ID being deleted
|
||||
cmd = cliTD.StubRootCmd(
|
||||
"backup", "details", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--backups", string(suite.backupOps[0].Results.BackupID))
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd_SingleID() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "delete", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--"+flags.BackupFN,
|
||||
string(suite.backupOps[2].Results.BackupID))
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
// run the command
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// a follow-up details call should fail, due to the backup ID being deleted
|
||||
cmd = cliTD.StubRootCmd(
|
||||
"backup", "details", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--backup", string(suite.backupOps[2].Results.BackupID))
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
err = cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd_UnknownID() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "delete", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath,
|
||||
"--"+flags.BackupIDsFN, uuid.NewString())
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
// unknown backupIDs should error since the modelStore can't find the backup
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
func (suite *BackupDeleteTeamsChatsE2ESuite) TestTeamsChatsBackupDeleteCmd_NoBackupID() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
ctx = config.SetViper(ctx, suite.dpnd.vpr)
|
||||
|
||||
defer flush()
|
||||
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "delete", "chats",
|
||||
"--"+flags.ConfigFileFN, suite.dpnd.configFilePath)
|
||||
cli.BuildCommandTree(cmd)
|
||||
|
||||
// empty backupIDs should error since no data provided
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
require.Error(t, err, clues.ToCore(err))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func buildTeamsChatsBackupCmd(
|
||||
ctx context.Context,
|
||||
configFile, resource, category string,
|
||||
recorder *strings.Builder,
|
||||
) (*cobra.Command, context.Context) {
|
||||
cmd := cliTD.StubRootCmd(
|
||||
"backup", "create", "chats",
|
||||
"--"+flags.ConfigFileFN, configFile,
|
||||
"--"+flags.UserFN, resource,
|
||||
"--"+flags.CategoryDataFN, category)
|
||||
cli.BuildCommandTree(cmd)
|
||||
cmd.SetOut(recorder)
|
||||
|
||||
return cmd, print.SetRootCmd(ctx, cmd)
|
||||
}
|
||||
248
src/cli/backup/teamschats_test.go
Normal file
248
src/cli/backup/teamschats_test.go
Normal file
@ -0,0 +1,248 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/cli/flags"
|
||||
flagsTD "github.com/alcionai/corso/src/cli/flags/testdata"
|
||||
cliTD "github.com/alcionai/corso/src/cli/testdata"
|
||||
"github.com/alcionai/corso/src/cli/utils"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
)
|
||||
|
||||
type TeamsChatsUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestTeamsChatsUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &TeamsChatsUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestAddTeamsChatsCommands() {
|
||||
expectUse := teamschatsServiceCommand
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
use string
|
||||
expectUse string
|
||||
expectShort string
|
||||
expectRunE func(*cobra.Command, []string) error
|
||||
}{
|
||||
{
|
||||
name: "create teamschats",
|
||||
use: createCommand,
|
||||
expectUse: expectUse + " " + teamschatsServiceCommandCreateUseSuffix,
|
||||
expectShort: teamschatsCreateCmd().Short,
|
||||
expectRunE: createTeamsChatsCmd,
|
||||
},
|
||||
{
|
||||
name: "list teamschats",
|
||||
use: listCommand,
|
||||
expectUse: expectUse,
|
||||
expectShort: teamschatsListCmd().Short,
|
||||
expectRunE: listTeamsChatsCmd,
|
||||
},
|
||||
{
|
||||
name: "details teamschats",
|
||||
use: detailsCommand,
|
||||
expectUse: expectUse + " " + teamschatsServiceCommandDetailsUseSuffix,
|
||||
expectShort: teamschatsDetailsCmd().Short,
|
||||
expectRunE: detailsTeamsChatsCmd,
|
||||
},
|
||||
{
|
||||
name: "delete teamschats",
|
||||
use: deleteCommand,
|
||||
expectUse: expectUse + " " + teamschatsServiceCommandDeleteUseSuffix,
|
||||
expectShort: teamschatsDeleteCmd().Short,
|
||||
expectRunE: deleteTeamsChatsCmd,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
cmd := &cobra.Command{Use: test.use}
|
||||
|
||||
c := addTeamsChatsCommands(cmd)
|
||||
require.NotNil(t, c)
|
||||
|
||||
cmds := cmd.Commands()
|
||||
require.Len(t, cmds, 1)
|
||||
|
||||
child := cmds[0]
|
||||
assert.Equal(t, test.expectUse, child.Use)
|
||||
assert.Equal(t, test.expectShort, child.Short)
|
||||
tester.AreSameFunc(t, test.expectRunE, child.RunE)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestValidateTeamsChatsBackupCreateFlags() {
|
||||
table := []struct {
|
||||
name string
|
||||
cats []string
|
||||
expect assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "none",
|
||||
cats: []string{},
|
||||
expect: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "chats",
|
||||
cats: []string{flags.DataChats},
|
||||
expect: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "all allowed",
|
||||
cats: []string{
|
||||
flags.DataChats,
|
||||
},
|
||||
expect: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "bad inputs",
|
||||
cats: []string{"foo"},
|
||||
expect: assert.Error,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
err := validateTeamsChatsBackupCreateFlags([]string{"*"}, test.cats)
|
||||
test.expect(suite.T(), err, clues.ToCore(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestBackupCreateFlags() {
|
||||
t := suite.T()
|
||||
|
||||
cmd := cliTD.SetUpCmdHasFlags(
|
||||
t,
|
||||
&cobra.Command{Use: createCommand},
|
||||
addTeamsChatsCommands,
|
||||
[]cliTD.UseCobraCommandFn{
|
||||
flags.AddAllProviderFlags,
|
||||
flags.AddAllStorageFlags,
|
||||
},
|
||||
flagsTD.WithFlags(
|
||||
teamschatsServiceCommand,
|
||||
[]string{
|
||||
"--" + flags.RunModeFN, flags.RunModeFlagTest,
|
||||
"--" + flags.UserFN, flagsTD.FlgInputs(flagsTD.UsersInput),
|
||||
"--" + flags.CategoryDataFN, flagsTD.FlgInputs(flagsTD.TeamsChatsCategoryDataInput),
|
||||
},
|
||||
flagsTD.PreparedGenericBackupFlags(),
|
||||
flagsTD.PreparedProviderFlags(),
|
||||
flagsTD.PreparedStorageFlags()))
|
||||
|
||||
opts := utils.MakeTeamsChatsOpts(cmd)
|
||||
co := utils.Control()
|
||||
backupOpts := utils.ParseBackupOptions()
|
||||
|
||||
// TODO(ashmrtn): Remove flag checks on control.Options to control.Backup once
|
||||
// restore flags are switched over too and we no longer parse flags beyond
|
||||
// connection info into control.Options.
|
||||
assert.Equal(t, control.FailFast, backupOpts.FailureHandling)
|
||||
assert.True(t, backupOpts.Incrementals.ForceFullEnumeration)
|
||||
assert.True(t, backupOpts.Incrementals.ForceItemDataRefresh)
|
||||
|
||||
assert.Equal(t, control.FailFast, co.FailureHandling)
|
||||
assert.True(t, co.ToggleFeatures.DisableIncrementals)
|
||||
assert.True(t, co.ToggleFeatures.ForceItemDataDownload)
|
||||
|
||||
assert.ElementsMatch(t, flagsTD.UsersInput, opts.Users)
|
||||
flagsTD.AssertGenericBackupFlags(t, cmd)
|
||||
flagsTD.AssertProviderFlags(t, cmd)
|
||||
flagsTD.AssertStorageFlags(t, cmd)
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestBackupListFlags() {
|
||||
t := suite.T()
|
||||
|
||||
cmd := cliTD.SetUpCmdHasFlags(
|
||||
t,
|
||||
&cobra.Command{Use: listCommand},
|
||||
addTeamsChatsCommands,
|
||||
[]cliTD.UseCobraCommandFn{
|
||||
flags.AddAllProviderFlags,
|
||||
flags.AddAllStorageFlags,
|
||||
},
|
||||
flagsTD.WithFlags(
|
||||
teamschatsServiceCommand,
|
||||
[]string{
|
||||
"--" + flags.RunModeFN, flags.RunModeFlagTest,
|
||||
"--" + flags.BackupFN, flagsTD.BackupInput,
|
||||
},
|
||||
flagsTD.PreparedBackupListFlags(),
|
||||
flagsTD.PreparedProviderFlags(),
|
||||
flagsTD.PreparedStorageFlags()))
|
||||
|
||||
assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV)
|
||||
flagsTD.AssertBackupListFlags(t, cmd)
|
||||
flagsTD.AssertProviderFlags(t, cmd)
|
||||
flagsTD.AssertStorageFlags(t, cmd)
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestBackupDetailsFlags() {
|
||||
t := suite.T()
|
||||
|
||||
cmd := cliTD.SetUpCmdHasFlags(
|
||||
t,
|
||||
&cobra.Command{Use: detailsCommand},
|
||||
addTeamsChatsCommands,
|
||||
[]cliTD.UseCobraCommandFn{
|
||||
flags.AddAllProviderFlags,
|
||||
flags.AddAllStorageFlags,
|
||||
},
|
||||
flagsTD.WithFlags(
|
||||
teamschatsServiceCommand,
|
||||
[]string{
|
||||
"--" + flags.RunModeFN, flags.RunModeFlagTest,
|
||||
"--" + flags.BackupFN, flagsTD.BackupInput,
|
||||
"--" + flags.SkipReduceFN,
|
||||
},
|
||||
flagsTD.PreparedTeamsChatsFlags(),
|
||||
flagsTD.PreparedProviderFlags(),
|
||||
flagsTD.PreparedStorageFlags()))
|
||||
|
||||
co := utils.Control()
|
||||
|
||||
assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV)
|
||||
assert.True(t, co.SkipReduce)
|
||||
flagsTD.AssertProviderFlags(t, cmd)
|
||||
flagsTD.AssertStorageFlags(t, cmd)
|
||||
flagsTD.AssertTeamsChatsFlags(t, cmd)
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestBackupDeleteFlags() {
|
||||
t := suite.T()
|
||||
|
||||
cmd := cliTD.SetUpCmdHasFlags(
|
||||
t,
|
||||
&cobra.Command{Use: deleteCommand},
|
||||
addTeamsChatsCommands,
|
||||
[]cliTD.UseCobraCommandFn{
|
||||
flags.AddAllProviderFlags,
|
||||
flags.AddAllStorageFlags,
|
||||
},
|
||||
flagsTD.WithFlags(
|
||||
teamschatsServiceCommand,
|
||||
[]string{
|
||||
"--" + flags.RunModeFN, flags.RunModeFlagTest,
|
||||
"--" + flags.BackupFN, flagsTD.BackupInput,
|
||||
},
|
||||
flagsTD.PreparedProviderFlags(),
|
||||
flagsTD.PreparedStorageFlags()))
|
||||
|
||||
assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV)
|
||||
flagsTD.AssertProviderFlags(t, cmd)
|
||||
flagsTD.AssertStorageFlags(t, cmd)
|
||||
}
|
||||
@ -25,6 +25,7 @@ var exportCommands = []func(cmd *cobra.Command) *cobra.Command{
|
||||
addSharePointCommands,
|
||||
addGroupsCommands,
|
||||
addExchangeCommands,
|
||||
addTeamsChatsCommands,
|
||||
}
|
||||
|
||||
var defaultAcceptedFormatTypes = []string{string(control.DefaultFormat)}
|
||||
|
||||
101
src/cli/export/teamschats.go
Normal file
101
src/cli/export/teamschats.go
Normal file
@ -0,0 +1,101 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/alcionai/corso/src/cli/flags"
|
||||
"github.com/alcionai/corso/src/cli/utils"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
)
|
||||
|
||||
// called by export.go to map subcommands to provider-specific handling.
|
||||
func addTeamsChatsCommands(cmd *cobra.Command) *cobra.Command {
|
||||
var c *cobra.Command
|
||||
|
||||
switch cmd.Use {
|
||||
case exportCommand:
|
||||
c, _ = utils.AddCommand(cmd, teamschatsExportCmd(), utils.MarkPreviewCommand())
|
||||
|
||||
c.Use = c.Use + " " + teamschatsServiceCommandUseSuffix
|
||||
|
||||
flags.AddBackupIDFlag(c, true)
|
||||
flags.AddTeamsChatsDetailsAndRestoreFlags(c)
|
||||
flags.AddExportConfigFlags(c)
|
||||
flags.AddFailFastFlag(c)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
const (
|
||||
teamschatsServiceCommand = "chats"
|
||||
teamschatsServiceCommandUseSuffix = "<destination> --backup <backupId>"
|
||||
|
||||
//nolint:lll
|
||||
teamschatsServiceCommandExportExamples = `# Export a specific chat from the last backup (1234abcd...) to /my-exports
|
||||
corso export chats my-exports --backup 1234abcd-12ab-cd34-56de-1234abcd --chat 98765abcdef
|
||||
|
||||
# Export all of Bob's chats to the current directory
|
||||
corso export chats . --backup 1234abcd-12ab-cd34-56de-1234abcd \
|
||||
--chat '*'
|
||||
|
||||
# Export all chats that were created before 2020 to /my-exports
|
||||
corso export chats my-exports --backup 1234abcd-12ab-cd34-56de-1234abcd
|
||||
--chat-created-before 2020-01-01T00:00:00`
|
||||
)
|
||||
|
||||
// `corso export chats [<flag>...] <destination>`
|
||||
func teamschatsExportCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: teamschatsServiceCommand,
|
||||
Aliases: []string{teamsServiceCommand},
|
||||
Short: "Export M365 Chats data",
|
||||
RunE: exportTeamsChatsCmd,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("missing export destination")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Example: teamschatsServiceCommandExportExamples,
|
||||
}
|
||||
}
|
||||
|
||||
// processes an teamschats service export.
|
||||
func exportTeamsChatsCmd(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if utils.HasNoFlagsAndShownHelp(cmd) {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := utils.MakeTeamsChatsOpts(cmd)
|
||||
|
||||
if flags.RunModeFV == flags.RunModeFlagTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := utils.ValidateTeamsChatsRestoreFlags(flags.BackupIDFV, opts, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sel := utils.IncludeTeamsChatsRestoreDataSelectors(ctx, opts)
|
||||
utils.FilterTeamsChatsRestoreInfoSelectors(sel, opts)
|
||||
|
||||
acceptedTeamsChatsFormatTypes := []string{
|
||||
string(control.DefaultFormat),
|
||||
string(control.JSONFormat),
|
||||
}
|
||||
|
||||
return runExport(
|
||||
ctx,
|
||||
cmd,
|
||||
args,
|
||||
opts.ExportCfg,
|
||||
sel.Selector,
|
||||
flags.BackupIDFV,
|
||||
"Chats",
|
||||
acceptedTeamsChatsFormatTypes)
|
||||
}
|
||||
78
src/cli/export/teamschats_test.go
Normal file
78
src/cli/export/teamschats_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/cli/flags"
|
||||
flagsTD "github.com/alcionai/corso/src/cli/flags/testdata"
|
||||
cliTD "github.com/alcionai/corso/src/cli/testdata"
|
||||
"github.com/alcionai/corso/src/cli/utils"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
)
|
||||
|
||||
type TeamsChatsUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestTeamsChatsUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &TeamsChatsUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsUnitSuite) TestAddTeamsChatsCommands() {
|
||||
expectUse := teamschatsServiceCommand + " " + teamschatsServiceCommandUseSuffix
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
use string
|
||||
expectUse string
|
||||
expectShort string
|
||||
expectRunE func(*cobra.Command, []string) error
|
||||
}{
|
||||
{"export teamschats", exportCommand, expectUse, teamschatsExportCmd().Short, exportTeamsChatsCmd},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
parent := &cobra.Command{Use: exportCommand}
|
||||
|
||||
cmd := cliTD.SetUpCmdHasFlags(
|
||||
t,
|
||||
parent,
|
||||
addTeamsChatsCommands,
|
||||
[]cliTD.UseCobraCommandFn{
|
||||
flags.AddAllProviderFlags,
|
||||
flags.AddAllStorageFlags,
|
||||
},
|
||||
flagsTD.WithFlags(
|
||||
teamschatsServiceCommand,
|
||||
[]string{
|
||||
flagsTD.RestoreDestination,
|
||||
"--" + flags.RunModeFN, flags.RunModeFlagTest,
|
||||
"--" + flags.BackupFN, flagsTD.BackupInput,
|
||||
"--" + flags.FormatFN, flagsTD.FormatType,
|
||||
"--" + flags.ArchiveFN,
|
||||
},
|
||||
flagsTD.PreparedProviderFlags(),
|
||||
flagsTD.PreparedStorageFlags()))
|
||||
|
||||
cliTD.CheckCmdChild(
|
||||
t,
|
||||
parent,
|
||||
3,
|
||||
test.expectUse,
|
||||
test.expectShort,
|
||||
test.expectRunE)
|
||||
|
||||
opts := utils.MakeTeamsChatsOpts(cmd)
|
||||
|
||||
assert.Equal(t, flagsTD.BackupInput, flags.BackupIDFV)
|
||||
assert.Equal(t, flagsTD.Archive, opts.ExportCfg.Archive)
|
||||
assert.Equal(t, flagsTD.FormatType, opts.ExportCfg.Format)
|
||||
flagsTD.AssertStorageFlags(t, cmd)
|
||||
})
|
||||
}
|
||||
}
|
||||
13
src/cli/flags/teamschats.go
Normal file
13
src/cli/flags/teamschats.go
Normal file
@ -0,0 +1,13 @@
|
||||
package flags
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
DataChats = "chats"
|
||||
)
|
||||
|
||||
func AddTeamsChatsDetailsAndRestoreFlags(cmd *cobra.Command) {
|
||||
// TODO: add details flags
|
||||
}
|
||||
1
src/cli/flags/testdata/flags.go
vendored
1
src/cli/flags/testdata/flags.go
vendored
@ -21,6 +21,7 @@ var (
|
||||
ExchangeCategoryDataInput = []string{"email", "events", "contacts"}
|
||||
SharepointCategoryDataInput = []string{"files", "lists", "pages"}
|
||||
GroupsCategoryDataInput = []string{"files", "lists", "pages", "messages"}
|
||||
TeamsChatsCategoryDataInput = []string{"chats"}
|
||||
|
||||
ChannelInput = []string{"channel1", "channel2"}
|
||||
MessageInput = []string{"message1", "message2"}
|
||||
|
||||
25
src/cli/flags/testdata/teamschats.go
vendored
Normal file
25
src/cli/flags/testdata/teamschats.go
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
package testdata
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func PreparedTeamsChatsFlags() []string {
|
||||
return []string{
|
||||
// FIXME: populate when adding filters
|
||||
// "--" + flags.ChatCreatedAfterFN, ChatCreatedAfterInput,
|
||||
// "--" + flags.ChatCreatedBeforeFN, ChatCreatedBeforeInput,
|
||||
// "--" + flags.ChatLastMessageAfterFN, ChatLastMessageAfterInput,
|
||||
// "--" + flags.ChatLastMessageBeforeFN, ChatLastMessageBeforeInput,
|
||||
}
|
||||
}
|
||||
|
||||
func AssertTeamsChatsFlags(t *testing.T, cmd *cobra.Command) {
|
||||
// FIXME: populate when adding filters
|
||||
// assert.Equal(t, ChatCreatedAfterInput, flags.ChatCreatedAfterFV)
|
||||
// assert.Equal(t, ChatCreatedBeforeInput, flags.ChatCreatedBeforeFV)
|
||||
// assert.Equal(t, ChatLastMessageAfterInput, flags.ChatLastMessageAfterFV)
|
||||
// assert.Equal(t, ChatLastMessageBeforeInput, flags.ChatLastMessageBeforeFV)
|
||||
}
|
||||
104
src/cli/utils/teamschats.go
Normal file
104
src/cli/utils/teamschats.go
Normal file
@ -0,0 +1,104 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/alcionai/corso/src/cli/flags"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
)
|
||||
|
||||
type TeamsChatsOpts struct {
|
||||
Users []string
|
||||
|
||||
ExportCfg ExportCfgOpts
|
||||
|
||||
Populated flags.PopulatedFlags
|
||||
}
|
||||
|
||||
func TeamsChatsAllowedCategories() map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
flags.DataChats: {},
|
||||
}
|
||||
}
|
||||
|
||||
func AddTeamsChatsCategories(sel *selectors.TeamsChatsBackup, cats []string) *selectors.TeamsChatsBackup {
|
||||
if len(cats) == 0 {
|
||||
sel.Include(sel.AllData())
|
||||
}
|
||||
|
||||
for _, d := range cats {
|
||||
switch d {
|
||||
case flags.DataChats:
|
||||
sel.Include(sel.Chats(selectors.Any()))
|
||||
}
|
||||
}
|
||||
|
||||
return sel
|
||||
}
|
||||
|
||||
func MakeTeamsChatsOpts(cmd *cobra.Command) TeamsChatsOpts {
|
||||
return TeamsChatsOpts{
|
||||
Users: flags.UserFV,
|
||||
|
||||
ExportCfg: makeExportCfgOpts(cmd),
|
||||
|
||||
// populated contains the list of flags that appear in the
|
||||
// command, according to pflags. Use this to differentiate
|
||||
// between an "empty" and a "missing" value.
|
||||
Populated: flags.GetPopulatedFlags(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateTeamsChatsRestoreFlags checks common flags for correctness and interdependencies
|
||||
func ValidateTeamsChatsRestoreFlags(backupID string, opts TeamsChatsOpts, isRestore bool) error {
|
||||
if len(backupID) == 0 {
|
||||
return clues.New("a backup ID is required")
|
||||
}
|
||||
|
||||
// restore isn't currently supported
|
||||
if isRestore {
|
||||
return clues.New("restore not supported")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTeamsChatsFilter adds the scope of the provided values to the selector's
|
||||
// filter set
|
||||
func AddTeamsChatsFilter(
|
||||
sel *selectors.TeamsChatsRestore,
|
||||
v string,
|
||||
f func(string) []selectors.TeamsChatsScope,
|
||||
) {
|
||||
if len(v) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sel.Filter(f(v))
|
||||
}
|
||||
|
||||
// IncludeTeamsChatsRestoreDataSelectors builds the common data-selector
|
||||
// inclusions for teamschats commands.
|
||||
func IncludeTeamsChatsRestoreDataSelectors(ctx context.Context, opts TeamsChatsOpts) *selectors.TeamsChatsRestore {
|
||||
users := opts.Users
|
||||
|
||||
if len(opts.Users) == 0 {
|
||||
users = selectors.Any()
|
||||
}
|
||||
|
||||
sel := selectors.NewTeamsChatsRestore(users)
|
||||
sel.Include(sel.Chats(selectors.Any()))
|
||||
|
||||
return sel
|
||||
}
|
||||
|
||||
// FilterTeamsChatsRestoreInfoSelectors builds the common info-selector filters.
|
||||
func FilterTeamsChatsRestoreInfoSelectors(
|
||||
sel *selectors.TeamsChatsRestore,
|
||||
opts TeamsChatsOpts,
|
||||
) {
|
||||
// TODO: populate when adding filters
|
||||
}
|
||||
@ -13,7 +13,10 @@ import (
|
||||
"github.com/alcionai/corso/src/internal/m365/service/groups"
|
||||
"github.com/alcionai/corso/src/internal/m365/service/onedrive"
|
||||
"github.com/alcionai/corso/src/internal/m365/service/sharepoint"
|
||||
"github.com/alcionai/corso/src/internal/m365/service/teamschats"
|
||||
"github.com/alcionai/corso/src/internal/m365/support"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/pkg/account"
|
||||
bupMD "github.com/alcionai/corso/src/pkg/backup/metadata"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
@ -22,9 +25,31 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/filters"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type backupHandler interface {
|
||||
produceBackupCollectionser
|
||||
}
|
||||
|
||||
type produceBackupCollectionser interface {
|
||||
ProduceBackupCollections(
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
ac api.Client,
|
||||
creds account.M365Config,
|
||||
su support.StatusUpdater,
|
||||
counter *count.Bus,
|
||||
errs *fault.Bus,
|
||||
) (
|
||||
collections []data.BackupCollection,
|
||||
excludeItems *prefixmatcher.StringSetMatcher,
|
||||
canUsePreviousBackup bool,
|
||||
err error,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Collections
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -63,67 +88,40 @@ func (ctrl *Controller) ProduceBackupCollections(
|
||||
canUsePreviousBackup bool
|
||||
)
|
||||
|
||||
var handler backupHandler
|
||||
|
||||
switch service {
|
||||
case path.ExchangeService:
|
||||
colls, excludeItems, canUsePreviousBackup, err = exchange.ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
ctrl.AC,
|
||||
ctrl.credentials,
|
||||
ctrl.UpdateStatus,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
handler = exchange.NewBackup()
|
||||
|
||||
case path.OneDriveService:
|
||||
colls, excludeItems, canUsePreviousBackup, err = onedrive.ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
ctrl.AC,
|
||||
ctrl.credentials,
|
||||
ctrl.UpdateStatus,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
handler = onedrive.NewBackup()
|
||||
|
||||
case path.SharePointService:
|
||||
colls, excludeItems, canUsePreviousBackup, err = sharepoint.ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
ctrl.AC,
|
||||
ctrl.credentials,
|
||||
ctrl.UpdateStatus,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
handler = sharepoint.NewBackup()
|
||||
|
||||
case path.GroupsService:
|
||||
colls, excludeItems, err = groups.ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
ctrl.AC,
|
||||
ctrl.credentials,
|
||||
ctrl.UpdateStatus,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
handler = groups.NewBackup()
|
||||
|
||||
// canUsePreviousBacukp can be always returned true for groups as we
|
||||
// return a tombstone collection in case the metadata read fails
|
||||
canUsePreviousBackup = true
|
||||
case path.TeamsChatsService:
|
||||
handler = teamschats.NewBackup()
|
||||
|
||||
default:
|
||||
return nil, nil, false, clues.Wrap(clues.NewWC(ctx, service.String()), "service not supported")
|
||||
}
|
||||
|
||||
colls, excludeItems, canUsePreviousBackup, err = handler.ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
ctrl.AC,
|
||||
ctrl.credentials,
|
||||
ctrl.UpdateStatus,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
for _, c := range colls {
|
||||
// kopia doesn't stream Items() from deleted collections,
|
||||
// and so they never end up calling the UpdateStatus closer.
|
||||
@ -153,25 +151,27 @@ func (ctrl *Controller) IsServiceEnabled(
|
||||
return sharepoint.IsServiceEnabled(ctx, ctrl.AC.Sites(), resourceOwner)
|
||||
case path.GroupsService:
|
||||
return groups.IsServiceEnabled(ctx, ctrl.AC.Groups(), resourceOwner)
|
||||
case path.TeamsChatsService:
|
||||
return teamschats.IsServiceEnabled(ctx, ctrl.AC.Users(), resourceOwner)
|
||||
}
|
||||
|
||||
return false, clues.Wrap(clues.NewWC(ctx, service.String()), "service not supported")
|
||||
}
|
||||
|
||||
func verifyBackupInputs(sels selectors.Selector, cachedIDs []string) error {
|
||||
func verifyBackupInputs(sel selectors.Selector, cachedIDs []string) error {
|
||||
var ids []string
|
||||
|
||||
switch sels.Service {
|
||||
switch sel.Service {
|
||||
case selectors.ServiceExchange, selectors.ServiceOneDrive:
|
||||
// Exchange and OneDrive user existence now checked in checkServiceEnabled.
|
||||
return nil
|
||||
|
||||
case selectors.ServiceSharePoint, selectors.ServiceGroups:
|
||||
case selectors.ServiceSharePoint, selectors.ServiceGroups, selectors.ServiceTeamsChats:
|
||||
ids = cachedIDs
|
||||
}
|
||||
|
||||
if !filters.Contains(ids).Compare(sels.ID()) {
|
||||
return clues.Stack(core.ErrNotFound).With("selector_protected_resource", sels.DiscreteOwner)
|
||||
if !filters.Contains(ids).Compare(sel.ID()) {
|
||||
return clues.Stack(core.ErrNotFound).With("selector_protected_resource", sel.ID())
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -139,7 +139,7 @@ func (suite *DataCollectionIntgSuite) TestExchangeDataCollection() {
|
||||
Selector: sel,
|
||||
}
|
||||
|
||||
collections, excludes, canUsePreviousBackup, err := exchange.ProduceBackupCollections(
|
||||
collections, excludes, canUsePreviousBackup, err := exchange.NewBackup().ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
suite.ac,
|
||||
@ -309,7 +309,7 @@ func (suite *DataCollectionIntgSuite) TestSharePointDataCollection() {
|
||||
Selector: sel,
|
||||
}
|
||||
|
||||
collections, excludes, canUsePreviousBackup, err := sharepoint.ProduceBackupCollections(
|
||||
collections, excludes, canUsePreviousBackup, err := sharepoint.NewBackup().ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
suite.ac,
|
||||
|
||||
@ -311,7 +311,7 @@ func (c *Collections) Get(
|
||||
}
|
||||
|
||||
// Enumerate drives for the specified resourceOwner
|
||||
pager := c.handler.NewDrivePager(c.protectedResource.ID(), nil)
|
||||
pager := c.handler.NewDrivePager(nil)
|
||||
|
||||
drives, err := api.GetAllDrives(ctx, pager)
|
||||
if err != nil {
|
||||
@ -439,7 +439,7 @@ func (c *Collections) Get(
|
||||
continue
|
||||
}
|
||||
|
||||
p, err := c.handler.CanonicalPath(odConsts.DriveFolderPrefixBuilder(driveID), c.tenantID)
|
||||
p, err := c.handler.CanonicalPath(odConsts.DriveFolderPrefixBuilder(driveID))
|
||||
if err != nil {
|
||||
return nil, false, clues.WrapWC(ictx, err, "making exclude prefix")
|
||||
}
|
||||
@ -504,7 +504,7 @@ func (c *Collections) Get(
|
||||
|
||||
// generate tombstones for drives that were removed.
|
||||
for driveID := range driveTombstones {
|
||||
prevDrivePath, err := c.handler.PathPrefix(c.tenantID, driveID)
|
||||
prevDrivePath, err := c.handler.PathPrefix(driveID)
|
||||
if err != nil {
|
||||
return nil, false, clues.WrapWC(ctx, err, "making drive tombstone for previous path").Label(count.BadPathPrefix)
|
||||
}
|
||||
@ -532,7 +532,7 @@ func (c *Collections) Get(
|
||||
alertIfPrevPathsHaveCollisions(ctx, driveIDToPrevPaths, c.counter, errs)
|
||||
|
||||
// add metadata collections
|
||||
pathPrefix, err := c.handler.MetadataPathPrefix(c.tenantID)
|
||||
pathPrefix, err := c.handler.MetadataPathPrefix()
|
||||
if err != nil {
|
||||
// It's safe to return here because the logic for starting an
|
||||
// incremental backup should eventually find that the metadata files are
|
||||
@ -729,7 +729,7 @@ func (c *Collections) getCollectionPath(
|
||||
pb = path.Builder{}.Append(path.Split(ptr.Val(item.GetParentReference().GetPath()))...)
|
||||
}
|
||||
|
||||
collectionPath, err := c.handler.CanonicalPath(pb, c.tenantID)
|
||||
collectionPath, err := c.handler.CanonicalPath(pb)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "making item path")
|
||||
}
|
||||
|
||||
@ -2639,7 +2639,7 @@ func (suite *CollectionsUnitSuite) TestGet() {
|
||||
|
||||
prevDelta := "prev-delta"
|
||||
|
||||
pathPrefix, err := mbh.MetadataPathPrefix(tenant)
|
||||
pathPrefix, err := mbh.MetadataPathPrefix()
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
mc, err := graph.MakeMetadataCollection(
|
||||
|
||||
@ -68,7 +68,7 @@ func (c *Collections) getTree(
|
||||
driveTombstones[driveID] = struct{}{}
|
||||
}
|
||||
|
||||
pager := c.handler.NewDrivePager(c.protectedResource.ID(), nil)
|
||||
pager := c.handler.NewDrivePager(nil)
|
||||
|
||||
drives, err := api.GetAllDrives(ctx, pager)
|
||||
if err != nil {
|
||||
@ -176,7 +176,7 @@ func (c *Collections) makeDriveCollections(
|
||||
) ([]data.BackupCollection, map[string]string, pagers.DeltaUpdate, error) {
|
||||
driveID := ptr.Val(drv.GetId())
|
||||
|
||||
ppfx, err := c.handler.PathPrefix(c.tenantID, driveID)
|
||||
ppfx, err := c.handler.PathPrefix(driveID)
|
||||
if err != nil {
|
||||
return nil, nil, pagers.DeltaUpdate{}, clues.Wrap(err, "generating backup tree prefix")
|
||||
}
|
||||
@ -228,7 +228,7 @@ func (c *Collections) makeDriveCollections(
|
||||
// if a reset did occur, the collections should already be marked as
|
||||
// "do not merge", therefore everything will get processed as a new addition.
|
||||
if !tree.hadReset && len(prevDeltaLink) > 0 {
|
||||
p, err := c.handler.CanonicalPath(odConsts.DriveFolderPrefixBuilder(driveID), c.tenantID)
|
||||
p, err := c.handler.CanonicalPath(odConsts.DriveFolderPrefixBuilder(driveID))
|
||||
if err != nil {
|
||||
err = clues.WrapWC(ctx, err, "making canonical path for item exclusions")
|
||||
return nil, nil, pagers.DeltaUpdate{}, err
|
||||
@ -552,7 +552,7 @@ func (c *Collections) makeFolderCollectionPath(
|
||||
) (path.Path, error) {
|
||||
if folder.GetRoot() != nil {
|
||||
pb := odConsts.DriveFolderPrefixBuilder(driveID)
|
||||
collectionPath, err := c.handler.CanonicalPath(pb, c.tenantID)
|
||||
collectionPath, err := c.handler.CanonicalPath(pb)
|
||||
|
||||
return collectionPath, clues.WrapWC(ctx, err, "making canonical root path").OrNil()
|
||||
}
|
||||
@ -571,7 +571,7 @@ func (c *Collections) makeFolderCollectionPath(
|
||||
folderPath := path.Split(ptr.Val(folder.GetParentReference().GetPath()))
|
||||
folderPath = append(folderPath, name)
|
||||
pb := path.Builder{}.Append(folderPath...)
|
||||
collectionPath, err := c.handler.CanonicalPath(pb, c.tenantID)
|
||||
collectionPath, err := c.handler.CanonicalPath(pb)
|
||||
|
||||
return collectionPath, clues.WrapWC(ctx, err, "making folder collection path").OrNil()
|
||||
}
|
||||
@ -684,7 +684,7 @@ func (c *Collections) makeDriveTombstones(
|
||||
break
|
||||
}
|
||||
|
||||
prevDrivePath, err := c.handler.PathPrefix(c.tenantID, driveID)
|
||||
prevDrivePath, err := c.handler.PathPrefix(driveID)
|
||||
if err != nil {
|
||||
err = clues.WrapWC(ctx, err, "making drive tombstone for previous path").Label(count.BadPathPrefix)
|
||||
el.AddRecoverable(ctx, err)
|
||||
@ -712,7 +712,7 @@ func (c *Collections) makeMetadataCollections(
|
||||
) []data.BackupCollection {
|
||||
colls := []data.BackupCollection{}
|
||||
|
||||
pathPrefix, err := c.handler.MetadataPathPrefix(c.tenantID)
|
||||
pathPrefix, err := c.handler.MetadataPathPrefix()
|
||||
if err != nil {
|
||||
logger.CtxErr(ctx, err).Info("making metadata collection path prefixes")
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/custom"
|
||||
)
|
||||
|
||||
@ -17,51 +18,51 @@ var _ BackupHandler = &groupBackupHandler{}
|
||||
|
||||
type groupBackupHandler struct {
|
||||
siteBackupHandler
|
||||
groupID string
|
||||
groupQP graph.QueryParams
|
||||
scope selectors.GroupsScope
|
||||
}
|
||||
|
||||
func NewGroupBackupHandler(
|
||||
groupID, siteID string,
|
||||
groupQP, siteQP graph.QueryParams,
|
||||
ac api.Drives,
|
||||
scope selectors.GroupsScope,
|
||||
) groupBackupHandler {
|
||||
return groupBackupHandler{
|
||||
siteBackupHandler{
|
||||
siteBackupHandler: siteBackupHandler{
|
||||
baseSiteHandler: baseSiteHandler{
|
||||
qp: siteQP,
|
||||
ac: ac,
|
||||
},
|
||||
siteID: siteID,
|
||||
// Not adding scope here. Anything that needs scope has to
|
||||
// be from group handler
|
||||
service: path.GroupsService,
|
||||
},
|
||||
groupID,
|
||||
scope,
|
||||
groupQP: groupQP,
|
||||
scope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
func (h groupBackupHandler) PathPrefix(
|
||||
tenantID, driveID string,
|
||||
driveID string,
|
||||
) (path.Path, error) {
|
||||
// TODO: move tenantID to struct
|
||||
return path.Build(
|
||||
tenantID,
|
||||
h.groupID,
|
||||
h.groupQP.TenantID,
|
||||
h.groupQP.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false,
|
||||
odConsts.SitesPathDir,
|
||||
h.siteID,
|
||||
h.siteBackupHandler.qp.ProtectedResource.ID(),
|
||||
odConsts.DrivesPathDir,
|
||||
driveID,
|
||||
odConsts.RootPathDir)
|
||||
}
|
||||
|
||||
func (h groupBackupHandler) MetadataPathPrefix(tenantID string) (path.Path, error) {
|
||||
func (h groupBackupHandler) MetadataPathPrefix() (path.Path, error) {
|
||||
p, err := path.BuildMetadata(
|
||||
tenantID,
|
||||
h.groupID,
|
||||
h.groupQP.TenantID,
|
||||
h.groupQP.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false)
|
||||
@ -69,7 +70,7 @@ func (h groupBackupHandler) MetadataPathPrefix(tenantID string) (path.Path, erro
|
||||
return nil, clues.Wrap(err, "making metadata path")
|
||||
}
|
||||
|
||||
p, err = p.Append(false, odConsts.SitesPathDir, h.siteID)
|
||||
p, err = p.Append(false, odConsts.SitesPathDir, h.siteBackupHandler.qp.ProtectedResource.ID())
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "appending site id to metadata path")
|
||||
}
|
||||
@ -79,27 +80,26 @@ func (h groupBackupHandler) MetadataPathPrefix(tenantID string) (path.Path, erro
|
||||
|
||||
func (h groupBackupHandler) CanonicalPath(
|
||||
folders *path.Builder,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return folders.ToDataLayerPath(
|
||||
tenantID,
|
||||
h.groupID,
|
||||
h.groupQP.TenantID,
|
||||
h.groupQP.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false,
|
||||
odConsts.SitesPathDir,
|
||||
h.siteID)
|
||||
h.siteBackupHandler.qp.ProtectedResource.ID())
|
||||
}
|
||||
|
||||
func (h groupBackupHandler) SitePathPrefix(tenantID string) (path.Path, error) {
|
||||
func (h groupBackupHandler) SitePathPrefix() (path.Path, error) {
|
||||
return path.Build(
|
||||
tenantID,
|
||||
h.groupID,
|
||||
h.groupQP.TenantID,
|
||||
h.groupQP.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false,
|
||||
odConsts.SitesPathDir,
|
||||
h.siteID)
|
||||
h.siteBackupHandler.qp.ProtectedResource.ID())
|
||||
}
|
||||
|
||||
func (h groupBackupHandler) AugmentItemInfo(
|
||||
|
||||
@ -7,9 +7,11 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type GroupBackupHandlerUnitSuite struct {
|
||||
@ -37,9 +39,17 @@ func (suite *GroupBackupHandlerUnitSuite) TestPathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := NewGroupBackupHandler(resourceOwner, "site-id", api.Drives{}, nil)
|
||||
groupQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
}
|
||||
siteQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider("site-id", "site-id"),
|
||||
}
|
||||
h := NewGroupBackupHandler(groupQP, siteQP, api.Drives{}, nil)
|
||||
|
||||
result, err := h.PathPrefix(tenantID, "drive-id")
|
||||
result, err := h.PathPrefix("drive-id")
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -66,9 +76,17 @@ func (suite *GroupBackupHandlerUnitSuite) TestSitePathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := NewGroupBackupHandler(resourceOwner, "site-id", api.Drives{}, nil)
|
||||
groupQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
}
|
||||
siteQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider("site-id", "site-id"),
|
||||
}
|
||||
h := NewGroupBackupHandler(groupQP, siteQP, api.Drives{}, nil)
|
||||
|
||||
result, err := h.SitePathPrefix(tenantID)
|
||||
result, err := h.SitePathPrefix()
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -95,9 +113,17 @@ func (suite *GroupBackupHandlerUnitSuite) TestMetadataPathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := NewGroupBackupHandler(resourceOwner, "site-id", api.Drives{}, nil)
|
||||
groupQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
}
|
||||
siteQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider("site-id", "site-id"),
|
||||
}
|
||||
h := NewGroupBackupHandler(groupQP, siteQP, api.Drives{}, nil)
|
||||
|
||||
result, err := h.MetadataPathPrefix(tenantID)
|
||||
result, err := h.MetadataPathPrefix()
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -124,10 +150,18 @@ func (suite *GroupBackupHandlerUnitSuite) TestCanonicalPath() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := NewGroupBackupHandler(resourceOwner, "site-id", api.Drives{}, nil)
|
||||
groupQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
}
|
||||
siteQP := graph.QueryParams{
|
||||
TenantID: tenantID,
|
||||
ProtectedResource: idname.NewProvider("site-id", "site-id"),
|
||||
}
|
||||
h := NewGroupBackupHandler(groupQP, siteQP, api.Drives{}, nil)
|
||||
p := path.Builder{}.Append("prefix")
|
||||
|
||||
result, err := h.CanonicalPath(p, tenantID)
|
||||
result, err := h.CanonicalPath(p)
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
|
||||
@ -40,19 +40,23 @@ type BackupHandler interface {
|
||||
GetItemPermissioner
|
||||
GetItemer
|
||||
GetRootFolderer
|
||||
NewDrivePagerer
|
||||
EnumerateDriveItemsDeltaer
|
||||
|
||||
// NewDrivePager produces a pager that fetches all drives for the
|
||||
// protected resource in the handler. Differs from the restore
|
||||
// drive pager in that it does not acccept a resource parameter.
|
||||
NewDrivePager(fields []string) pagers.NonDeltaHandler[models.Driveable]
|
||||
|
||||
// PathPrefix constructs the service and category specific path prefix for
|
||||
// the given values.
|
||||
PathPrefix(tenantID, driveID string) (path.Path, error)
|
||||
PathPrefix(driveID string) (path.Path, error)
|
||||
|
||||
// MetadataPathPrefix returns the prefix path for metadata
|
||||
MetadataPathPrefix(tenantID string) (path.Path, error)
|
||||
MetadataPathPrefix() (path.Path, error)
|
||||
|
||||
// CanonicalPath constructs the service and category specific path for
|
||||
// the given values.
|
||||
CanonicalPath(folders *path.Builder, tenantID string) (path.Path, error)
|
||||
CanonicalPath(folders *path.Builder) (path.Path, error)
|
||||
|
||||
// ServiceCat returns the service and category used by this implementation.
|
||||
ServiceCat() (path.ServiceType, path.CategoryType)
|
||||
@ -67,10 +71,6 @@ type BackupHandler interface {
|
||||
IncludesDir(dir string) bool
|
||||
}
|
||||
|
||||
type NewDrivePagerer interface {
|
||||
NewDrivePager(resourceOwner string, fields []string) pagers.NonDeltaHandler[models.Driveable]
|
||||
}
|
||||
|
||||
type GetItemPermissioner interface {
|
||||
GetItemPermission(
|
||||
ctx context.Context,
|
||||
@ -104,13 +104,14 @@ type RestoreHandler interface {
|
||||
GetItemsByCollisionKeyser
|
||||
GetRootFolderer
|
||||
ItemInfoAugmenter
|
||||
NewDrivePagerer
|
||||
NewItemContentUploader
|
||||
PostDriver
|
||||
PostItemInContainerer
|
||||
DeleteItemPermissioner
|
||||
UpdateItemPermissioner
|
||||
UpdateItemLinkSharer
|
||||
|
||||
NewDrivePagerer
|
||||
}
|
||||
|
||||
type DeleteItemer interface {
|
||||
@ -195,3 +196,13 @@ type GetRootFolderer interface {
|
||||
driveID string,
|
||||
) (models.DriveItemable, error)
|
||||
}
|
||||
|
||||
// NewDrivePagerer produces a pager that fetches all drives for the
|
||||
// protected resource in the handler. Differs from the backup
|
||||
// drive pager in that it accepts a resource parameter.
|
||||
type NewDrivePagerer interface {
|
||||
NewDrivePager(
|
||||
protectedResourceID string,
|
||||
fields []string,
|
||||
) pagers.NonDeltaHandler[models.Driveable]
|
||||
}
|
||||
|
||||
@ -717,6 +717,8 @@ type mockBackupHandler[T any] struct {
|
||||
GetErrs []error
|
||||
|
||||
RootFolder models.DriveItemable
|
||||
|
||||
TenantID string
|
||||
}
|
||||
|
||||
func stubRootFolder() models.DriveItemable {
|
||||
@ -752,6 +754,7 @@ func defaultOneDriveBH(resourceOwner string) *mockBackupHandler[models.DriveItem
|
||||
GetResps: []*http.Response{nil},
|
||||
GetErrs: []error{clues.New("not defined")},
|
||||
RootFolder: stubRootFolder(),
|
||||
TenantID: "tenantID",
|
||||
}
|
||||
}
|
||||
|
||||
@ -777,6 +780,7 @@ func defaultSharePointBH(resourceOwner string) *mockBackupHandler[models.DriveIt
|
||||
GetResps: []*http.Response{nil},
|
||||
GetErrs: []error{clues.New("not defined")},
|
||||
RootFolder: stubRootFolder(),
|
||||
TenantID: "tenantID",
|
||||
}
|
||||
}
|
||||
|
||||
@ -790,8 +794,8 @@ func defaultDriveBHWith(
|
||||
return mbh
|
||||
}
|
||||
|
||||
func (h mockBackupHandler[T]) PathPrefix(tID, driveID string) (path.Path, error) {
|
||||
pp, err := h.PathPrefixFn(tID, h.ProtectedResource.ID(), driveID)
|
||||
func (h mockBackupHandler[T]) PathPrefix(driveID string) (path.Path, error) {
|
||||
pp, err := h.PathPrefixFn(h.TenantID, h.ProtectedResource.ID(), driveID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -799,8 +803,8 @@ func (h mockBackupHandler[T]) PathPrefix(tID, driveID string) (path.Path, error)
|
||||
return pp, h.PathPrefixErr
|
||||
}
|
||||
|
||||
func (h mockBackupHandler[T]) MetadataPathPrefix(tID string) (path.Path, error) {
|
||||
pp, err := h.MetadataPathPrefixFn(tID, h.ProtectedResource.ID())
|
||||
func (h mockBackupHandler[T]) MetadataPathPrefix() (path.Path, error) {
|
||||
pp, err := h.MetadataPathPrefixFn(h.TenantID, h.ProtectedResource.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -808,8 +812,8 @@ func (h mockBackupHandler[T]) MetadataPathPrefix(tID string) (path.Path, error)
|
||||
return pp, h.MetadataPathPrefixErr
|
||||
}
|
||||
|
||||
func (h mockBackupHandler[T]) CanonicalPath(pb *path.Builder, tID string) (path.Path, error) {
|
||||
cp, err := h.CanonPathFn(pb, tID, h.ProtectedResource.ID())
|
||||
func (h mockBackupHandler[T]) CanonicalPath(pb *path.Builder) (path.Path, error) {
|
||||
cp, err := h.CanonPathFn(pb, h.TenantID, h.ProtectedResource.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -821,7 +825,7 @@ func (h mockBackupHandler[T]) ServiceCat() (path.ServiceType, path.CategoryType)
|
||||
return h.Service, h.Category
|
||||
}
|
||||
|
||||
func (h mockBackupHandler[T]) NewDrivePager(string, []string) pagers.NonDeltaHandler[models.Driveable] {
|
||||
func (h mockBackupHandler[T]) NewDrivePager([]string) pagers.NonDeltaHandler[models.Driveable] {
|
||||
return h.DriveItemEnumeration.drivePager()
|
||||
}
|
||||
|
||||
|
||||
@ -265,10 +265,13 @@ func (suite *OneDriveIntgSuite) TestOneDriveNewCollections() {
|
||||
colls := NewCollections(
|
||||
&userDriveBackupHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(suite.userID, suite.userID),
|
||||
TenantID: suite.creds.AzureClientID,
|
||||
},
|
||||
ac: suite.ac.Drives(),
|
||||
},
|
||||
userID: test.user,
|
||||
scope: scope,
|
||||
scope: scope,
|
||||
},
|
||||
creds.AzureTenantID,
|
||||
idname.NewProvider(test.user, test.user),
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/common/str"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
@ -117,10 +118,13 @@ func (suite *ItemIntegrationSuite) TestItemReader_oneDrive() {
|
||||
|
||||
bh := &userDriveBackupHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(suite.user, suite.user),
|
||||
TenantID: suite.service.credentials.AzureTenantID,
|
||||
},
|
||||
ac: suite.service.ac.Drives(),
|
||||
},
|
||||
userID: suite.user,
|
||||
scope: sc,
|
||||
scope: sc,
|
||||
}
|
||||
|
||||
// Read data for the file
|
||||
|
||||
@ -16,19 +16,20 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/custom"
|
||||
)
|
||||
|
||||
type baseSiteHandler struct {
|
||||
ac api.Drives
|
||||
qp graph.QueryParams
|
||||
}
|
||||
|
||||
func (h baseSiteHandler) NewDrivePager(
|
||||
resourceOwner string,
|
||||
fields []string,
|
||||
) pagers.NonDeltaHandler[models.Driveable] {
|
||||
return h.ac.NewSiteDrivePager(resourceOwner, fields)
|
||||
return h.ac.NewSiteDrivePager(h.qp.ProtectedResource.ID(), fields)
|
||||
}
|
||||
|
||||
func (h baseSiteHandler) AugmentItemInfo(
|
||||
@ -68,22 +69,21 @@ var _ BackupHandler = &siteBackupHandler{}
|
||||
|
||||
type siteBackupHandler struct {
|
||||
baseSiteHandler
|
||||
siteID string
|
||||
scope selectors.SharePointScope
|
||||
service path.ServiceType
|
||||
}
|
||||
|
||||
func NewSiteBackupHandler(
|
||||
qp graph.QueryParams,
|
||||
ac api.Drives,
|
||||
siteID string,
|
||||
scope selectors.SharePointScope,
|
||||
service path.ServiceType,
|
||||
) siteBackupHandler {
|
||||
return siteBackupHandler{
|
||||
baseSiteHandler: baseSiteHandler{
|
||||
ac: ac,
|
||||
qp: qp,
|
||||
},
|
||||
siteID: siteID,
|
||||
scope: scope,
|
||||
service: service,
|
||||
}
|
||||
@ -98,11 +98,11 @@ func (h siteBackupHandler) Get(
|
||||
}
|
||||
|
||||
func (h siteBackupHandler) PathPrefix(
|
||||
tenantID, driveID string,
|
||||
driveID string,
|
||||
) (path.Path, error) {
|
||||
return path.Build(
|
||||
tenantID,
|
||||
h.siteID,
|
||||
h.qp.TenantID,
|
||||
h.qp.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false,
|
||||
@ -111,12 +111,10 @@ func (h siteBackupHandler) PathPrefix(
|
||||
odConsts.RootPathDir)
|
||||
}
|
||||
|
||||
func (h siteBackupHandler) MetadataPathPrefix(
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
func (h siteBackupHandler) MetadataPathPrefix() (path.Path, error) {
|
||||
p, err := path.BuildMetadata(
|
||||
tenantID,
|
||||
h.siteID,
|
||||
h.qp.TenantID,
|
||||
h.qp.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false)
|
||||
@ -129,9 +127,13 @@ func (h siteBackupHandler) MetadataPathPrefix(
|
||||
|
||||
func (h siteBackupHandler) CanonicalPath(
|
||||
folders *path.Builder,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return folders.ToDataLayerPath(tenantID, h.siteID, h.service, path.LibrariesCategory, false)
|
||||
return folders.ToDataLayerPath(
|
||||
h.qp.TenantID,
|
||||
h.qp.ProtectedResource.ID(),
|
||||
h.service,
|
||||
path.LibrariesCategory,
|
||||
false)
|
||||
}
|
||||
|
||||
func (h siteBackupHandler) ServiceCat() (path.ServiceType, path.CategoryType) {
|
||||
@ -212,6 +214,13 @@ func NewSiteRestoreHandler(ac api.Client, service path.ServiceType) siteRestoreH
|
||||
}
|
||||
}
|
||||
|
||||
func (h siteRestoreHandler) NewDrivePager(
|
||||
protectedResourceID string,
|
||||
fields []string,
|
||||
) pagers.NonDeltaHandler[models.Driveable] {
|
||||
return h.ac.Drives().NewSiteDrivePager(protectedResourceID, fields)
|
||||
}
|
||||
|
||||
func (h siteRestoreHandler) PostDrive(
|
||||
ctx context.Context,
|
||||
siteID, driveName string,
|
||||
|
||||
@ -7,8 +7,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type LibraryBackupHandlerUnitSuite struct {
|
||||
@ -36,9 +38,17 @@ func (suite *LibraryBackupHandlerUnitSuite) TestPathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := siteBackupHandler{service: path.SharePointService, siteID: resourceOwner}
|
||||
h := siteBackupHandler{
|
||||
baseSiteHandler: baseSiteHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
},
|
||||
service: path.SharePointService,
|
||||
}
|
||||
|
||||
result, err := h.PathPrefix(tenantID, "driveID")
|
||||
result, err := h.PathPrefix("driveID")
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -65,9 +75,17 @@ func (suite *LibraryBackupHandlerUnitSuite) TestMetadataPathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := siteBackupHandler{service: path.SharePointService, siteID: resourceOwner}
|
||||
h := siteBackupHandler{
|
||||
baseSiteHandler: baseSiteHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
},
|
||||
service: path.SharePointService,
|
||||
}
|
||||
|
||||
result, err := h.MetadataPathPrefix(tenantID)
|
||||
result, err := h.MetadataPathPrefix()
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -94,10 +112,18 @@ func (suite *LibraryBackupHandlerUnitSuite) TestCanonicalPath() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := siteBackupHandler{service: path.SharePointService, siteID: resourceOwner}
|
||||
h := siteBackupHandler{
|
||||
baseSiteHandler: baseSiteHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
},
|
||||
service: path.SharePointService,
|
||||
}
|
||||
p := path.Builder{}.Append("prefix")
|
||||
|
||||
result, err := h.CanonicalPath(p, tenantID)
|
||||
result, err := h.CanonicalPath(p)
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/custom"
|
||||
)
|
||||
@ -26,13 +27,13 @@ import (
|
||||
|
||||
type baseUserDriveHandler struct {
|
||||
ac api.Drives
|
||||
qp graph.QueryParams
|
||||
}
|
||||
|
||||
func (h baseUserDriveHandler) NewDrivePager(
|
||||
resourceOwner string,
|
||||
fields []string,
|
||||
) pagers.NonDeltaHandler[models.Driveable] {
|
||||
return h.ac.NewUserDrivePager(resourceOwner, fields)
|
||||
return h.ac.NewUserDrivePager(h.qp.ProtectedResource.ID(), fields)
|
||||
}
|
||||
|
||||
// AugmentItemInfo will populate a details.OneDriveInfo struct
|
||||
@ -75,17 +76,20 @@ var _ BackupHandler = &userDriveBackupHandler{}
|
||||
|
||||
type userDriveBackupHandler struct {
|
||||
baseUserDriveHandler
|
||||
userID string
|
||||
scope selectors.OneDriveScope
|
||||
scope selectors.OneDriveScope
|
||||
}
|
||||
|
||||
func NewUserDriveBackupHandler(ac api.Drives, userID string, scope selectors.OneDriveScope) *userDriveBackupHandler {
|
||||
func NewUserDriveBackupHandler(
|
||||
qp graph.QueryParams,
|
||||
ac api.Drives,
|
||||
scope selectors.OneDriveScope,
|
||||
) *userDriveBackupHandler {
|
||||
return &userDriveBackupHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
ac: ac,
|
||||
qp: qp,
|
||||
},
|
||||
userID: userID,
|
||||
scope: scope,
|
||||
scope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,11 +102,11 @@ func (h userDriveBackupHandler) Get(
|
||||
}
|
||||
|
||||
func (h userDriveBackupHandler) PathPrefix(
|
||||
tenantID, driveID string,
|
||||
driveID string,
|
||||
) (path.Path, error) {
|
||||
return path.Build(
|
||||
tenantID,
|
||||
h.userID,
|
||||
h.qp.TenantID,
|
||||
h.qp.ProtectedResource.ID(),
|
||||
path.OneDriveService,
|
||||
path.FilesCategory,
|
||||
false,
|
||||
@ -111,12 +115,10 @@ func (h userDriveBackupHandler) PathPrefix(
|
||||
odConsts.RootPathDir)
|
||||
}
|
||||
|
||||
func (h userDriveBackupHandler) MetadataPathPrefix(
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
func (h userDriveBackupHandler) MetadataPathPrefix() (path.Path, error) {
|
||||
p, err := path.BuildMetadata(
|
||||
tenantID,
|
||||
h.userID,
|
||||
h.qp.TenantID,
|
||||
h.qp.ProtectedResource.ID(),
|
||||
path.OneDriveService,
|
||||
path.FilesCategory,
|
||||
false)
|
||||
@ -129,11 +131,10 @@ func (h userDriveBackupHandler) MetadataPathPrefix(
|
||||
|
||||
func (h userDriveBackupHandler) CanonicalPath(
|
||||
folders *path.Builder,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return path.Build(
|
||||
tenantID,
|
||||
h.userID,
|
||||
h.qp.TenantID,
|
||||
h.qp.ProtectedResource.ID(),
|
||||
path.OneDriveService,
|
||||
path.FilesCategory,
|
||||
false,
|
||||
@ -205,7 +206,9 @@ type userDriveRestoreHandler struct {
|
||||
baseUserDriveHandler
|
||||
}
|
||||
|
||||
func NewUserDriveRestoreHandler(ac api.Client) *userDriveRestoreHandler {
|
||||
func NewUserDriveRestoreHandler(
|
||||
ac api.Client,
|
||||
) *userDriveRestoreHandler {
|
||||
return &userDriveRestoreHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
ac: ac.Drives(),
|
||||
@ -213,6 +216,13 @@ func NewUserDriveRestoreHandler(ac api.Client) *userDriveRestoreHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func (h userDriveRestoreHandler) NewDrivePager(
|
||||
protectedResourceID string,
|
||||
fields []string,
|
||||
) pagers.NonDeltaHandler[models.Driveable] {
|
||||
return h.ac.NewUserDrivePager(protectedResourceID, fields)
|
||||
}
|
||||
|
||||
func (h userDriveRestoreHandler) PostDrive(
|
||||
context.Context,
|
||||
string, string,
|
||||
|
||||
@ -7,8 +7,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type ItemBackupHandlerUnitSuite struct {
|
||||
@ -36,9 +38,16 @@ func (suite *ItemBackupHandlerUnitSuite) TestPathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := userDriveBackupHandler{userID: resourceOwner}
|
||||
h := userDriveBackupHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := h.PathPrefix(tenantID, "driveID")
|
||||
result, err := h.PathPrefix("driveID")
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -65,9 +74,16 @@ func (suite *ItemBackupHandlerUnitSuite) TestMetadataPathPrefix() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := userDriveBackupHandler{userID: resourceOwner}
|
||||
h := userDriveBackupHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := h.MetadataPathPrefix(tenantID)
|
||||
result, err := h.MetadataPathPrefix()
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
@ -94,10 +110,17 @@ func (suite *ItemBackupHandlerUnitSuite) TestCanonicalPath() {
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
h := userDriveBackupHandler{userID: resourceOwner}
|
||||
h := userDriveBackupHandler{
|
||||
baseUserDriveHandler: baseUserDriveHandler{
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(resourceOwner, resourceOwner),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
},
|
||||
}
|
||||
p := path.Builder{}.Append("prefix")
|
||||
|
||||
result, err := h.CanonicalPath(p, tenantID)
|
||||
result, err := h.CanonicalPath(p)
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
if result != nil {
|
||||
|
||||
@ -181,7 +181,7 @@ func populateCollections[C graph.GetIDer, I groupsItemer](
|
||||
logger.Ctx(ictx).Info("missing delta url")
|
||||
}
|
||||
|
||||
currPath, err := bh.canonicalPath(c.storageDirFolders, qp.TenantID)
|
||||
currPath, err := bh.canonicalPath(c.storageDirFolders)
|
||||
if err != nil {
|
||||
err = clues.StackWC(ctx, err).Label(count.BadCollPath)
|
||||
el.AddRecoverable(ctx, err)
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
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"
|
||||
@ -50,6 +51,7 @@ type mockBackupHandler struct {
|
||||
info map[string]*details.GroupsInfo
|
||||
getMessageErr map[string]error
|
||||
doNotInclude bool
|
||||
tenantID string
|
||||
}
|
||||
|
||||
//lint:ignore U1000 false linter issue due to generics
|
||||
@ -114,12 +116,11 @@ func (bh mockBackupHandler) includeContainer(
|
||||
|
||||
func (bh mockBackupHandler) canonicalPath(
|
||||
storageDirFolders path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return storageDirFolders.
|
||||
Builder().
|
||||
ToDataLayerPath(
|
||||
tenantID,
|
||||
bh.tenantID,
|
||||
"protectedResource",
|
||||
path.GroupsService,
|
||||
path.ChannelMessagesCategory,
|
||||
@ -519,7 +520,11 @@ func (suite *BackupIntgSuite) TestCreateCollections() {
|
||||
var (
|
||||
protectedResource = tconfig.M365TeamID(suite.T())
|
||||
resources = []string{protectedResource}
|
||||
handler = NewChannelBackupHandler(protectedResource, suite.ac.Channels())
|
||||
qp = graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(protectedResource, protectedResource),
|
||||
TenantID: "tenant",
|
||||
}
|
||||
handler = NewChannelBackupHandler(qp, suite.ac.Channels())
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@ -11,23 +11,24 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
|
||||
)
|
||||
|
||||
var _ backupHandler[models.Channelable, models.ChatMessageable] = &channelsBackupHandler{}
|
||||
|
||||
type channelsBackupHandler struct {
|
||||
ac api.Channels
|
||||
protectedResource string
|
||||
ac api.Channels
|
||||
qp graph.QueryParams
|
||||
}
|
||||
|
||||
func NewChannelBackupHandler(
|
||||
protectedResource string,
|
||||
qp graph.QueryParams,
|
||||
ac api.Channels,
|
||||
) channelsBackupHandler {
|
||||
return channelsBackupHandler{
|
||||
ac: ac,
|
||||
protectedResource: protectedResource,
|
||||
ac: ac,
|
||||
qp: qp,
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +41,7 @@ func (bh channelsBackupHandler) getContainers(
|
||||
ctx context.Context,
|
||||
_ api.CallConfig,
|
||||
) ([]container[models.Channelable], error) {
|
||||
chans, err := bh.ac.GetChannels(ctx, bh.protectedResource)
|
||||
chans, err := bh.ac.GetChannels(ctx, bh.qp.ProtectedResource.ID())
|
||||
results := make([]container[models.Channelable], 0, len(chans))
|
||||
|
||||
for _, ch := range chans {
|
||||
@ -58,7 +59,7 @@ func (bh channelsBackupHandler) getContainerItemIDs(
|
||||
) (pagers.AddedAndRemoved, error) {
|
||||
return bh.ac.GetChannelMessageIDs(
|
||||
ctx,
|
||||
bh.protectedResource,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
containerPath[0],
|
||||
prevDelta,
|
||||
cc)
|
||||
@ -74,22 +75,21 @@ func (bh channelsBackupHandler) includeContainer(
|
||||
|
||||
func (bh channelsBackupHandler) canonicalPath(
|
||||
storageDirFolders path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return storageDirFolders.
|
||||
Builder().
|
||||
ToDataLayerPath(
|
||||
tenantID,
|
||||
bh.protectedResource,
|
||||
bh.qp.TenantID,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
path.GroupsService,
|
||||
path.ChannelMessagesCategory,
|
||||
false)
|
||||
}
|
||||
|
||||
func (bh channelsBackupHandler) PathPrefix(tenantID string) (path.Path, error) {
|
||||
func (bh channelsBackupHandler) PathPrefix() (path.Path, error) {
|
||||
return path.Build(
|
||||
tenantID,
|
||||
bh.protectedResource,
|
||||
bh.qp.TenantID,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
path.GroupsService,
|
||||
path.ChannelMessagesCategory,
|
||||
false)
|
||||
|
||||
@ -11,23 +11,24 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
|
||||
)
|
||||
|
||||
var _ backupHandler[models.Conversationable, models.Postable] = &conversationsBackupHandler{}
|
||||
|
||||
type conversationsBackupHandler struct {
|
||||
ac api.Conversations
|
||||
protectedResource string
|
||||
ac api.Conversations
|
||||
qp graph.QueryParams
|
||||
}
|
||||
|
||||
func NewConversationBackupHandler(
|
||||
protectedResource string,
|
||||
qp graph.QueryParams,
|
||||
ac api.Conversations,
|
||||
) conversationsBackupHandler {
|
||||
return conversationsBackupHandler{
|
||||
ac: ac,
|
||||
protectedResource: protectedResource,
|
||||
ac: ac,
|
||||
qp: qp,
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +42,7 @@ func (bh conversationsBackupHandler) getContainers(
|
||||
ctx context.Context,
|
||||
cc api.CallConfig,
|
||||
) ([]container[models.Conversationable], error) {
|
||||
convs, err := bh.ac.GetConversations(ctx, bh.protectedResource, cc)
|
||||
convs, err := bh.ac.GetConversations(ctx, bh.qp.ProtectedResource.ID(), cc)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "getting conversations")
|
||||
}
|
||||
@ -53,7 +54,7 @@ func (bh conversationsBackupHandler) getContainers(
|
||||
|
||||
threads, err := bh.ac.GetConversationThreads(
|
||||
ictx,
|
||||
bh.protectedResource,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
ptr.Val(conv.GetId()),
|
||||
cc)
|
||||
if err != nil {
|
||||
@ -76,7 +77,7 @@ func (bh conversationsBackupHandler) getContainerItemIDs(
|
||||
) (pagers.AddedAndRemoved, error) {
|
||||
return bh.ac.GetConversationThreadPostIDs(
|
||||
ctx,
|
||||
bh.protectedResource,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
containerPath[0],
|
||||
containerPath[1],
|
||||
cc)
|
||||
@ -92,22 +93,21 @@ func (bh conversationsBackupHandler) includeContainer(
|
||||
|
||||
func (bh conversationsBackupHandler) canonicalPath(
|
||||
storageDirFolders path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return storageDirFolders.
|
||||
Builder().
|
||||
ToDataLayerPath(
|
||||
tenantID,
|
||||
bh.protectedResource,
|
||||
bh.qp.TenantID,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
path.GroupsService,
|
||||
path.ConversationPostsCategory,
|
||||
false)
|
||||
}
|
||||
|
||||
func (bh conversationsBackupHandler) PathPrefix(tenantID string) (path.Path, error) {
|
||||
func (bh conversationsBackupHandler) PathPrefix() (path.Path, error) {
|
||||
return path.Build(
|
||||
tenantID,
|
||||
bh.protectedResource,
|
||||
bh.qp.TenantID,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
path.GroupsService,
|
||||
path.ConversationPostsCategory,
|
||||
false)
|
||||
|
||||
@ -137,7 +137,7 @@ func formatChannelMessage(
|
||||
return nil, clues.New("expected deserialized item to implement models.ChatMessageable")
|
||||
}
|
||||
|
||||
mItem := makeMinimumChannelMesasge(msg)
|
||||
mItem := makeMinimumChannelMessage(msg)
|
||||
replies := msg.GetReplies()
|
||||
|
||||
mcmar := minimumChannelMessageAndReplies{
|
||||
@ -146,7 +146,7 @@ func formatChannelMessage(
|
||||
}
|
||||
|
||||
for _, r := range replies {
|
||||
mcmar.Replies = append(mcmar.Replies, makeMinimumChannelMesasge(r))
|
||||
mcmar.Replies = append(mcmar.Replies, makeMinimumChannelMessage(r))
|
||||
}
|
||||
|
||||
bs, err = marshalJSONContainingHTML(mcmar)
|
||||
@ -172,7 +172,7 @@ func marshalJSONContainingHTML(a any) ([]byte, error) {
|
||||
return buffer.Bytes(), clues.Stack(err).OrNil()
|
||||
}
|
||||
|
||||
func makeMinimumChannelMesasge(item models.ChatMessageable) minimumChannelMessage {
|
||||
func makeMinimumChannelMessage(item models.ChatMessageable) minimumChannelMessage {
|
||||
var content string
|
||||
|
||||
if item.GetBody() != nil {
|
||||
|
||||
@ -81,10 +81,7 @@ type includeContainerer[C graph.GetIDer] interface {
|
||||
// canonicalPath constructs the service and category specific path for
|
||||
// the given builder.
|
||||
type canonicalPather interface {
|
||||
canonicalPath(
|
||||
storageDir path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error)
|
||||
canonicalPath(storageDir path.Elements) (path.Path, error)
|
||||
}
|
||||
|
||||
// canMakeDeltaQueries evaluates whether the handler can support a
|
||||
|
||||
@ -253,7 +253,7 @@ func populateListsCollections(
|
||||
}
|
||||
}
|
||||
|
||||
currPath, err := bh.CanonicalPath(storageDir, tenantID)
|
||||
currPath, err := bh.CanonicalPath(storageDir)
|
||||
if err != nil {
|
||||
el.AddRecoverable(ctx, clues.WrapWC(ctx, err, "creating list collection path"))
|
||||
return nil, err
|
||||
|
||||
@ -67,7 +67,7 @@ func (suite *SharePointBackupUnitSuite) TestCollectLists() {
|
||||
}{
|
||||
{
|
||||
name: "one list",
|
||||
mock: siteMock.NewListHandler(siteMock.StubLists("one"), siteID, nil),
|
||||
mock: siteMock.NewListHandler(siteMock.StubLists("one"), suite.creds.AzureTenantID, siteID, nil),
|
||||
expectErr: require.NoError,
|
||||
expectColls: 2,
|
||||
expectNewColls: 1,
|
||||
@ -76,7 +76,7 @@ func (suite *SharePointBackupUnitSuite) TestCollectLists() {
|
||||
},
|
||||
{
|
||||
name: "many lists",
|
||||
mock: siteMock.NewListHandler(siteMock.StubLists("one", "two"), siteID, nil),
|
||||
mock: siteMock.NewListHandler(siteMock.StubLists("one", "two"), suite.creds.AzureTenantID, siteID, nil),
|
||||
expectErr: require.NoError,
|
||||
expectColls: 3,
|
||||
expectNewColls: 2,
|
||||
@ -85,7 +85,7 @@ func (suite *SharePointBackupUnitSuite) TestCollectLists() {
|
||||
},
|
||||
{
|
||||
name: "with error",
|
||||
mock: siteMock.NewListHandler(siteMock.StubLists("one"), siteID, errors.New("some error")),
|
||||
mock: siteMock.NewListHandler(siteMock.StubLists("one"), suite.creds.AzureTenantID, siteID, errors.New("some error")),
|
||||
expectErr: require.Error,
|
||||
expectColls: 0,
|
||||
expectNewColls: 0,
|
||||
@ -286,7 +286,7 @@ func (suite *SharePointBackupUnitSuite) TestPopulateListsCollections_incremental
|
||||
|
||||
cs, err := populateListsCollections(
|
||||
ctx,
|
||||
siteMock.NewListHandler(test.lists, siteID, nil),
|
||||
siteMock.NewListHandler(test.lists, suite.creds.AzureTenantID, siteID, nil),
|
||||
bpc,
|
||||
ac,
|
||||
suite.creds.AzureTenantID,
|
||||
@ -417,8 +417,12 @@ func (suite *SharePointSuite) TestCollectLists() {
|
||||
}
|
||||
|
||||
sel := selectors.NewSharePointBackup([]string{siteID})
|
||||
qp := graph.QueryParams{
|
||||
ProtectedResource: bpc.ProtectedResource,
|
||||
TenantID: creds.AzureTenantID,
|
||||
}
|
||||
|
||||
bh := NewListsBackupHandler(bpc.ProtectedResource.ID(), ac.Lists())
|
||||
bh := NewListsBackupHandler(qp, ac.Lists())
|
||||
|
||||
col, _, err := CollectLists(
|
||||
ctx,
|
||||
|
||||
@ -312,10 +312,11 @@ func (suite *SharePointCollectionSuite) TestLazyCollection_Items() {
|
||||
errs = fault.New(true)
|
||||
start = time.Now().Add(-time.Second)
|
||||
statusUpdater = func(*support.ControllerOperationStatus) {}
|
||||
tenant = "t"
|
||||
)
|
||||
|
||||
fullPath, err := path.Build(
|
||||
"t",
|
||||
tenant,
|
||||
"pr",
|
||||
path.SharePointService,
|
||||
path.ListsCategory,
|
||||
@ -326,7 +327,7 @@ func (suite *SharePointCollectionSuite) TestLazyCollection_Items() {
|
||||
locPath := path.Elements{"full"}.Builder()
|
||||
|
||||
prevPath, err := path.Build(
|
||||
"t",
|
||||
tenant,
|
||||
"pr",
|
||||
path.SharePointService,
|
||||
path.ListsCategory,
|
||||
@ -367,7 +368,7 @@ func (suite *SharePointCollectionSuite) TestLazyCollection_Items() {
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
getter := mock.NewListHandler(nil, "", nil)
|
||||
getter := mock.NewListHandler(nil, tenant, "", nil)
|
||||
defer getter.Check(t, test.expectReads)
|
||||
|
||||
col := NewLazyFetchCollection(
|
||||
@ -425,7 +426,7 @@ func (suite *SharePointCollectionSuite) TestLazyItem() {
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
lh := mock.NewListHandler(nil, "", nil)
|
||||
lh := mock.NewListHandler(nil, "tenant", "", nil)
|
||||
|
||||
li := data.NewLazyItemWithInfo(
|
||||
ctx,
|
||||
@ -469,7 +470,7 @@ func (suite *SharePointCollectionSuite) TestLazyItem_ReturnsEmptyReaderOnDeleted
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
lh := mock.NewListHandler(nil, "", core.ErrNotFound)
|
||||
lh := mock.NewListHandler(nil, "tenant", "", core.ErrNotFound)
|
||||
|
||||
li := data.NewLazyItemWithInfo(
|
||||
ctx,
|
||||
|
||||
@ -20,10 +20,7 @@ type backupHandler interface {
|
||||
// canonicalPath constructs the service and category specific path for
|
||||
// the given builder.
|
||||
type canonicalPather interface {
|
||||
CanonicalPath(
|
||||
storageDir path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error)
|
||||
CanonicalPath(storageDir path.Elements) (path.Path, error)
|
||||
}
|
||||
|
||||
type getItemByIDer interface {
|
||||
|
||||
@ -9,31 +9,34 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
var _ backupHandler = &listsBackupHandler{}
|
||||
|
||||
type listsBackupHandler struct {
|
||||
ac api.Lists
|
||||
protectedResource string
|
||||
ac api.Lists
|
||||
qp graph.QueryParams
|
||||
}
|
||||
|
||||
func NewListsBackupHandler(protectedResource string, ac api.Lists) listsBackupHandler {
|
||||
func NewListsBackupHandler(
|
||||
qp graph.QueryParams,
|
||||
ac api.Lists,
|
||||
) listsBackupHandler {
|
||||
return listsBackupHandler{
|
||||
ac: ac,
|
||||
protectedResource: protectedResource,
|
||||
ac: ac,
|
||||
qp: qp,
|
||||
}
|
||||
}
|
||||
|
||||
func (bh listsBackupHandler) CanonicalPath(
|
||||
storageDirFolders path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return storageDirFolders.
|
||||
Builder().
|
||||
ToDataLayerPath(
|
||||
tenantID,
|
||||
bh.protectedResource,
|
||||
bh.qp.TenantID,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
path.SharePointService,
|
||||
path.ListsCategory,
|
||||
false)
|
||||
@ -43,11 +46,11 @@ func (bh listsBackupHandler) GetItemByID(
|
||||
ctx context.Context,
|
||||
itemID string,
|
||||
) (models.Listable, *details.SharePointInfo, error) {
|
||||
return bh.ac.GetListByID(ctx, bh.protectedResource, itemID)
|
||||
return bh.ac.GetListByID(ctx, bh.qp.ProtectedResource.ID(), itemID)
|
||||
}
|
||||
|
||||
func (bh listsBackupHandler) GetItems(ctx context.Context, cc api.CallConfig) ([]models.Listable, error) {
|
||||
return bh.ac.GetLists(ctx, bh.protectedResource, cc)
|
||||
return bh.ac.GetLists(ctx, bh.qp.ProtectedResource.ID(), cc)
|
||||
}
|
||||
|
||||
var _ restoreHandler = &listsRestoreHandler{}
|
||||
|
||||
@ -9,30 +9,39 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type ListHandler struct {
|
||||
protectedResource string
|
||||
lists []models.Listable
|
||||
listsMap map[string]models.Listable
|
||||
err error
|
||||
qp graph.QueryParams
|
||||
lists []models.Listable
|
||||
listsMap map[string]models.Listable
|
||||
err error
|
||||
}
|
||||
|
||||
func NewListHandler(lists []models.Listable, protectedResource string, err error) ListHandler {
|
||||
func NewListHandler(
|
||||
lists []models.Listable,
|
||||
tenantID, protectedResource string,
|
||||
err error,
|
||||
) ListHandler {
|
||||
lstMap := make(map[string]models.Listable)
|
||||
for _, lst := range lists {
|
||||
lstMap[ptr.Val(lst.GetId())] = lst
|
||||
}
|
||||
|
||||
return ListHandler{
|
||||
protectedResource: protectedResource,
|
||||
lists: lists,
|
||||
listsMap: lstMap,
|
||||
err: err,
|
||||
qp: graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(protectedResource, protectedResource),
|
||||
TenantID: tenantID,
|
||||
},
|
||||
lists: lists,
|
||||
listsMap: lstMap,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,13 +81,12 @@ func (lh ListHandler) GetItems(
|
||||
|
||||
func (lh ListHandler) CanonicalPath(
|
||||
storageDirFolders path.Elements,
|
||||
tenantID string,
|
||||
) (path.Path, error) {
|
||||
return storageDirFolders.
|
||||
Builder().
|
||||
ToDataLayerPath(
|
||||
tenantID,
|
||||
lh.protectedResource,
|
||||
lh.qp.TenantID,
|
||||
lh.qp.ProtectedResource.ID(),
|
||||
path.SharePointService,
|
||||
path.ListsCategory,
|
||||
false)
|
||||
|
||||
155
src/internal/m365/collection/teamsChats/backup.go
Normal file
155
src/internal/m365/collection/teamsChats/backup.go
Normal file
@ -0,0 +1,155 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/support"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/pkg/backup/metadata"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
func CreateCollections[I chatsItemer](
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
bh backupHandler[I],
|
||||
tenantID string,
|
||||
scope selectors.TeamsChatsScope,
|
||||
statusUpdater support.StatusUpdater,
|
||||
useLazyReader bool,
|
||||
counter *count.Bus,
|
||||
errs *fault.Bus,
|
||||
) ([]data.BackupCollection, bool, error) {
|
||||
var (
|
||||
category = scope.Category().PathType()
|
||||
qp = graph.QueryParams{
|
||||
Category: category,
|
||||
ProtectedResource: bpc.ProtectedResource,
|
||||
TenantID: tenantID,
|
||||
}
|
||||
)
|
||||
|
||||
container := bh.getContainer()
|
||||
|
||||
counter.Add(count.Containers, 1)
|
||||
|
||||
collection, err := populateCollection[I](
|
||||
ctx,
|
||||
qp,
|
||||
bh,
|
||||
statusUpdater,
|
||||
container,
|
||||
scope,
|
||||
useLazyReader,
|
||||
bpc.Options,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, false, clues.Wrap(err, "filling collections")
|
||||
}
|
||||
|
||||
collections := []data.BackupCollection{collection}
|
||||
|
||||
metadataPrefix, err := path.BuildMetadata(
|
||||
qp.TenantID,
|
||||
qp.ProtectedResource.ID(),
|
||||
path.TeamsChatsService,
|
||||
qp.Category,
|
||||
false)
|
||||
if err != nil {
|
||||
return nil, false, clues.WrapWC(ctx, err, "making metadata path prefix").
|
||||
Label(count.BadPathPrefix)
|
||||
}
|
||||
|
||||
metadataCollection, err := graph.MakeMetadataCollection(
|
||||
metadataPrefix,
|
||||
// no deltas or previousPaths are used here; we store empty files instead
|
||||
[]graph.MetadataCollectionEntry{
|
||||
graph.NewMetadataEntry(metadata.PreviousPathFileName, map[string]string{}),
|
||||
graph.NewMetadataEntry(metadata.DeltaURLsFileName, map[string]string{}),
|
||||
},
|
||||
statusUpdater,
|
||||
counter.Local())
|
||||
if err != nil {
|
||||
return nil, false, clues.WrapWC(ctx, err, "making metadata collection")
|
||||
}
|
||||
|
||||
collections = append(collections, metadataCollection)
|
||||
|
||||
// no deltas involved in this category, so canUsePrevBackups is always true.
|
||||
return collections, true, nil
|
||||
}
|
||||
|
||||
func populateCollection[I chatsItemer](
|
||||
ctx context.Context,
|
||||
qp graph.QueryParams,
|
||||
bh backupHandler[I],
|
||||
statusUpdater support.StatusUpdater,
|
||||
container container[I],
|
||||
scope selectors.TeamsChatsScope,
|
||||
useLazyReader bool,
|
||||
ctrlOpts control.Options,
|
||||
counter *count.Bus,
|
||||
errs *fault.Bus,
|
||||
) (data.BackupCollection, error) {
|
||||
var (
|
||||
cl = counter.Local()
|
||||
collection data.BackupCollection
|
||||
err error
|
||||
)
|
||||
|
||||
ctx = clues.AddLabelCounter(ctx, cl.PlainAdder())
|
||||
|
||||
items, err := bh.getItemIDs(ctx)
|
||||
if err != nil {
|
||||
errs.AddRecoverable(ctx, clues.Stack(err))
|
||||
return collection, clues.Stack(errs.Failure()).OrNil()
|
||||
}
|
||||
|
||||
// Only create a collection if the path matches the scope.
|
||||
includedItems := []I{}
|
||||
|
||||
for _, item := range items {
|
||||
if !bh.includeItem(item, scope) {
|
||||
cl.Inc(count.SkippedItems)
|
||||
continue
|
||||
}
|
||||
|
||||
includedItems = append(includedItems, item)
|
||||
}
|
||||
|
||||
cl.Add(count.ItemsAdded, int64(len(includedItems)))
|
||||
cl.Add(count.ItemsRemoved, 0)
|
||||
|
||||
p, err := bh.CanonicalPath()
|
||||
if err != nil {
|
||||
err = clues.StackWC(ctx, err).Label(count.BadCollPath)
|
||||
errs.AddRecoverable(ctx, err)
|
||||
|
||||
return collection, clues.Stack(errs.Failure()).OrNil()
|
||||
}
|
||||
|
||||
collection = NewCollection(
|
||||
data.NewBaseCollection(
|
||||
p,
|
||||
p,
|
||||
&path.Builder{},
|
||||
ctrlOpts,
|
||||
false,
|
||||
cl),
|
||||
bh,
|
||||
qp.ProtectedResource.ID(),
|
||||
includedItems,
|
||||
container,
|
||||
statusUpdater)
|
||||
|
||||
return collection, clues.Stack(errs.Failure()).OrNil()
|
||||
}
|
||||
356
src/internal/m365/collection/teamsChats/backup_test.go
Normal file
356
src/internal/m365/collection/teamsChats/backup_test.go
Normal file
@ -0,0 +1,356 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
inMock "github.com/alcionai/corso/src/internal/common/idname/mock"
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/teamschats/testdata"
|
||||
"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/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
"github.com/alcionai/corso/src/pkg/errs/core"
|
||||
"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"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _ backupHandler[models.Chatable] = &mockBackupHandler{}
|
||||
|
||||
//lint:ignore U1000 false linter issue due to generics
|
||||
type mockBackupHandler struct {
|
||||
chatsErr error
|
||||
chats []models.Chatable
|
||||
chatMessagesErr error
|
||||
chatMessages map[string][]models.ChatMessageable
|
||||
info map[string]*details.TeamsChatsInfo
|
||||
getMessageErr map[string]error
|
||||
doNotInclude bool
|
||||
}
|
||||
|
||||
func (bh mockBackupHandler) container() container[models.Chatable] {
|
||||
return container[models.Chatable]{}
|
||||
}
|
||||
|
||||
//lint:ignore U1000 required for interface compliance
|
||||
func (bh mockBackupHandler) getContainer() container[models.Chatable] {
|
||||
return container[models.Chatable]{}
|
||||
}
|
||||
|
||||
func (bh mockBackupHandler) getItemIDs(
|
||||
_ context.Context,
|
||||
) ([]models.Chatable, error) {
|
||||
return bh.chats, bh.chatsErr
|
||||
}
|
||||
|
||||
//lint:ignore U1000 required for interface compliance
|
||||
func (bh mockBackupHandler) includeItem(
|
||||
models.Chatable,
|
||||
selectors.TeamsChatsScope,
|
||||
) bool {
|
||||
return !bh.doNotInclude
|
||||
}
|
||||
|
||||
func (bh mockBackupHandler) CanonicalPath() (path.Path, error) {
|
||||
return path.BuildPrefix(
|
||||
"tenant",
|
||||
"protectedResource",
|
||||
path.TeamsChatsService,
|
||||
path.ChatsCategory)
|
||||
}
|
||||
|
||||
//lint:ignore U1000 false linter issue due to generics
|
||||
func (bh mockBackupHandler) fillItem(
|
||||
_ context.Context,
|
||||
chat models.Chatable,
|
||||
) (models.Chatable, *details.TeamsChatsInfo, error) {
|
||||
chatID := ptr.Val(chat.GetId())
|
||||
|
||||
chat.SetMessages(bh.chatMessages[chatID])
|
||||
|
||||
return chat, bh.info[chatID], bh.getMessageErr[chatID]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.ChatsCategory, // doesn't matter which one we use.
|
||||
ProtectedResource: inMock.NewProvider("user_id", "user_name"),
|
||||
TenantID: suite.creds.AzureTenantID,
|
||||
}
|
||||
statusUpdater = func(*support.ControllerOperationStatus) {}
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
mock mockBackupHandler
|
||||
expectErr require.ErrorAssertionFunc
|
||||
expectColl require.ValueAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "happy path, one chat",
|
||||
mock: mockBackupHandler{
|
||||
chats: testdata.StubChats("one"),
|
||||
chatMessages: map[string][]models.ChatMessageable{
|
||||
"one": testdata.StubChatMessages("msg-one"),
|
||||
},
|
||||
},
|
||||
expectErr: require.NoError,
|
||||
expectColl: require.NotNil,
|
||||
},
|
||||
{
|
||||
name: "happy path, many chats",
|
||||
mock: mockBackupHandler{
|
||||
chats: testdata.StubChats("one", "two"),
|
||||
chatMessages: map[string][]models.ChatMessageable{
|
||||
"one": testdata.StubChatMessages("msg-one"),
|
||||
"two": testdata.StubChatMessages("msg-two"),
|
||||
},
|
||||
},
|
||||
expectErr: require.NoError,
|
||||
expectColl: require.NotNil,
|
||||
},
|
||||
{
|
||||
name: "no chats pass scope",
|
||||
mock: mockBackupHandler{
|
||||
chats: testdata.StubChats("one"),
|
||||
doNotInclude: true,
|
||||
},
|
||||
expectErr: require.NoError,
|
||||
expectColl: require.NotNil,
|
||||
},
|
||||
{
|
||||
name: "no chats",
|
||||
mock: mockBackupHandler{},
|
||||
expectErr: require.NoError,
|
||||
expectColl: require.NotNil,
|
||||
},
|
||||
{
|
||||
name: "no chat messages",
|
||||
mock: mockBackupHandler{
|
||||
chats: testdata.StubChats("one"),
|
||||
},
|
||||
expectErr: require.NoError,
|
||||
expectColl: require.NotNil,
|
||||
},
|
||||
{
|
||||
name: "err: deleted in flight",
|
||||
mock: mockBackupHandler{
|
||||
chats: testdata.StubChats("one"),
|
||||
chatsErr: core.ErrNotFound,
|
||||
},
|
||||
expectErr: require.Error,
|
||||
expectColl: require.Nil,
|
||||
},
|
||||
{
|
||||
name: "err enumerating chats",
|
||||
mock: mockBackupHandler{
|
||||
chats: testdata.StubChats("one"),
|
||||
chatsErr: assert.AnError,
|
||||
},
|
||||
expectErr: require.Error,
|
||||
expectColl: require.Nil,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
ctrlOpts := control.Options{FailureHandling: control.FailFast}
|
||||
|
||||
result, err := populateCollection(
|
||||
ctx,
|
||||
qp,
|
||||
test.mock,
|
||||
statusUpdater,
|
||||
test.mock.container(),
|
||||
selectors.NewTeamsChatsBackup(nil).Chats(selectors.Any())[0],
|
||||
false,
|
||||
ctrlOpts,
|
||||
count.New(),
|
||||
fault.New(true))
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
test.expectColl(t, result)
|
||||
|
||||
if err != nil || result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// collection assertions
|
||||
|
||||
assert.NotEqual(
|
||||
t,
|
||||
result.FullPath().Service(),
|
||||
path.TeamsChatsMetadataService,
|
||||
"should not contain metadata collections")
|
||||
assert.NotEqual(t, result.State(), data.DeletedState, "no tombstones should be produced")
|
||||
assert.Equal(t, result.State(), data.NotMovedState)
|
||||
assert.False(t, result.DoNotMergeItems(), "doNotMergeItems should always be false")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(),
|
||||
count.New())
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
suite.tenantID = creds.AzureTenantID
|
||||
}
|
||||
|
||||
func (suite *BackupIntgSuite) TestCreateCollections() {
|
||||
var (
|
||||
tenant = tconfig.M365TenantID(suite.T())
|
||||
protectedResource = tconfig.M365TeamID(suite.T())
|
||||
resources = []string{protectedResource}
|
||||
qp = graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(protectedResource, protectedResource),
|
||||
TenantID: tenant,
|
||||
}
|
||||
handler = NewUsersChatsBackupHandler(qp, suite.ac.Chats())
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scope selectors.TeamsChatsScope
|
||||
chatNames map[string]struct{}
|
||||
}{
|
||||
{
|
||||
name: "chat messages",
|
||||
scope: selTD.TeamsChatsBackupChatScope(selectors.NewTeamsChatsBackup(resources))[0],
|
||||
chatNames: map[string]struct{}{
|
||||
selTD.TestChatTopic: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
ctrlOpts := control.DefaultOptions()
|
||||
|
||||
sel := selectors.NewTeamsChatsBackup([]string{protectedResource})
|
||||
sel.Include(selTD.TeamsChatsBackupChatScope(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) {},
|
||||
false,
|
||||
count.New(),
|
||||
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.TeamsChatsMetadataService {
|
||||
continue
|
||||
}
|
||||
|
||||
require.Empty(t, c.FullPath().Folder(false), "all items should be stored at the root")
|
||||
|
||||
locp, ok := c.(data.LocationPather)
|
||||
|
||||
if ok {
|
||||
loc := locp.LocationPath().String()
|
||||
require.Empty(t, loc, "no items should have locations")
|
||||
}
|
||||
}
|
||||
|
||||
assert.Len(t, collections, 2, "should have the root folder collection and metadata collection")
|
||||
})
|
||||
}
|
||||
}
|
||||
105
src/internal/m365/collection/teamsChats/chat_handler.go
Normal file
105
src/internal/m365/collection/teamsChats/chat_handler.go
Normal file
@ -0,0 +1,105 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/errs/core"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
var _ backupHandler[models.Chatable] = &usersChatsBackupHandler{}
|
||||
|
||||
type usersChatsBackupHandler struct {
|
||||
ac api.Chats
|
||||
qp graph.QueryParams
|
||||
}
|
||||
|
||||
func NewUsersChatsBackupHandler(
|
||||
qp graph.QueryParams,
|
||||
ac api.Chats,
|
||||
) usersChatsBackupHandler {
|
||||
return usersChatsBackupHandler{
|
||||
ac: ac,
|
||||
qp: qp,
|
||||
}
|
||||
}
|
||||
|
||||
// chats have no containers. Everything is stored at the root.
|
||||
//
|
||||
//lint:ignore U1000 required for interface compliance
|
||||
func (bh usersChatsBackupHandler) getContainer() container[models.Chatable] {
|
||||
return container[models.Chatable]{}
|
||||
}
|
||||
|
||||
//lint:ignore U1000 required for interface compliance
|
||||
func (bh usersChatsBackupHandler) getItemIDs(
|
||||
ctx context.Context,
|
||||
) ([]models.Chatable, error) {
|
||||
cc := api.CallConfig{
|
||||
Expand: []string{"lastMessagePreview"},
|
||||
}
|
||||
|
||||
return bh.ac.GetChats(
|
||||
ctx,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
cc)
|
||||
}
|
||||
|
||||
//lint:ignore U1000 required for interface compliance
|
||||
func (bh usersChatsBackupHandler) includeItem(
|
||||
ch models.Chatable,
|
||||
scope selectors.TeamsChatsScope,
|
||||
) bool {
|
||||
// corner case: many Topics are empty, and empty inputs are automatically
|
||||
// set to non-matching in the selectors code. This allows us to include
|
||||
// everything without needing to check the topic value in that case.
|
||||
if scope.IsAny(selectors.TeamsChatsChat) {
|
||||
return true
|
||||
}
|
||||
|
||||
return scope.Matches(selectors.TeamsChatsChat, ptr.Val(ch.GetTopic()))
|
||||
}
|
||||
|
||||
func (bh usersChatsBackupHandler) CanonicalPath() (path.Path, error) {
|
||||
return path.BuildPrefix(
|
||||
bh.qp.TenantID,
|
||||
bh.qp.ProtectedResource.ID(),
|
||||
path.TeamsChatsService,
|
||||
path.ChatsCategory)
|
||||
}
|
||||
|
||||
//lint:ignore U1000 false linter issue due to generics
|
||||
func (bh usersChatsBackupHandler) fillItem(
|
||||
ctx context.Context,
|
||||
chat models.Chatable,
|
||||
) (models.Chatable, *details.TeamsChatsInfo, error) {
|
||||
if chat == nil {
|
||||
return nil, nil, clues.Stack(core.ErrNotFound)
|
||||
}
|
||||
|
||||
chatID := ptr.Val(chat.GetId())
|
||||
|
||||
msgs, err := bh.ac.GetChatMessages(ctx, chatID, api.CallConfig{})
|
||||
if err != nil {
|
||||
return nil, nil, clues.Stack(err)
|
||||
}
|
||||
|
||||
chat.SetMessages(msgs)
|
||||
|
||||
members, err := bh.ac.GetChatMembers(ctx, chatID, api.CallConfig{})
|
||||
if err != nil {
|
||||
return nil, nil, clues.Stack(err)
|
||||
}
|
||||
|
||||
chat.SetMembers(members)
|
||||
|
||||
return chat, api.TeamsChatInfo(chat), nil
|
||||
}
|
||||
253
src/internal/m365/collection/teamsChats/collection.go
Normal file
253
src/internal/m365/collection/teamsChats/collection.go
Normal file
@ -0,0 +1,253 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
kjson "github.com/microsoft/kiota-serialization-json-go"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/support"
|
||||
"github.com/alcionai/corso/src/internal/observe"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/errs/core"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/logger"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
var _ data.BackupCollection = &lazyFetchCollection[chatsItemer]{}
|
||||
|
||||
const (
|
||||
collectionChannelBufferSize = 1000
|
||||
numberOfRetries = 4
|
||||
)
|
||||
|
||||
// updateStatus is a utility function used to send the status update through
|
||||
// the channel.
|
||||
func updateStatus(
|
||||
ctx context.Context,
|
||||
statusUpdater support.StatusUpdater,
|
||||
attempted int,
|
||||
streamedItems int64,
|
||||
totalBytes int64,
|
||||
folderPath string,
|
||||
err error,
|
||||
) {
|
||||
status := support.CreateStatus(
|
||||
ctx,
|
||||
support.Backup,
|
||||
1,
|
||||
support.CollectionMetrics{
|
||||
Objects: attempted,
|
||||
Successes: int(streamedItems),
|
||||
Bytes: totalBytes,
|
||||
},
|
||||
folderPath)
|
||||
|
||||
logger.Ctx(ctx).Debugw("done streaming items", "status", status.String())
|
||||
|
||||
statusUpdater(status)
|
||||
}
|
||||
|
||||
// State of the collection is set as an observation of the current
|
||||
// and previous paths. If the curr path is nil, the state is assumed
|
||||
// to be deleted. If the prev path is nil, it is assumed newly created.
|
||||
// If both are populated, then state is either moved (if they differ),
|
||||
// or notMoved (if they match).
|
||||
func NewCollection[I chatsItemer](
|
||||
baseCol data.BaseCollection,
|
||||
filler fillItemer[I],
|
||||
protectedResource string,
|
||||
items []I,
|
||||
contains container[I],
|
||||
statusUpdater support.StatusUpdater,
|
||||
) data.BackupCollection {
|
||||
return &lazyFetchCollection[I]{
|
||||
BaseCollection: baseCol,
|
||||
items: items,
|
||||
contains: contains,
|
||||
filler: filler,
|
||||
statusUpdater: statusUpdater,
|
||||
stream: make(chan data.Item, collectionChannelBufferSize),
|
||||
protectedResource: protectedResource,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// lazyFetchCollection
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type lazyFetchCollection[I chatsItemer] struct {
|
||||
data.BaseCollection
|
||||
protectedResource string
|
||||
stream chan data.Item
|
||||
|
||||
contains container[I]
|
||||
|
||||
items []I
|
||||
|
||||
filler fillItemer[I]
|
||||
|
||||
statusUpdater support.StatusUpdater
|
||||
}
|
||||
|
||||
func (col *lazyFetchCollection[I]) Items(
|
||||
ctx context.Context,
|
||||
errs *fault.Bus,
|
||||
) <-chan data.Item {
|
||||
go col.streamItems(ctx, errs)
|
||||
return col.stream
|
||||
}
|
||||
|
||||
func (col *lazyFetchCollection[I]) streamItems(ctx context.Context, errs *fault.Bus) {
|
||||
var (
|
||||
streamedItems int64
|
||||
wg sync.WaitGroup
|
||||
progressMessage chan<- struct{}
|
||||
el = errs.Local()
|
||||
)
|
||||
|
||||
ctx = clues.Add(ctx, "category", col.Category().String())
|
||||
|
||||
defer func() {
|
||||
close(col.stream)
|
||||
logger.Ctx(ctx).Infow(
|
||||
"finished stream backup collection items",
|
||||
"stats", col.Counter.Values())
|
||||
|
||||
updateStatus(
|
||||
ctx,
|
||||
col.statusUpdater,
|
||||
len(col.items),
|
||||
streamedItems,
|
||||
0,
|
||||
col.FullPath().Folder(false),
|
||||
errs.Failure())
|
||||
}()
|
||||
|
||||
if len(col.items) > 0 {
|
||||
progressMessage = observe.CollectionProgress(
|
||||
ctx,
|
||||
col.Category().HumanString(),
|
||||
col.LocationPath().Elements())
|
||||
defer close(progressMessage)
|
||||
}
|
||||
|
||||
semaphoreCh := make(chan struct{}, col.Opts().Parallelism.ItemFetch)
|
||||
defer close(semaphoreCh)
|
||||
|
||||
// add any new items
|
||||
for _, item := range col.items {
|
||||
if el.Failure() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
modTime := ptr.Val(item.GetLastUpdatedDateTime())
|
||||
|
||||
wg.Add(1)
|
||||
semaphoreCh <- struct{}{}
|
||||
|
||||
go func(item I, modTime time.Time) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphoreCh }()
|
||||
|
||||
itemID := ptr.Val(item.GetId())
|
||||
ictx := clues.Add(ctx, "item_id", itemID)
|
||||
|
||||
col.stream <- data.NewLazyItemWithInfo(
|
||||
ictx,
|
||||
&lazyItemFiller[I]{
|
||||
modTime: modTime,
|
||||
filler: col.filler,
|
||||
resourceID: col.protectedResource,
|
||||
item: item,
|
||||
containerIDs: col.FullPath().Folders(),
|
||||
contains: col.contains,
|
||||
parentPath: col.LocationPath().String(),
|
||||
},
|
||||
itemID,
|
||||
modTime,
|
||||
col.Counter,
|
||||
el)
|
||||
|
||||
atomic.AddInt64(&streamedItems, 1)
|
||||
|
||||
if progressMessage != nil {
|
||||
progressMessage <- struct{}{}
|
||||
}
|
||||
}(item, modTime)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
type lazyItemFiller[I chatsItemer] struct {
|
||||
filler fillItemer[I]
|
||||
resourceID string
|
||||
item I
|
||||
parentPath string
|
||||
containerIDs path.Elements
|
||||
modTime time.Time
|
||||
contains container[I]
|
||||
}
|
||||
|
||||
func (lig *lazyItemFiller[I]) GetData(
|
||||
ctx context.Context,
|
||||
errs *fault.Bus,
|
||||
) (io.ReadCloser, *details.ItemInfo, bool, error) {
|
||||
writer := kjson.NewJsonSerializationWriter()
|
||||
defer writer.Close()
|
||||
|
||||
item, info, err := lig.filler.fillItem(ctx, lig.item)
|
||||
if err != nil {
|
||||
// For items that were deleted in flight, add the skip label so that
|
||||
// they don't lead to recoverable failures during backup.
|
||||
if clues.HasLabel(err, graph.LabelStatus(http.StatusNotFound)) || errors.Is(err, core.ErrNotFound) {
|
||||
logger.CtxErr(ctx, err).Info("item deleted in flight. skipping")
|
||||
|
||||
// Returning delInFlight as true here for correctness, although the caller is going
|
||||
// to ignore it since we are returning an error.
|
||||
return nil, nil, true, clues.Wrap(err, "deleted item").Label(graph.LabelsSkippable)
|
||||
}
|
||||
|
||||
err = clues.WrapWC(ctx, err, "getting item data").Label(fault.LabelForceNoBackupCreation)
|
||||
errs.AddRecoverable(ctx, err)
|
||||
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
if err := writer.WriteObjectValue("", item); err != nil {
|
||||
err = clues.WrapWC(ctx, err, "writing item to serializer").Label(fault.LabelForceNoBackupCreation)
|
||||
errs.AddRecoverable(ctx, err)
|
||||
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
itemData, err := writer.GetSerializedContent()
|
||||
if err != nil {
|
||||
err = clues.WrapWC(ctx, err, "serializing item").Label(fault.LabelForceNoBackupCreation)
|
||||
errs.AddRecoverable(ctx, err)
|
||||
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
info.ParentPath = lig.parentPath
|
||||
// Update the mod time to what we already told kopia about. This is required
|
||||
// for proper details merging.
|
||||
info.Modified = lig.modTime
|
||||
|
||||
return io.NopCloser(bytes.NewReader(itemData)),
|
||||
&details.ItemInfo{TeamsChats: info},
|
||||
false,
|
||||
nil
|
||||
}
|
||||
399
src/internal/m365/collection/teamsChats/collection_test.go
Normal file
399
src/internal/m365/collection/teamsChats/collection_test.go
Normal file
@ -0,0 +1,399 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/google/uuid"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/common/readers"
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/teamschats/testdata"
|
||||
"github.com/alcionai/corso/src/internal/m365/support"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
"github.com/alcionai/corso/src/pkg/errs/core"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
)
|
||||
|
||||
type CollectionUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestCollectionUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &CollectionUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *CollectionUnitSuite) TestPrefetchedItem_Reader() {
|
||||
table := []struct {
|
||||
name string
|
||||
readData []byte
|
||||
}{
|
||||
{
|
||||
name: "HasData",
|
||||
readData: []byte("test message"),
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
readData: []byte{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ed, err := data.NewPrefetchedItemWithInfo(
|
||||
io.NopCloser(bytes.NewReader(test.readData)),
|
||||
"itemID",
|
||||
details.ItemInfo{})
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
r, err := readers.NewVersionedRestoreReader(ed.ToReader())
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
assert.Equal(t, readers.DefaultSerializationVersion, r.Format().Version)
|
||||
assert.False(t, r.Format().DelInFlight)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
_, err = buf.ReadFrom(r)
|
||||
assert.NoError(t, err, "reading data: %v", clues.ToCore(err))
|
||||
assert.Equal(t, test.readData, buf.Bytes(), "read data")
|
||||
assert.Equal(t, "itemID", ed.ID(), "item ID")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CollectionUnitSuite) TestNewCollection_state() {
|
||||
fooP, err := path.Build("t", "u", path.TeamsChatsService, path.ChatsCategory, false, "foo")
|
||||
require.NoError(suite.T(), err, clues.ToCore(err))
|
||||
barP, err := path.Build("t", "u", path.TeamsChatsService, path.ChatsCategory, false, "bar")
|
||||
require.NoError(suite.T(), err, clues.ToCore(err))
|
||||
|
||||
locPB := path.Builder{}.Append("human-readable")
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
prev path.Path
|
||||
curr path.Path
|
||||
loc *path.Builder
|
||||
expect data.CollectionState
|
||||
}{
|
||||
{
|
||||
name: "new",
|
||||
curr: fooP,
|
||||
loc: locPB,
|
||||
expect: data.NewState,
|
||||
},
|
||||
{
|
||||
name: "not moved",
|
||||
prev: fooP,
|
||||
curr: fooP,
|
||||
loc: locPB,
|
||||
expect: data.NotMovedState,
|
||||
},
|
||||
{
|
||||
name: "moved",
|
||||
prev: fooP,
|
||||
curr: barP,
|
||||
loc: locPB,
|
||||
expect: data.MovedState,
|
||||
},
|
||||
{
|
||||
name: "deleted",
|
||||
prev: fooP,
|
||||
expect: data.DeletedState,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
c := NewCollection[models.Chatable](
|
||||
data.NewBaseCollection(
|
||||
test.curr,
|
||||
test.prev,
|
||||
test.loc,
|
||||
control.DefaultOptions(),
|
||||
false,
|
||||
count.New()),
|
||||
nil,
|
||||
"g",
|
||||
nil,
|
||||
container[models.Chatable]{},
|
||||
nil)
|
||||
|
||||
assert.Equal(t, test.expect, c.State(), "collection state")
|
||||
assert.Equal(t, test.curr, c.FullPath(), "full path")
|
||||
assert.Equal(t, test.prev, c.PreviousPath(), "prev path")
|
||||
|
||||
prefetch, ok := c.(*lazyFetchCollection[models.Chatable])
|
||||
require.True(t, ok, "collection type")
|
||||
|
||||
assert.Equal(t, test.loc, prefetch.LocationPath(), "location path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fillChat struct {
|
||||
err error
|
||||
}
|
||||
|
||||
//lint:ignore U1000 false linter issue due to generics
|
||||
func (m fillChat) fillItem(
|
||||
_ context.Context,
|
||||
chat models.Chatable,
|
||||
) (models.Chatable, *details.TeamsChatsInfo, error) {
|
||||
chat.SetTopic(chat.GetId())
|
||||
|
||||
return chat, &details.TeamsChatsInfo{}, m.err
|
||||
}
|
||||
|
||||
func (suite *CollectionUnitSuite) TestLazyFetchCollection_Items_LazyFetch() {
|
||||
var (
|
||||
t = suite.T()
|
||||
statusUpdater = func(*support.ControllerOperationStatus) {}
|
||||
)
|
||||
|
||||
fullPath, err := path.BuildPrefix("t", "pr", path.TeamsChatsService, path.ChatsCategory)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
locPath, err := path.BuildPrefix("t", "pr", path.TeamsChatsService, path.ChatsCategory)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
items []models.Chatable
|
||||
expectItemCount int
|
||||
// Items we want to trigger lazy reader on.
|
||||
expectReads []string
|
||||
}{
|
||||
{
|
||||
name: "no items",
|
||||
},
|
||||
{
|
||||
name: "items",
|
||||
items: testdata.StubChats("fisher", "flannigan", "fitzbog"),
|
||||
expectItemCount: 3,
|
||||
expectReads: []string{
|
||||
"fisher",
|
||||
"flannigan",
|
||||
"fitzbog",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
var (
|
||||
t = suite.T()
|
||||
errs = fault.New(true)
|
||||
itemCount int
|
||||
)
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
filler := &fillChat{}
|
||||
|
||||
col := &lazyFetchCollection[models.Chatable]{
|
||||
BaseCollection: data.NewBaseCollection(
|
||||
fullPath,
|
||||
nil,
|
||||
locPath.ToBuilder(),
|
||||
control.DefaultOptions(),
|
||||
false,
|
||||
count.New()),
|
||||
items: test.items,
|
||||
contains: container[models.Chatable]{},
|
||||
filler: filler,
|
||||
stream: make(chan data.Item),
|
||||
statusUpdater: statusUpdater,
|
||||
}
|
||||
|
||||
for item := range col.Items(ctx, errs) {
|
||||
itemCount++
|
||||
|
||||
ok := slices.ContainsFunc(test.items, func(mc models.Chatable) bool {
|
||||
return ptr.Val(mc.GetId()) == item.ID()
|
||||
})
|
||||
|
||||
require.True(t, ok, "item must be either added or removed: %q", item.ID())
|
||||
assert.False(t, item.Deleted(), "additions should not be marked as deleted")
|
||||
}
|
||||
|
||||
assert.NoError(t, errs.Failure())
|
||||
assert.Equal(
|
||||
t,
|
||||
test.expectItemCount,
|
||||
itemCount,
|
||||
"should see all expected items")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CollectionUnitSuite) TestLazyItem_GetDataErrors() {
|
||||
var (
|
||||
parentPath = ""
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
getErr error
|
||||
expectReadErrType error
|
||||
}{
|
||||
{
|
||||
name: "ReturnsErrorOnGenericGetError",
|
||||
getErr: assert.AnError,
|
||||
expectReadErrType: assert.AnError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
chat := testdata.StubChats(uuid.NewString())[0]
|
||||
|
||||
m := fillChat{
|
||||
err: test.getErr,
|
||||
}
|
||||
|
||||
li := data.NewLazyItemWithInfo(
|
||||
ctx,
|
||||
&lazyItemFiller[models.Chatable]{
|
||||
resourceID: "resourceID",
|
||||
item: chat,
|
||||
filler: &m,
|
||||
modTime: now,
|
||||
parentPath: parentPath,
|
||||
},
|
||||
ptr.Val(chat.GetId()),
|
||||
now,
|
||||
count.New(),
|
||||
fault.New(true))
|
||||
|
||||
assert.False(t, li.Deleted(), "item shouldn't be marked deleted")
|
||||
assert.Equal(t, now, li.ModTime(), "item mod time")
|
||||
|
||||
_, err := readers.NewVersionedRestoreReader(li.ToReader())
|
||||
assert.ErrorIs(t, err, test.expectReadErrType)
|
||||
|
||||
// Should get some form of error when trying to get info.
|
||||
_, err = li.Info()
|
||||
assert.Error(t, err, "Info()")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *CollectionUnitSuite) TestLazyItem_ReturnsEmptyReaderOnDeletedInFlight() {
|
||||
var (
|
||||
t = suite.T()
|
||||
|
||||
parentPath = ""
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
chat := testdata.StubChats(uuid.NewString())[0]
|
||||
|
||||
m := fillChat{
|
||||
err: core.ErrNotFound,
|
||||
}
|
||||
|
||||
li := data.NewLazyItemWithInfo(
|
||||
ctx,
|
||||
&lazyItemFiller[models.Chatable]{
|
||||
resourceID: "resourceID",
|
||||
item: chat,
|
||||
filler: &m,
|
||||
modTime: now,
|
||||
parentPath: parentPath,
|
||||
},
|
||||
ptr.Val(chat.GetId()),
|
||||
now,
|
||||
count.New(),
|
||||
fault.New(true))
|
||||
|
||||
assert.False(t, li.Deleted(), "item shouldn't be marked deleted")
|
||||
assert.Equal(
|
||||
t,
|
||||
now,
|
||||
li.ModTime(),
|
||||
"item mod time")
|
||||
|
||||
_, err := readers.NewVersionedRestoreReader(li.ToReader())
|
||||
assert.ErrorIs(t, err, core.ErrNotFound, "item should be marked deleted in flight")
|
||||
}
|
||||
|
||||
func (suite *CollectionUnitSuite) TestLazyItem() {
|
||||
var (
|
||||
t = suite.T()
|
||||
|
||||
parentPath = ""
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
chat := testdata.StubChats(uuid.NewString())[0]
|
||||
m := fillChat{}
|
||||
|
||||
li := data.NewLazyItemWithInfo(
|
||||
ctx,
|
||||
&lazyItemFiller[models.Chatable]{
|
||||
resourceID: "resourceID",
|
||||
item: chat,
|
||||
filler: &m,
|
||||
modTime: now,
|
||||
parentPath: parentPath,
|
||||
},
|
||||
ptr.Val(chat.GetId()),
|
||||
now,
|
||||
count.New(),
|
||||
fault.New(true))
|
||||
|
||||
assert.False(t, li.Deleted(), "item shouldn't be marked deleted")
|
||||
assert.Equal(
|
||||
t,
|
||||
now,
|
||||
li.ModTime(),
|
||||
"item mod time")
|
||||
|
||||
r, err := readers.NewVersionedRestoreReader(li.ToReader())
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
assert.Equal(t, readers.DefaultSerializationVersion, r.Format().Version)
|
||||
assert.False(t, r.Format().DelInFlight)
|
||||
|
||||
readData, err := io.ReadAll(r)
|
||||
assert.NoError(t, err, "reading item data: %v", clues.ToCore(err))
|
||||
|
||||
assert.NotEmpty(t, readData, "read item data")
|
||||
|
||||
info, err := li.Info()
|
||||
assert.NoError(t, err, "getting item info: %v", clues.ToCore(err))
|
||||
|
||||
assert.Empty(t, parentPath)
|
||||
assert.Equal(t, now, info.Modified())
|
||||
}
|
||||
17
src/internal/m365/collection/teamsChats/debug.go
Normal file
17
src/internal/m365/collection/teamsChats/debug.go
Normal file
@ -0,0 +1,17 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/pkg/store"
|
||||
)
|
||||
|
||||
func DeserializeMetadataFiles(
|
||||
ctx context.Context,
|
||||
colls []data.RestoreCollection,
|
||||
) ([]store.MetadataFile, error) {
|
||||
return nil, clues.New("no metadata stored for this service/category")
|
||||
}
|
||||
229
src/internal/m365/collection/teamsChats/export.go
Normal file
229
src/internal/m365/collection/teamsChats/export.go
Normal file
@ -0,0 +1,229 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/export"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/metrics"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
)
|
||||
|
||||
func NewExportCollection(
|
||||
baseDir string,
|
||||
backingCollections []data.RestoreCollection,
|
||||
backupVersion int,
|
||||
cec control.ExportConfig,
|
||||
stats *metrics.ExportStats,
|
||||
) export.Collectioner {
|
||||
return export.BaseCollection{
|
||||
BaseDir: baseDir,
|
||||
BackingCollection: backingCollections,
|
||||
BackupVersion: backupVersion,
|
||||
Cfg: cec,
|
||||
Stream: streamItems,
|
||||
Stats: stats,
|
||||
}
|
||||
}
|
||||
|
||||
// streamItems streams the items in the backingCollection into the export stream chan
|
||||
func streamItems(
|
||||
ctx context.Context,
|
||||
drc []data.RestoreCollection,
|
||||
backupVersion int,
|
||||
cec control.ExportConfig,
|
||||
ch chan<- export.Item,
|
||||
stats *metrics.ExportStats,
|
||||
) {
|
||||
defer close(ch)
|
||||
|
||||
errs := fault.New(false)
|
||||
|
||||
for _, rc := range drc {
|
||||
for item := range rc.Items(ctx, errs) {
|
||||
body, err := formatChat(cec, item.ToReader())
|
||||
if err != nil {
|
||||
ch <- export.Item{
|
||||
ID: item.ID(),
|
||||
Error: err,
|
||||
}
|
||||
} else {
|
||||
stats.UpdateResourceCount(path.ChatsCategory)
|
||||
body = metrics.ReaderWithStats(body, path.ChatsCategory, stats)
|
||||
|
||||
// messages are exported as json and should be named as such
|
||||
name := item.ID() + ".json"
|
||||
|
||||
ch <- export.Item{
|
||||
ID: item.ID(),
|
||||
Name: name,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items, recovered := errs.ItemsAndRecovered()
|
||||
|
||||
// Return all the items that we failed to source from the persistence layer
|
||||
for _, item := range items {
|
||||
ch <- export.Item{
|
||||
ID: item.ID,
|
||||
Error: &item,
|
||||
}
|
||||
}
|
||||
|
||||
for _, err := range recovered {
|
||||
ch <- export.Item{
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
minimumChat struct {
|
||||
CreatedDateTime time.Time `json:"createdDateTime"`
|
||||
LastUpdatedDateTime time.Time `json:"lastUpdatedDateTime"`
|
||||
Topic string `json:"topic"`
|
||||
Messages []minimumChatMessage `json:"replies,omitempty"`
|
||||
Members []minimumChatMember `json:"members"`
|
||||
}
|
||||
|
||||
minimumChatMember struct {
|
||||
Name string `json:"name"`
|
||||
VisibleHistoryStartedAt time.Time `json:"visibleHistoryStartedAt"`
|
||||
}
|
||||
|
||||
minimumChatMessage struct {
|
||||
Attachments []minimumAttachment `json:"attachments"`
|
||||
Content string `json:"content"`
|
||||
CreatedDateTime time.Time `json:"createdDateTime"`
|
||||
From string `json:"from"`
|
||||
LastModifiedDateTime time.Time `json:"lastModifiedDateTime"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
|
||||
minimumAttachment struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
)
|
||||
|
||||
func formatChat(
|
||||
cec control.ExportConfig,
|
||||
rc io.ReadCloser,
|
||||
) (io.ReadCloser, error) {
|
||||
if cec.Format == control.JSONFormat {
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
bs, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "reading item bytes")
|
||||
}
|
||||
|
||||
defer rc.Close()
|
||||
|
||||
cfb, err := api.CreateFromBytes(bs, models.CreateChatFromDiscriminatorValue)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "deserializing bytes to message")
|
||||
}
|
||||
|
||||
chat, ok := cfb.(models.Chatable)
|
||||
if !ok {
|
||||
return nil, clues.New("expected deserialized item to implement models.Chatable")
|
||||
}
|
||||
|
||||
var (
|
||||
members = chat.GetMembers()
|
||||
messages = chat.GetMessages()
|
||||
)
|
||||
|
||||
result := minimumChat{
|
||||
CreatedDateTime: ptr.Val(chat.GetCreatedDateTime()),
|
||||
LastUpdatedDateTime: ptr.Val(chat.GetLastUpdatedDateTime()),
|
||||
Topic: ptr.Val(chat.GetTopic()),
|
||||
Members: make([]minimumChatMember, 0, len(members)),
|
||||
Messages: make([]minimumChatMessage, 0, len(messages)),
|
||||
}
|
||||
|
||||
for _, r := range messages {
|
||||
result.Messages = append(result.Messages, makeMinimumChatMessage(r))
|
||||
}
|
||||
|
||||
for _, r := range members {
|
||||
result.Members = append(result.Members, makeMinimumChatMember(r))
|
||||
}
|
||||
|
||||
bs, err = marshalJSONContainingHTML(result)
|
||||
if err != nil {
|
||||
return nil, clues.Wrap(err, "serializing minimized chat")
|
||||
}
|
||||
|
||||
return io.NopCloser(bytes.NewReader(bs)), nil
|
||||
}
|
||||
|
||||
func makeMinimumChatMessage(item models.ChatMessageable) minimumChatMessage {
|
||||
var content string
|
||||
|
||||
if item.GetBody() != nil {
|
||||
content = ptr.Val(item.GetBody().GetContent())
|
||||
}
|
||||
|
||||
attachments := item.GetAttachments()
|
||||
minAttachments := make([]minimumAttachment, 0, len(attachments))
|
||||
|
||||
for _, a := range attachments {
|
||||
minAttachments = append(minAttachments, minimumAttachment{
|
||||
ID: ptr.Val(a.GetId()),
|
||||
Name: ptr.Val(a.GetName()),
|
||||
})
|
||||
}
|
||||
|
||||
var isDeleted bool
|
||||
|
||||
deletedAt, ok := ptr.ValOK(item.GetDeletedDateTime())
|
||||
isDeleted = ok && deletedAt.After(time.Time{})
|
||||
|
||||
return minimumChatMessage{
|
||||
Attachments: minAttachments,
|
||||
Content: content,
|
||||
CreatedDateTime: ptr.Val(item.GetCreatedDateTime()),
|
||||
From: api.GetChatMessageFrom(item),
|
||||
LastModifiedDateTime: ptr.Val(item.GetLastModifiedDateTime()),
|
||||
IsDeleted: isDeleted,
|
||||
}
|
||||
}
|
||||
|
||||
func makeMinimumChatMember(item models.ConversationMemberable) minimumChatMember {
|
||||
return minimumChatMember{
|
||||
Name: ptr.Val(item.GetDisplayName()),
|
||||
VisibleHistoryStartedAt: ptr.Val(item.GetVisibleHistoryStartDateTime()),
|
||||
}
|
||||
}
|
||||
|
||||
// json.Marshal will replace many markup tags (ex: "<" and ">") with their unicode
|
||||
// equivalent. In order to maintain parity with original content that contains html,
|
||||
// we have to use this alternative encoding behavior.
|
||||
// https://stackoverflow.com/questions/28595664/how-to-stop-json-marshal-from-escaping-and
|
||||
func marshalJSONContainingHTML(a any) ([]byte, error) {
|
||||
buffer := &bytes.Buffer{}
|
||||
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
|
||||
err := encoder.Encode(a)
|
||||
|
||||
return buffer.Bytes(), clues.Stack(err).OrNil()
|
||||
}
|
||||
115
src/internal/m365/collection/teamsChats/export_test.go
Normal file
115
src/internal/m365/collection/teamsChats/export_test.go
Normal file
@ -0,0 +1,115 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
dataMock "github.com/alcionai/corso/src/internal/data/mock"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/internal/version"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/export"
|
||||
"github.com/alcionai/corso/src/pkg/metrics"
|
||||
)
|
||||
|
||||
type ExportUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestExportUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &ExportUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *ExportUnitSuite) TestStreamItems() {
|
||||
makeBody := func() io.ReadCloser {
|
||||
return io.NopCloser(bytes.NewReader([]byte("{}")))
|
||||
}
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
backingColl dataMock.Collection
|
||||
expectName string
|
||||
expectErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "no errors",
|
||||
backingColl: dataMock.Collection{
|
||||
ItemData: []data.Item{
|
||||
&dataMock.Item{
|
||||
ItemID: "zim",
|
||||
Reader: makeBody(),
|
||||
},
|
||||
},
|
||||
},
|
||||
expectName: "zim.json",
|
||||
expectErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "only recoverable errors",
|
||||
backingColl: dataMock.Collection{
|
||||
ItemsRecoverableErrs: []error{
|
||||
clues.New("The knowledge... it fills me! It is neat!"),
|
||||
},
|
||||
},
|
||||
expectErr: assert.Error,
|
||||
},
|
||||
{
|
||||
name: "items and recoverable errors",
|
||||
backingColl: dataMock.Collection{
|
||||
ItemData: []data.Item{
|
||||
&dataMock.Item{
|
||||
ItemID: "gir",
|
||||
Reader: makeBody(),
|
||||
},
|
||||
},
|
||||
ItemsRecoverableErrs: []error{
|
||||
clues.New("I miss my cupcake."),
|
||||
},
|
||||
},
|
||||
expectName: "gir.json",
|
||||
expectErr: assert.Error,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
ch := make(chan export.Item)
|
||||
|
||||
go streamItems(
|
||||
ctx,
|
||||
[]data.RestoreCollection{test.backingColl},
|
||||
version.NoBackup,
|
||||
control.DefaultExportConfig(),
|
||||
ch,
|
||||
&metrics.ExportStats{})
|
||||
|
||||
var (
|
||||
itm export.Item
|
||||
err error
|
||||
)
|
||||
|
||||
for i := range ch {
|
||||
if i.Error == nil {
|
||||
itm = i
|
||||
} else {
|
||||
err = i.Error
|
||||
}
|
||||
}
|
||||
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
|
||||
assert.Equal(t, test.expectName, itm.Name, "item name")
|
||||
})
|
||||
}
|
||||
}
|
||||
73
src/internal/m365/collection/teamsChats/handlers.go
Normal file
73
src/internal/m365/collection/teamsChats/handlers.go
Normal file
@ -0,0 +1,73 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/microsoft/kiota-abstractions-go/serialization"
|
||||
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
// itemer standardizes common behavior that can be expected from all
|
||||
// items within a chats collection backup.
|
||||
type chatsItemer interface {
|
||||
serialization.Parsable
|
||||
graph.GetIDer
|
||||
graph.GetLastUpdatedDateTimer
|
||||
}
|
||||
|
||||
type backupHandler[I chatsItemer] interface {
|
||||
getContainerer[I]
|
||||
fillItemer[I]
|
||||
getItemIDser[I]
|
||||
includeItemer[I]
|
||||
canonicalPather
|
||||
}
|
||||
|
||||
// gets the container for the resource
|
||||
// within this handler set, only one container (the root)
|
||||
// is expected
|
||||
type getContainerer[I chatsItemer] interface {
|
||||
getContainer() container[I]
|
||||
}
|
||||
|
||||
// gets all item IDs in the container
|
||||
type getItemIDser[I chatsItemer] interface {
|
||||
getItemIDs(
|
||||
ctx context.Context,
|
||||
) ([]I, error)
|
||||
}
|
||||
|
||||
// fillItemer takes a complete item and extends it with data that
|
||||
// gets lazily populated during item streaming.
|
||||
type fillItemer[I chatsItemer] interface {
|
||||
fillItem(
|
||||
ctx context.Context,
|
||||
i I,
|
||||
) (I, *details.TeamsChatsInfo, error)
|
||||
}
|
||||
|
||||
// includeItemer evaluates whether the item is included
|
||||
// in the provided scope.
|
||||
type includeItemer[I chatsItemer] interface {
|
||||
includeItem(
|
||||
i I,
|
||||
scope selectors.TeamsChatsScope,
|
||||
) bool
|
||||
}
|
||||
|
||||
// canonicalPath constructs the service and category specific path for
|
||||
// the given builder. The tenantID and protectedResourceID are assumed
|
||||
// to be stored in the handler already.
|
||||
type canonicalPather interface {
|
||||
CanonicalPath() (path.Path, error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Container management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type container[I chatsItemer] struct{}
|
||||
57
src/internal/m365/collection/teamsChats/testdata/chats.go
vendored
Normal file
57
src/internal/m365/collection/teamsChats/testdata/chats.go
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
package testdata
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
)
|
||||
|
||||
func StubChats(ids ...string) []models.Chatable {
|
||||
sl := make([]models.Chatable, 0, len(ids))
|
||||
|
||||
for _, id := range ids {
|
||||
chat := models.NewChat()
|
||||
chat.SetTopic(ptr.To(id))
|
||||
chat.SetId(ptr.To(id))
|
||||
|
||||
// we should expect to get the latest message preview by default
|
||||
lastMsgPrv := models.NewChatMessageInfo()
|
||||
lastMsgPrv.SetId(ptr.To(uuid.NewString()))
|
||||
|
||||
body := models.NewItemBody()
|
||||
body.SetContent(ptr.To(id))
|
||||
lastMsgPrv.SetBody(body)
|
||||
|
||||
chat.SetLastMessagePreview(lastMsgPrv)
|
||||
|
||||
sl = append(sl, chat)
|
||||
}
|
||||
|
||||
return sl
|
||||
}
|
||||
|
||||
func StubChatMessages(ids ...string) []models.ChatMessageable {
|
||||
sl := make([]models.ChatMessageable, 0, len(ids))
|
||||
|
||||
var lastMsg models.ChatMessageable
|
||||
|
||||
for _, id := range ids {
|
||||
msg := models.NewChatMessage()
|
||||
msg.SetId(ptr.To(uuid.NewString()))
|
||||
|
||||
body := models.NewItemBody()
|
||||
body.SetContent(ptr.To(id))
|
||||
|
||||
msg.SetBody(body)
|
||||
|
||||
sl = append(sl, msg)
|
||||
lastMsg = msg
|
||||
}
|
||||
|
||||
lastMsgPrv := models.NewChatMessageInfo()
|
||||
lastMsgPrv.SetId(lastMsg.GetId())
|
||||
lastMsgPrv.SetBody(lastMsg.GetBody())
|
||||
|
||||
return sl
|
||||
}
|
||||
@ -112,7 +112,7 @@ func (ctrl *Controller) setResourceHandler(
|
||||
var rh *resourceGetter
|
||||
|
||||
switch serviceInOperation {
|
||||
case path.ExchangeService, path.OneDriveService:
|
||||
case path.ExchangeService, path.OneDriveService, path.TeamsChatsService:
|
||||
rh = &resourceGetter{
|
||||
enum: resource.Users,
|
||||
getter: ctrl.AC.Users(),
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/drive"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/exchange"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/groups"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/teamschats"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/store"
|
||||
@ -34,6 +35,8 @@ func (ctrl *Controller) DeserializeMetadataFiles(
|
||||
return drive.DeserializeMetadataFiles(ctx, colls, count.New())
|
||||
case path.GroupsService, path.GroupsMetadataService:
|
||||
return groups.DeserializeMetadataFiles(ctx, colls)
|
||||
case path.TeamsChatsService, path.TeamsChatsMetadataService:
|
||||
return teamschats.DeserializeMetadataFiles(ctx, colls)
|
||||
default:
|
||||
return nil, clues.NewWC(ctx, "unrecognized service").With("service", service)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"github.com/alcionai/corso/src/internal/m365/service/groups"
|
||||
"github.com/alcionai/corso/src/internal/m365/service/onedrive"
|
||||
"github.com/alcionai/corso/src/internal/m365/service/sharepoint"
|
||||
"github.com/alcionai/corso/src/internal/m365/service/teamschats"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
)
|
||||
@ -30,6 +31,9 @@ func (ctrl *Controller) NewServiceHandler(
|
||||
|
||||
case path.ExchangeService:
|
||||
return exchange.NewExchangeHandler(ctrl.AC, ctrl.resourceHandler), nil
|
||||
|
||||
case path.TeamsChatsService:
|
||||
return teamschats.NewTeamsChatsHandler(ctrl.AC, ctrl.resourceHandler), nil
|
||||
}
|
||||
|
||||
return nil, clues.New("unrecognized service").
|
||||
|
||||
@ -19,9 +19,17 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type exchangeBackup struct{}
|
||||
|
||||
// NewBackup provides a struct that matches standard apis
|
||||
// across m365/service handlers.
|
||||
func NewBackup() *exchangeBackup {
|
||||
return &exchangeBackup{}
|
||||
}
|
||||
|
||||
// ProduceBackupCollections returns a DataCollection which the caller can
|
||||
// use to read mailbox data out for the specified user
|
||||
func ProduceBackupCollections(
|
||||
func (exchangeBackup) ProduceBackupCollections(
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
ac api.Client,
|
||||
|
||||
@ -33,7 +33,15 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
func ProduceBackupCollections(
|
||||
type groupsBackup struct{}
|
||||
|
||||
// NewBackup provides a struct that matches standard apis
|
||||
// across m365/service handlers.
|
||||
func NewBackup() *groupsBackup {
|
||||
return &groupsBackup{}
|
||||
}
|
||||
|
||||
func (groupsBackup) ProduceBackupCollections(
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
ac api.Client,
|
||||
@ -41,10 +49,10 @@ func ProduceBackupCollections(
|
||||
su support.StatusUpdater,
|
||||
counter *count.Bus,
|
||||
errs *fault.Bus,
|
||||
) ([]data.BackupCollection, *prefixmatcher.StringSetMatcher, error) {
|
||||
) ([]data.BackupCollection, *prefixmatcher.StringSetMatcher, bool, error) {
|
||||
b, err := bpc.Selector.ToGroupsBackup()
|
||||
if err != nil {
|
||||
return nil, nil, clues.Wrap(err, "groupsDataCollection: parsing selector")
|
||||
return nil, nil, true, clues.Wrap(err, "groupsDataCollection: parsing selector")
|
||||
}
|
||||
|
||||
var (
|
||||
@ -65,7 +73,7 @@ func ProduceBackupCollections(
|
||||
bpc.ProtectedResource.ID(),
|
||||
api.CallConfig{})
|
||||
if err != nil {
|
||||
return nil, nil, clues.WrapWC(ctx, err, "getting group")
|
||||
return nil, nil, true, clues.WrapWC(ctx, err, "getting group")
|
||||
}
|
||||
|
||||
bc := backupCommon{ac, bpc, creds, group, sitesPreviousPaths, su}
|
||||
@ -128,7 +136,7 @@ func ProduceBackupCollections(
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, true, err
|
||||
}
|
||||
|
||||
collections = append(collections, baseCols...)
|
||||
@ -142,7 +150,7 @@ func ProduceBackupCollections(
|
||||
su,
|
||||
counter)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, true, err
|
||||
}
|
||||
|
||||
collections = append(collections, md)
|
||||
@ -151,7 +159,7 @@ func ProduceBackupCollections(
|
||||
|
||||
logger.Ctx(ctx).Infow("produced collections", "stats", counter.Values())
|
||||
|
||||
return collections, globalItemIDExclusions.ToReader(), el.Failure()
|
||||
return collections, globalItemIDExclusions.ToReader(), true, el.Failure()
|
||||
}
|
||||
|
||||
type backupCommon struct {
|
||||
@ -209,9 +217,17 @@ func backupLibraries(
|
||||
Selector: bc.producerConfig.Selector,
|
||||
MetadataCollections: siteMetadataCollection[ptr.Val(s.GetId())],
|
||||
}
|
||||
groupQP = graph.QueryParams{
|
||||
ProtectedResource: bc.producerConfig.ProtectedResource,
|
||||
TenantID: bc.creds.AzureTenantID,
|
||||
}
|
||||
siteQP = graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(ptr.Val(s.GetId()), ptr.Val(s.GetWebUrl())),
|
||||
TenantID: bc.creds.AzureTenantID,
|
||||
}
|
||||
bh = drive.NewGroupBackupHandler(
|
||||
bc.producerConfig.ProtectedResource.ID(),
|
||||
ptr.Val(s.GetId()),
|
||||
groupQP,
|
||||
siteQP,
|
||||
bc.apiCli.Drives(),
|
||||
scope)
|
||||
)
|
||||
@ -221,7 +237,7 @@ func backupLibraries(
|
||||
"site_id", ptr.Val(s.GetId()),
|
||||
"site_weburl", graph.LoggableURL(ptr.Val(s.GetWebUrl())))
|
||||
|
||||
sp, err := bh.SitePathPrefix(bc.creds.AzureTenantID)
|
||||
sp, err := bh.SitePathPrefix()
|
||||
if err != nil {
|
||||
return nil, clues.WrapWC(ictx, err, "getting site path").Label(count.BadPathPrefix)
|
||||
}
|
||||
@ -262,6 +278,10 @@ func backupChannels(
|
||||
var (
|
||||
colls []data.BackupCollection
|
||||
canUsePreviousBackup bool
|
||||
qp = graph.QueryParams{
|
||||
ProtectedResource: bc.producerConfig.ProtectedResource,
|
||||
TenantID: bc.creds.AzureTenantID,
|
||||
}
|
||||
)
|
||||
|
||||
progressMessage := observe.MessageWithCompletion(
|
||||
@ -278,9 +298,7 @@ func backupChannels(
|
||||
return colls, nil
|
||||
}
|
||||
|
||||
bh := groups.NewChannelBackupHandler(
|
||||
bc.producerConfig.ProtectedResource.ID(),
|
||||
bc.apiCli.Channels())
|
||||
bh := groups.NewChannelBackupHandler(qp, bc.apiCli.Channels())
|
||||
|
||||
// Always disable lazy reader for channels until #4321 support is added
|
||||
useLazyReader := false
|
||||
@ -300,7 +318,7 @@ func backupChannels(
|
||||
}
|
||||
|
||||
if !canUsePreviousBackup {
|
||||
tp, err := bh.PathPrefix(bc.creds.AzureTenantID)
|
||||
tp, err := bh.PathPrefix()
|
||||
if err != nil {
|
||||
err = clues.WrapWC(ctx, err, "getting message path").Label(count.BadPathPrefix)
|
||||
return nil, err
|
||||
@ -320,9 +338,11 @@ func backupConversations(
|
||||
errs *fault.Bus,
|
||||
) ([]data.BackupCollection, error) {
|
||||
var (
|
||||
bh = groups.NewConversationBackupHandler(
|
||||
bc.producerConfig.ProtectedResource.ID(),
|
||||
bc.apiCli.Conversations())
|
||||
qp = graph.QueryParams{
|
||||
ProtectedResource: bc.producerConfig.ProtectedResource,
|
||||
TenantID: bc.creds.AzureTenantID,
|
||||
}
|
||||
bh = groups.NewConversationBackupHandler(qp, bc.apiCli.Conversations())
|
||||
colls []data.BackupCollection
|
||||
)
|
||||
|
||||
@ -352,7 +372,7 @@ func backupConversations(
|
||||
}
|
||||
|
||||
if !canUsePreviousBackup {
|
||||
tp, err := bh.PathPrefix(bc.creds.AzureTenantID)
|
||||
tp, err := bh.PathPrefix()
|
||||
if err != nil {
|
||||
err = clues.WrapWC(ctx, err, "getting conversation path").Label(count.BadPathPrefix)
|
||||
return nil, err
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
@ -79,7 +80,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() {
|
||||
)
|
||||
|
||||
p, err := path.Build("t", "pr", path.GroupsService, path.ChannelMessagesCategory, false, containerName)
|
||||
assert.NoError(t, err, "build path")
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
dcs := []data.RestoreCollection{
|
||||
data.FetchRestoreCollection{
|
||||
@ -106,7 +107,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() {
|
||||
dcs,
|
||||
stats,
|
||||
fault.New(true))
|
||||
assert.NoError(t, err, "export collections error")
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
assert.Len(t, ecs, 1, "num of collections")
|
||||
|
||||
assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir")
|
||||
@ -117,7 +118,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() {
|
||||
|
||||
for item := range ecs[0].Items(ctx) {
|
||||
b, err := io.ReadAll(item.Body)
|
||||
assert.NoError(t, err, clues.ToCore(err))
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// count up size for tests
|
||||
size += len(b)
|
||||
@ -181,7 +182,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() {
|
||||
false,
|
||||
odConsts.SitesPathDir,
|
||||
siteID)
|
||||
assert.NoError(t, err, "build path")
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
dcs := []data.RestoreCollection{
|
||||
data.FetchRestoreCollection{
|
||||
@ -210,7 +211,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() {
|
||||
dcs,
|
||||
stats,
|
||||
fault.New(true))
|
||||
assert.NoError(t, err, "export collections error")
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
assert.Len(t, ecs, 1, "num of collections")
|
||||
|
||||
assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir")
|
||||
@ -222,7 +223,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() {
|
||||
for item := range ecs[0].Items(ctx) {
|
||||
// unwrap the body from stats reader
|
||||
b, err := io.ReadAll(item.Body)
|
||||
assert.NoError(t, err, clues.ToCore(err))
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
size += len(b)
|
||||
bitem := io.NopCloser(bytes.NewBuffer(b))
|
||||
|
||||
@ -22,7 +22,15 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
func ProduceBackupCollections(
|
||||
type oneDriveBackup struct{}
|
||||
|
||||
// NewBackup provides a struct that matches standard apis
|
||||
// across m365/service handlers.
|
||||
func NewBackup() *oneDriveBackup {
|
||||
return &oneDriveBackup{}
|
||||
}
|
||||
|
||||
func (oneDriveBackup) ProduceBackupCollections(
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
ac api.Client,
|
||||
@ -54,8 +62,13 @@ func ProduceBackupCollections(
|
||||
|
||||
logger.Ctx(ctx).Debug("creating OneDrive collections")
|
||||
|
||||
qp := graph.QueryParams{
|
||||
ProtectedResource: bpc.ProtectedResource,
|
||||
TenantID: tenantID,
|
||||
}
|
||||
|
||||
nc := drive.NewCollections(
|
||||
drive.NewUserDriveBackupHandler(ac.Drives(), bpc.ProtectedResource.ID(), scope),
|
||||
drive.NewUserDriveBackupHandler(qp, ac.Drives(), scope),
|
||||
tenantID,
|
||||
bpc.ProtectedResource,
|
||||
su,
|
||||
|
||||
@ -71,6 +71,8 @@ type BackupHandler[T any] struct {
|
||||
GetErrs []error
|
||||
|
||||
RootFolder models.DriveItemable
|
||||
|
||||
TenantID string
|
||||
}
|
||||
|
||||
func stubRootFolder() models.DriveItemable {
|
||||
@ -106,6 +108,7 @@ func DefaultOneDriveBH(resourceOwner string) *BackupHandler[models.DriveItemable
|
||||
GetResps: []*http.Response{nil},
|
||||
GetErrs: []error{clues.New("not defined")},
|
||||
RootFolder: stubRootFolder(),
|
||||
TenantID: "tenantID",
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,6 +134,7 @@ func DefaultSharePointBH(resourceOwner string) *BackupHandler[models.DriveItemab
|
||||
GetResps: []*http.Response{nil},
|
||||
GetErrs: []error{clues.New("not defined")},
|
||||
RootFolder: stubRootFolder(),
|
||||
TenantID: "tenantID",
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,8 +148,8 @@ func DefaultDriveBHWith(
|
||||
return mbh
|
||||
}
|
||||
|
||||
func (h BackupHandler[T]) PathPrefix(tID, driveID string) (path.Path, error) {
|
||||
pp, err := h.PathPrefixFn(tID, h.ProtectedResource.ID(), driveID)
|
||||
func (h BackupHandler[T]) PathPrefix(driveID string) (path.Path, error) {
|
||||
pp, err := h.PathPrefixFn(h.TenantID, h.ProtectedResource.ID(), driveID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -153,8 +157,8 @@ func (h BackupHandler[T]) PathPrefix(tID, driveID string) (path.Path, error) {
|
||||
return pp, h.PathPrefixErr
|
||||
}
|
||||
|
||||
func (h BackupHandler[T]) MetadataPathPrefix(tID string) (path.Path, error) {
|
||||
pp, err := h.MetadataPathPrefixFn(tID, h.ProtectedResource.ID())
|
||||
func (h BackupHandler[T]) MetadataPathPrefix() (path.Path, error) {
|
||||
pp, err := h.MetadataPathPrefixFn(h.TenantID, h.ProtectedResource.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -162,8 +166,8 @@ func (h BackupHandler[T]) MetadataPathPrefix(tID string) (path.Path, error) {
|
||||
return pp, h.MetadataPathPrefixErr
|
||||
}
|
||||
|
||||
func (h BackupHandler[T]) CanonicalPath(pb *path.Builder, tID string) (path.Path, error) {
|
||||
cp, err := h.CanonPathFn(pb, tID, h.ProtectedResource.ID())
|
||||
func (h BackupHandler[T]) CanonicalPath(pb *path.Builder) (path.Path, error) {
|
||||
cp, err := h.CanonPathFn(pb, h.TenantID, h.ProtectedResource.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -175,7 +179,7 @@ func (h BackupHandler[T]) ServiceCat() (path.ServiceType, path.CategoryType) {
|
||||
return h.Service, h.Category
|
||||
}
|
||||
|
||||
func (h BackupHandler[T]) NewDrivePager(string, []string) pagers.NonDeltaHandler[models.Driveable] {
|
||||
func (h BackupHandler[T]) NewDrivePager([]string) pagers.NonDeltaHandler[models.Driveable] {
|
||||
return h.DriveItemEnumeration.DrivePager()
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,15 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
func ProduceBackupCollections(
|
||||
type sharePointBackup struct{}
|
||||
|
||||
// NewBackup provides a struct that matches standard apis
|
||||
// across m365/service handlers.
|
||||
func NewBackup() *sharePointBackup {
|
||||
return &sharePointBackup{}
|
||||
}
|
||||
|
||||
func (sharePointBackup) ProduceBackupCollections(
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
ac api.Client,
|
||||
@ -54,9 +62,14 @@ func ProduceBackupCollections(
|
||||
|
||||
var spcs []data.BackupCollection
|
||||
|
||||
qp := graph.QueryParams{
|
||||
ProtectedResource: bpc.ProtectedResource,
|
||||
TenantID: creds.AzureClientID,
|
||||
}
|
||||
|
||||
switch scope.Category().PathType() {
|
||||
case path.ListsCategory:
|
||||
bh := site.NewListsBackupHandler(bpc.ProtectedResource.ID(), ac.Lists())
|
||||
bh := site.NewListsBackupHandler(qp, ac.Lists())
|
||||
|
||||
spcs, canUsePreviousBackup, err = site.CollectLists(
|
||||
ctx,
|
||||
@ -78,8 +91,8 @@ func ProduceBackupCollections(
|
||||
ctx,
|
||||
bpc,
|
||||
drive.NewSiteBackupHandler(
|
||||
qp,
|
||||
ac.Drives(),
|
||||
bpc.ProtectedResource.ID(),
|
||||
scope,
|
||||
bpc.Selector.PathService()),
|
||||
creds.AzureTenantID,
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -54,9 +55,14 @@ func (suite *LibrariesBackupUnitSuite) TestUpdateCollections() {
|
||||
siteID = "site"
|
||||
)
|
||||
|
||||
qp := graph.QueryParams{
|
||||
ProtectedResource: idname.NewProvider(siteID, siteID),
|
||||
TenantID: tenantID,
|
||||
}
|
||||
|
||||
pb := path.Builder{}.Append(testBaseDrivePath.Elements()...)
|
||||
ep, err := drive.NewSiteBackupHandler(api.Drives{}, siteID, nil, path.SharePointService).
|
||||
CanonicalPath(pb, tenantID)
|
||||
ep, err := drive.NewSiteBackupHandler(qp, api.Drives{}, nil, path.SharePointService).
|
||||
CanonicalPath(pb)
|
||||
require.NoError(suite.T(), err, clues.ToCore(err))
|
||||
|
||||
tests := []struct {
|
||||
|
||||
172
src/internal/m365/service/teamschats/backup.go
Normal file
172
src/internal/m365/service/teamschats/backup.go
Normal file
@ -0,0 +1,172 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/common/prefixmatcher"
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/teamschats"
|
||||
"github.com/alcionai/corso/src/internal/m365/support"
|
||||
"github.com/alcionai/corso/src/internal/observe"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/pkg/account"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"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"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
type teamsChatsBackup struct{}
|
||||
|
||||
// NewBackup provides a struct that matches standard apis
|
||||
// across m365/service handlers.
|
||||
func NewBackup() *teamsChatsBackup {
|
||||
return &teamsChatsBackup{}
|
||||
}
|
||||
|
||||
func (teamsChatsBackup) ProduceBackupCollections(
|
||||
ctx context.Context,
|
||||
bpc inject.BackupProducerConfig,
|
||||
ac api.Client,
|
||||
creds account.M365Config,
|
||||
su support.StatusUpdater,
|
||||
counter *count.Bus,
|
||||
errs *fault.Bus,
|
||||
) ([]data.BackupCollection, *prefixmatcher.StringSetMatcher, bool, error) {
|
||||
b, err := bpc.Selector.ToTeamsChatsBackup()
|
||||
if err != nil {
|
||||
return nil, nil, true, clues.WrapWC(ctx, err, "parsing selector")
|
||||
}
|
||||
|
||||
var (
|
||||
el = errs.Local()
|
||||
collections = []data.BackupCollection{}
|
||||
categories = map[path.CategoryType]struct{}{}
|
||||
)
|
||||
|
||||
ctx = clues.Add(
|
||||
ctx,
|
||||
"user_id", clues.Hide(bpc.ProtectedResource.ID()),
|
||||
"user_name", clues.Hide(bpc.ProtectedResource.Name()))
|
||||
|
||||
bc := backupCommon{
|
||||
apiCli: ac,
|
||||
producerConfig: bpc,
|
||||
creds: creds,
|
||||
user: bpc.ProtectedResource,
|
||||
statusUpdater: su,
|
||||
}
|
||||
|
||||
for _, scope := range b.Scopes() {
|
||||
if el.Failure() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
cl := counter.Local()
|
||||
ictx := clues.AddLabelCounter(ctx, cl.PlainAdder())
|
||||
ictx = clues.Add(ictx, "category", scope.Category().PathType())
|
||||
|
||||
var colls []data.BackupCollection
|
||||
|
||||
switch scope.Category().PathType() {
|
||||
case path.ChatsCategory:
|
||||
colls, err = backupChats(
|
||||
ictx,
|
||||
bc,
|
||||
scope,
|
||||
cl,
|
||||
el)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
el.AddRecoverable(ctx, clues.Stack(err))
|
||||
continue
|
||||
}
|
||||
|
||||
collections = append(collections, colls...)
|
||||
|
||||
categories[scope.Category().PathType()] = struct{}{}
|
||||
}
|
||||
|
||||
if len(collections) > 0 {
|
||||
baseCols, err := graph.BaseCollections(
|
||||
ctx,
|
||||
collections,
|
||||
creds.AzureTenantID,
|
||||
bpc.ProtectedResource.ID(),
|
||||
path.TeamsChatsService,
|
||||
categories,
|
||||
su,
|
||||
counter,
|
||||
errs)
|
||||
if err != nil {
|
||||
return nil, nil, true, err
|
||||
}
|
||||
|
||||
collections = append(collections, baseCols...)
|
||||
}
|
||||
|
||||
counter.Add(count.Collections, int64(len(collections)))
|
||||
|
||||
logger.Ctx(ctx).Infow("produced collections", "stats", counter.Values())
|
||||
|
||||
return collections, nil, true, clues.Stack(el.Failure()).OrNil()
|
||||
}
|
||||
|
||||
type backupCommon struct {
|
||||
apiCli api.Client
|
||||
producerConfig inject.BackupProducerConfig
|
||||
creds account.M365Config
|
||||
user idname.Provider
|
||||
statusUpdater support.StatusUpdater
|
||||
}
|
||||
|
||||
func backupChats(
|
||||
ctx context.Context,
|
||||
bc backupCommon,
|
||||
scope selectors.TeamsChatsScope,
|
||||
counter *count.Bus,
|
||||
errs *fault.Bus,
|
||||
) ([]data.BackupCollection, error) {
|
||||
var colls []data.BackupCollection
|
||||
|
||||
progressMessage := observe.MessageWithCompletion(
|
||||
ctx,
|
||||
observe.ProgressCfg{
|
||||
Indent: 1,
|
||||
CompletionMessage: func() string { return "(done)" },
|
||||
},
|
||||
scope.Category().PathType().HumanString())
|
||||
defer close(progressMessage)
|
||||
|
||||
qp := graph.QueryParams{
|
||||
ProtectedResource: bc.producerConfig.ProtectedResource,
|
||||
TenantID: bc.creds.AzureTenantID,
|
||||
}
|
||||
|
||||
bh := teamschats.NewUsersChatsBackupHandler(
|
||||
qp,
|
||||
bc.apiCli.Chats())
|
||||
|
||||
// Always disable lazy reader for channels until #4321 support is added
|
||||
useLazyReader := false
|
||||
|
||||
colls, _, err := teamschats.CreateCollections(
|
||||
ctx,
|
||||
bc.producerConfig,
|
||||
bh,
|
||||
bc.creds.AzureTenantID,
|
||||
scope,
|
||||
bc.statusUpdater,
|
||||
useLazyReader,
|
||||
counter,
|
||||
errs)
|
||||
|
||||
return colls, clues.Stack(err).OrNil()
|
||||
}
|
||||
18
src/internal/m365/service/teamschats/enabled.go
Normal file
18
src/internal/m365/service/teamschats/enabled.go
Normal file
@ -0,0 +1,18 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
)
|
||||
|
||||
func IsServiceEnabled(
|
||||
ctx context.Context,
|
||||
gbi api.GetByIDer[models.Userable],
|
||||
resource string,
|
||||
) (bool, error) {
|
||||
// TODO(rkeepers): investgate service enablement checks
|
||||
return true, nil
|
||||
}
|
||||
69
src/internal/m365/service/teamschats/enabled_test.go
Normal file
69
src/internal/m365/service/teamschats/enabled_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
)
|
||||
|
||||
type EnabledUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestEnabledUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &EnabledUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
var _ api.GetByIDer[models.Userable] = mockGU{}
|
||||
|
||||
type mockGU struct {
|
||||
user models.Userable
|
||||
err error
|
||||
}
|
||||
|
||||
func (m mockGU) GetByID(
|
||||
ctx context.Context,
|
||||
identifier string,
|
||||
_ api.CallConfig,
|
||||
) (models.Userable, error) {
|
||||
return m.user, m.err
|
||||
}
|
||||
|
||||
func (suite *EnabledUnitSuite) TestIsServiceEnabled() {
|
||||
table := []struct {
|
||||
name string
|
||||
mock func(context.Context) api.GetByIDer[models.Userable]
|
||||
expect assert.BoolAssertionFunc
|
||||
expectErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "ok",
|
||||
mock: func(ctx context.Context) api.GetByIDer[models.Userable] {
|
||||
return mockGU{}
|
||||
},
|
||||
expect: assert.True,
|
||||
expectErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
gu := test.mock(ctx)
|
||||
|
||||
ok, err := IsServiceEnabled(ctx, gu, "resource_id")
|
||||
test.expect(t, ok, "has mailbox flag")
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
119
src/internal/m365/service/teamschats/export.go
Normal file
119
src/internal/m365/service/teamschats/export.go
Normal file
@ -0,0 +1,119 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/idname"
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/teamschats"
|
||||
"github.com/alcionai/corso/src/internal/m365/resource"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/export"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/metrics"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
)
|
||||
|
||||
var _ inject.ServiceHandler = &teamsChatsHandler{}
|
||||
|
||||
func NewTeamsChatsHandler(
|
||||
apiClient api.Client,
|
||||
resourceGetter idname.GetResourceIDAndNamer,
|
||||
) *teamsChatsHandler {
|
||||
return &teamsChatsHandler{
|
||||
baseTeamsChatsHandler: baseTeamsChatsHandler{},
|
||||
apiClient: apiClient,
|
||||
resourceGetter: resourceGetter,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================== //
|
||||
// baseTeamsChatsHandler
|
||||
// ========================================================================== //
|
||||
|
||||
// baseTeamsChatsHandler contains logic for tracking data and doing operations
|
||||
// (e.x. export) that don't require contact with external M356 services.
|
||||
type baseTeamsChatsHandler struct{}
|
||||
|
||||
func (h *baseTeamsChatsHandler) CacheItemInfo(v details.ItemInfo) {}
|
||||
|
||||
// ProduceExportCollections will create the export collections for the
|
||||
// given restore collections.
|
||||
func (h *baseTeamsChatsHandler) ProduceExportCollections(
|
||||
ctx context.Context,
|
||||
backupVersion int,
|
||||
exportCfg control.ExportConfig,
|
||||
dcs []data.RestoreCollection,
|
||||
stats *metrics.ExportStats,
|
||||
errs *fault.Bus,
|
||||
) ([]export.Collectioner, error) {
|
||||
var (
|
||||
el = errs.Local()
|
||||
ec = make([]export.Collectioner, 0, len(dcs))
|
||||
)
|
||||
|
||||
for _, dc := range dcs {
|
||||
category := dc.FullPath().Category()
|
||||
|
||||
switch category {
|
||||
case path.ChatsCategory:
|
||||
folders := dc.FullPath().Folders()
|
||||
pth := path.Builder{}.Append(category.HumanString()).Append(folders...)
|
||||
|
||||
ec = append(
|
||||
ec,
|
||||
teamschats.NewExportCollection(
|
||||
pth.String(),
|
||||
[]data.RestoreCollection{dc},
|
||||
backupVersion,
|
||||
exportCfg,
|
||||
stats))
|
||||
default:
|
||||
return nil, clues.NewWC(ctx, "data category not supported").
|
||||
With("category", category)
|
||||
}
|
||||
}
|
||||
|
||||
return ec, el.Failure()
|
||||
}
|
||||
|
||||
// ========================================================================== //
|
||||
// teamschatsHandler
|
||||
// ========================================================================== //
|
||||
|
||||
// teamsChatsHandler contains logic for handling data and performing operations
|
||||
// (e.x. restore) regardless of whether they require contact with external M365
|
||||
// services or not.
|
||||
type teamsChatsHandler struct {
|
||||
baseTeamsChatsHandler
|
||||
apiClient api.Client
|
||||
resourceGetter idname.GetResourceIDAndNamer
|
||||
}
|
||||
|
||||
func (h *teamsChatsHandler) IsServiceEnabled(
|
||||
ctx context.Context,
|
||||
resourceID string,
|
||||
) (bool, error) {
|
||||
// TODO(ashmrtn): Move free function implementation to this function.
|
||||
res, err := IsServiceEnabled(ctx, h.apiClient.Users(), resourceID)
|
||||
return res, clues.Stack(err).OrNil()
|
||||
}
|
||||
|
||||
func (h *teamsChatsHandler) PopulateProtectedResourceIDAndName(
|
||||
ctx context.Context,
|
||||
resourceID string, // Can be either ID or name.
|
||||
ins idname.Cacher,
|
||||
) (idname.Provider, error) {
|
||||
if h.resourceGetter == nil {
|
||||
return nil, clues.StackWC(ctx, resource.ErrNoResourceLookup)
|
||||
}
|
||||
|
||||
pr, err := h.resourceGetter.GetResourceIDAndNameFrom(ctx, resourceID, ins)
|
||||
|
||||
return pr, clues.Wrap(err, "identifying resource owner").OrNil()
|
||||
}
|
||||
140
src/internal/m365/service/teamschats/export_test.go
Normal file
140
src/internal/m365/service/teamschats/export_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
dataMock "github.com/alcionai/corso/src/internal/data/mock"
|
||||
teamschatMock "github.com/alcionai/corso/src/internal/m365/service/teamschats/mock"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/internal/version"
|
||||
"github.com/alcionai/corso/src/pkg/control"
|
||||
"github.com/alcionai/corso/src/pkg/export"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/metrics"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
)
|
||||
|
||||
type ExportUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestExportUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &ExportUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
type finD struct {
|
||||
id string
|
||||
key string
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
func (fd finD) FetchItemByName(ctx context.Context, name string) (data.Item, error) {
|
||||
if fd.err != nil {
|
||||
return nil, fd.err
|
||||
}
|
||||
|
||||
if name == fd.id {
|
||||
return &dataMock.Item{
|
||||
ItemID: fd.id,
|
||||
Reader: io.NopCloser(bytes.NewBufferString(`{"` + fd.key + `": "` + fd.name + `"}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
func (suite *ExportUnitSuite) TestExportRestoreCollections_chats() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
var (
|
||||
category = path.ChatsCategory
|
||||
itemID = "itemID"
|
||||
dii = teamschatMock.ItemInfo()
|
||||
content = `{"topic": "` + dii.TeamsChats.Chat.Topic + `"}`
|
||||
body = io.NopCloser(bytes.NewBufferString(content))
|
||||
exportCfg = control.ExportConfig{}
|
||||
expectedPath = category.HumanString()
|
||||
expectedItems = []export.Item{
|
||||
{
|
||||
ID: itemID,
|
||||
Name: itemID + ".json",
|
||||
// Body: body, not checked
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
p, err := path.BuildPrefix("t", "pr", path.TeamsChatsService, category)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
dcs := []data.RestoreCollection{
|
||||
data.FetchRestoreCollection{
|
||||
Collection: dataMock.Collection{
|
||||
Path: p,
|
||||
ItemData: []data.Item{
|
||||
&dataMock.Item{
|
||||
ItemID: itemID,
|
||||
Reader: body,
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchItemByNamer: finD{
|
||||
id: itemID,
|
||||
key: "id",
|
||||
name: itemID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stats := metrics.NewExportStats()
|
||||
|
||||
ecs, err := NewTeamsChatsHandler(api.Client{}, nil).
|
||||
ProduceExportCollections(
|
||||
ctx,
|
||||
int(version.Backup),
|
||||
exportCfg,
|
||||
dcs,
|
||||
stats,
|
||||
fault.New(true))
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
assert.Len(t, ecs, 1, "num of collections")
|
||||
|
||||
assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir")
|
||||
|
||||
fitems := []export.Item{}
|
||||
|
||||
size := 0
|
||||
|
||||
for item := range ecs[0].Items(ctx) {
|
||||
b, err := io.ReadAll(item.Body)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// count up size for tests
|
||||
size += len(b)
|
||||
|
||||
// have to nil out body, otherwise assert fails due to
|
||||
// pointer memory location differences
|
||||
item.Body = nil
|
||||
fitems = append(fitems, item)
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedItems, fitems, "items")
|
||||
|
||||
expectedStats := metrics.NewExportStats()
|
||||
expectedStats.UpdateBytes(category, int64(size))
|
||||
expectedStats.UpdateResourceCount(category)
|
||||
assert.Equal(t, expectedStats.GetStats(), stats.GetStats(), "stats")
|
||||
}
|
||||
14
src/internal/m365/service/teamschats/mock/mock.go
Normal file
14
src/internal/m365/service/teamschats/mock/mock.go
Normal file
@ -0,0 +1,14 @@
|
||||
package stub
|
||||
|
||||
import (
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
)
|
||||
|
||||
func ItemInfo() details.ItemInfo {
|
||||
return details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Chat: details.ChatInfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
100
src/internal/m365/service/teamschats/restore.go
Normal file
100
src/internal/m365/service/teamschats/restore.go
Normal file
@ -0,0 +1,100 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/m365/support"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/count"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/logger"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
// ConsumeRestoreCollections will restore the specified data collections
|
||||
func (h *teamsChatsHandler) ConsumeRestoreCollections(
|
||||
ctx context.Context,
|
||||
rcc inject.RestoreConsumerConfig,
|
||||
dcs []data.RestoreCollection,
|
||||
errs *fault.Bus,
|
||||
ctr *count.Bus,
|
||||
) (*details.Details, *data.CollectionStats, error) {
|
||||
if len(dcs) == 0 {
|
||||
return nil, nil, clues.WrapWC(ctx, data.ErrNoData, "performing restore")
|
||||
}
|
||||
|
||||
// TODO(ashmrtn): We should stop relying on the context for rate limiter stuff
|
||||
// and instead configure this when we make the handler instance. We can't
|
||||
// initialize it in the NewHandler call right now because those functions
|
||||
// aren't (and shouldn't be) returning a context along with the handler. Since
|
||||
// that call isn't directly calling into this function even if we did
|
||||
// initialize the rate limiter there it would be lost because it wouldn't get
|
||||
// stored in an ancestor of the context passed to this function.
|
||||
ctx = graph.BindRateLimiterConfig(
|
||||
ctx,
|
||||
graph.LimiterCfg{Service: path.TeamsChatsService})
|
||||
|
||||
var (
|
||||
deets = &details.Builder{}
|
||||
restoreMetrics support.CollectionMetrics
|
||||
el = errs.Local()
|
||||
)
|
||||
|
||||
// Reorder collections so that the parents directories are created
|
||||
// before the child directories; a requirement for permissions.
|
||||
data.SortRestoreCollections(dcs)
|
||||
|
||||
// Iterate through the data collections and restore the contents of each
|
||||
for _, dc := range dcs {
|
||||
if el.Failure() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
category = dc.FullPath().Category()
|
||||
metrics support.CollectionMetrics
|
||||
ictx = clues.Add(ctx,
|
||||
"category", category,
|
||||
"restore_location", clues.Hide(rcc.RestoreConfig.Location),
|
||||
"protected_resource", clues.Hide(dc.FullPath().ProtectedResource()),
|
||||
"full_path", dc.FullPath())
|
||||
)
|
||||
|
||||
switch dc.FullPath().Category() {
|
||||
case path.ChatsCategory:
|
||||
// chats cannot be restored using Graph API.
|
||||
// a delegated token is required, and Corso has no
|
||||
// good way of obtaining such a token.
|
||||
logger.Ctx(ictx).Debug("Skipping restore for channel messages")
|
||||
default:
|
||||
return nil, nil, clues.NewWC(ictx, "data category not supported").
|
||||
With("category", category)
|
||||
}
|
||||
|
||||
restoreMetrics = support.CombineMetrics(restoreMetrics, metrics)
|
||||
|
||||
if err != nil {
|
||||
el.AddRecoverable(ictx, err)
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
status := support.CreateStatus(
|
||||
ctx,
|
||||
support.Restore,
|
||||
len(dcs),
|
||||
restoreMetrics,
|
||||
rcc.RestoreConfig.Location)
|
||||
|
||||
return deets.Details(), status.ToCollectionStats(), el.Failure()
|
||||
}
|
||||
54
src/internal/m365/service/teamschats/restore_test.go
Normal file
54
src/internal/m365/service/teamschats/restore_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package teamschats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/data"
|
||||
"github.com/alcionai/corso/src/internal/data/mock"
|
||||
"github.com/alcionai/corso/src/internal/operations/inject"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||
)
|
||||
|
||||
type RestoreUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestRestoreUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &RestoreUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *RestoreUnitSuite) TestConsumeRestoreCollections_noErrorOnChats() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
rcc := inject.RestoreConsumerConfig{}
|
||||
pth, err := path.BuildPrefix(
|
||||
"t",
|
||||
"pr",
|
||||
path.TeamsChatsService,
|
||||
path.ChatsCategory)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
dcs := []data.RestoreCollection{
|
||||
mock.Collection{Path: pth},
|
||||
}
|
||||
|
||||
_, _, err = NewTeamsChatsHandler(api.Client{}, nil).
|
||||
ConsumeRestoreCollections(
|
||||
ctx,
|
||||
rcc,
|
||||
dcs,
|
||||
fault.New(false),
|
||||
nil)
|
||||
assert.NoError(t, err, "Chats restore")
|
||||
}
|
||||
@ -142,6 +142,7 @@ func makeRestorePathsForEntry(
|
||||
// * OneDrive/SharePoint (needs drive information)
|
||||
switch true {
|
||||
case ent.Exchange != nil ||
|
||||
ent.TeamsChats != nil ||
|
||||
(ent.Groups != nil && ent.Groups.ItemType == details.GroupsChannelMessage) ||
|
||||
(ent.SharePoint != nil && ent.SharePoint.ItemType == details.SharePointList):
|
||||
// TODO(ashmrtn): Eventually make Events have it's own function to handle
|
||||
|
||||
@ -399,6 +399,30 @@ func (suite *RestorePathTransformerUnitSuite) TestGetPaths() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TeamsChats Chats",
|
||||
backupVersion: version.Groups9Update,
|
||||
input: []*details.Entry{
|
||||
{
|
||||
RepoRef: testdata.ExchangeEmailItemPath3.RR.String(),
|
||||
LocationRef: testdata.ExchangeEmailItemPath3.Loc.String(),
|
||||
ItemInfo: details.ItemInfo{
|
||||
Exchange: &details.ExchangeInfo{
|
||||
ItemType: details.ExchangeMail,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErr: assert.NoError,
|
||||
expected: []expectPaths{
|
||||
{
|
||||
storage: testdata.ExchangeEmailItemPath3.RR.String(),
|
||||
restore: toRestore(
|
||||
testdata.ExchangeEmailItemPath3.RR,
|
||||
testdata.ExchangeEmailItemPath3.Loc.Elements()...),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
|
||||
@ -103,6 +103,12 @@ func (de Entry) ToLocationIDer(backupVersion int) (LocationIDer, error) {
|
||||
}
|
||||
|
||||
baseLoc = path.Builder{}.Append(p.Root).Append(p.Folders...)
|
||||
|
||||
case TeamsChat:
|
||||
baseLoc = &path.Builder{}
|
||||
|
||||
default:
|
||||
return nil, clues.New("undentified item type").With("item_type", de.ItemInfo.infoType())
|
||||
}
|
||||
|
||||
if baseLoc == nil {
|
||||
@ -141,26 +147,23 @@ func (de Entry) MinimumPrintable() any {
|
||||
// Headers returns the human-readable names of properties in a DetailsEntry
|
||||
// for printing out to a terminal in a columnar display.
|
||||
func (de Entry) Headers(skipID bool) []string {
|
||||
hs := []string{}
|
||||
var hs []string
|
||||
|
||||
if de.ItemInfo.Folder != nil {
|
||||
switch {
|
||||
case de.ItemInfo.Folder != nil:
|
||||
hs = de.ItemInfo.Folder.Headers()
|
||||
}
|
||||
|
||||
if de.ItemInfo.Exchange != nil {
|
||||
case de.ItemInfo.Exchange != nil:
|
||||
hs = de.ItemInfo.Exchange.Headers()
|
||||
}
|
||||
|
||||
if de.ItemInfo.SharePoint != nil {
|
||||
case de.ItemInfo.SharePoint != nil:
|
||||
hs = de.ItemInfo.SharePoint.Headers()
|
||||
}
|
||||
|
||||
if de.ItemInfo.OneDrive != nil {
|
||||
case de.ItemInfo.OneDrive != nil:
|
||||
hs = de.ItemInfo.OneDrive.Headers()
|
||||
}
|
||||
|
||||
if de.ItemInfo.Groups != nil {
|
||||
case de.ItemInfo.Groups != nil:
|
||||
hs = de.ItemInfo.Groups.Headers()
|
||||
case de.ItemInfo.TeamsChats != nil:
|
||||
hs = de.ItemInfo.TeamsChats.Headers()
|
||||
default:
|
||||
hs = []string{"ERROR - Service not recognized"}
|
||||
}
|
||||
|
||||
if skipID {
|
||||
@ -172,26 +175,23 @@ func (de Entry) Headers(skipID bool) []string {
|
||||
|
||||
// Values returns the values matching the Headers list.
|
||||
func (de Entry) Values(skipID bool) []string {
|
||||
vs := []string{}
|
||||
var vs []string
|
||||
|
||||
if de.ItemInfo.Folder != nil {
|
||||
switch {
|
||||
case de.ItemInfo.Folder != nil:
|
||||
vs = de.ItemInfo.Folder.Values()
|
||||
}
|
||||
|
||||
if de.ItemInfo.Exchange != nil {
|
||||
case de.ItemInfo.Exchange != nil:
|
||||
vs = de.ItemInfo.Exchange.Values()
|
||||
}
|
||||
|
||||
if de.ItemInfo.SharePoint != nil {
|
||||
case de.ItemInfo.SharePoint != nil:
|
||||
vs = de.ItemInfo.SharePoint.Values()
|
||||
}
|
||||
|
||||
if de.ItemInfo.OneDrive != nil {
|
||||
case de.ItemInfo.OneDrive != nil:
|
||||
vs = de.ItemInfo.OneDrive.Values()
|
||||
}
|
||||
|
||||
if de.ItemInfo.Groups != nil {
|
||||
case de.ItemInfo.Groups != nil:
|
||||
vs = de.ItemInfo.Groups.Values()
|
||||
case de.ItemInfo.TeamsChats != nil:
|
||||
vs = de.ItemInfo.TeamsChats.Values()
|
||||
default:
|
||||
vs = []string{"ERROR - Service not recognized"}
|
||||
}
|
||||
|
||||
if skipID {
|
||||
|
||||
@ -78,7 +78,7 @@ type ChannelMessageInfo struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// Headers returns the human-readable names of properties in a SharePointInfo
|
||||
// Headers returns the human-readable names of properties in a gropusInfo
|
||||
// for printing out to a terminal in a columnar display.
|
||||
func (i GroupsInfo) Headers() []string {
|
||||
switch i.ItemType {
|
||||
|
||||
@ -41,6 +41,9 @@ const (
|
||||
// Groups/Teams(40x)
|
||||
GroupsChannelMessage ItemType = 401
|
||||
GroupsConversationPost ItemType = 402
|
||||
|
||||
// Teams Chat
|
||||
TeamsChat ItemType = 501
|
||||
)
|
||||
|
||||
func UpdateItem(item *ItemInfo, newLocPath *path.Builder) {
|
||||
@ -73,6 +76,7 @@ type ItemInfo struct {
|
||||
SharePoint *SharePointInfo `json:"sharePoint,omitempty"`
|
||||
OneDrive *OneDriveInfo `json:"oneDrive,omitempty"`
|
||||
Groups *GroupsInfo `json:"groups,omitempty"`
|
||||
TeamsChats *TeamsChatsInfo `json:"teamsChats,omitempty"`
|
||||
// Optional item extension data
|
||||
Extension *ExtensionData `json:"extension,omitempty"`
|
||||
}
|
||||
@ -87,18 +91,16 @@ func (i ItemInfo) infoType() ItemType {
|
||||
switch {
|
||||
case i.Folder != nil:
|
||||
return i.Folder.ItemType
|
||||
|
||||
case i.Exchange != nil:
|
||||
return i.Exchange.ItemType
|
||||
|
||||
case i.SharePoint != nil:
|
||||
return i.SharePoint.ItemType
|
||||
|
||||
case i.OneDrive != nil:
|
||||
return i.OneDrive.ItemType
|
||||
|
||||
case i.Groups != nil:
|
||||
return i.Groups.ItemType
|
||||
case i.TeamsChats != nil:
|
||||
return i.TeamsChats.ItemType
|
||||
}
|
||||
|
||||
return UnknownType
|
||||
@ -108,18 +110,16 @@ func (i ItemInfo) size() int64 {
|
||||
switch {
|
||||
case i.Exchange != nil:
|
||||
return i.Exchange.Size
|
||||
|
||||
case i.OneDrive != nil:
|
||||
return i.OneDrive.Size
|
||||
|
||||
case i.SharePoint != nil:
|
||||
return i.SharePoint.Size
|
||||
|
||||
case i.Groups != nil:
|
||||
return i.Groups.Size
|
||||
|
||||
case i.Folder != nil:
|
||||
return i.Folder.Size
|
||||
case i.TeamsChats != nil:
|
||||
return int64(i.TeamsChats.Chat.MessageCount)
|
||||
}
|
||||
|
||||
return 0
|
||||
@ -129,18 +129,16 @@ func (i ItemInfo) Modified() time.Time {
|
||||
switch {
|
||||
case i.Exchange != nil:
|
||||
return i.Exchange.Modified
|
||||
|
||||
case i.OneDrive != nil:
|
||||
return i.OneDrive.Modified
|
||||
|
||||
case i.SharePoint != nil:
|
||||
return i.SharePoint.Modified
|
||||
|
||||
case i.Groups != nil:
|
||||
return i.Groups.Modified
|
||||
|
||||
case i.Folder != nil:
|
||||
return i.Folder.Modified
|
||||
case i.TeamsChats != nil:
|
||||
return i.TeamsChats.Modified
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
@ -150,16 +148,14 @@ func (i ItemInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) {
|
||||
switch {
|
||||
case i.Exchange != nil:
|
||||
return i.Exchange.uniqueLocation(baseLoc)
|
||||
|
||||
case i.OneDrive != nil:
|
||||
return i.OneDrive.uniqueLocation(baseLoc)
|
||||
|
||||
case i.SharePoint != nil:
|
||||
return i.SharePoint.uniqueLocation(baseLoc)
|
||||
|
||||
case i.Groups != nil:
|
||||
return i.Groups.uniqueLocation(baseLoc)
|
||||
|
||||
case i.TeamsChats != nil:
|
||||
return i.TeamsChats.uniqueLocation(baseLoc)
|
||||
default:
|
||||
return nil, clues.New("unsupported type")
|
||||
}
|
||||
@ -169,16 +165,14 @@ func (i ItemInfo) updateFolder(f *FolderInfo) error {
|
||||
switch {
|
||||
case i.Exchange != nil:
|
||||
return i.Exchange.updateFolder(f)
|
||||
|
||||
case i.OneDrive != nil:
|
||||
return i.OneDrive.updateFolder(f)
|
||||
|
||||
case i.SharePoint != nil:
|
||||
return i.SharePoint.updateFolder(f)
|
||||
|
||||
case i.Groups != nil:
|
||||
return i.Groups.updateFolder(f)
|
||||
|
||||
case i.TeamsChats != nil:
|
||||
return i.TeamsChats.updateFolder(f)
|
||||
default:
|
||||
return clues.New("unsupported type")
|
||||
}
|
||||
|
||||
@ -71,12 +71,21 @@ func (suite *ItemInfoUnitSuite) TestItemInfo_IsDriveItem() {
|
||||
{
|
||||
name: "exchange anything",
|
||||
ii: ItemInfo{
|
||||
Groups: &GroupsInfo{
|
||||
Exchange: &ExchangeInfo{
|
||||
ItemType: ExchangeMail,
|
||||
},
|
||||
},
|
||||
expect: assert.False,
|
||||
},
|
||||
{
|
||||
name: "teams chat",
|
||||
ii: ItemInfo{
|
||||
TeamsChats: &TeamsChatsInfo{
|
||||
ItemType: TeamsChat,
|
||||
},
|
||||
},
|
||||
expect: assert.False,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
|
||||
120
src/pkg/backup/details/teamsChats.go
Normal file
120
src/pkg/backup/details/teamsChats.go
Normal file
@ -0,0 +1,120 @@
|
||||
package details
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/pkg/dttm"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
)
|
||||
|
||||
// NewChatsLocationIDer builds a LocationIDer for the chats.
|
||||
func NewChatsLocationIDer(
|
||||
category path.CategoryType,
|
||||
escapedFolders ...string,
|
||||
) (uniqueLoc, error) {
|
||||
if err := path.ValidateServiceAndCategory(path.TeamsChatsService, category); err != nil {
|
||||
return uniqueLoc{}, clues.Wrap(err, "making chats LocationIDer")
|
||||
}
|
||||
|
||||
pb := path.Builder{}.Append(category.String()).Append(escapedFolders...)
|
||||
|
||||
return uniqueLoc{
|
||||
pb: pb,
|
||||
prefixElems: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TeamsChatsInfo describes a chat within teams chats.
|
||||
type TeamsChatsInfo struct {
|
||||
ItemType ItemType `json:"itemType,omitempty"`
|
||||
Modified time.Time `json:"modified,omitempty"`
|
||||
ParentPath string `json:"parentPath,omitempty"`
|
||||
|
||||
Chat ChatInfo `json:"chat,omitempty"`
|
||||
}
|
||||
|
||||
type ChatInfo struct {
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
HasExternalMembers bool `json:"hasExternalMemebers,omitempty"`
|
||||
LastMessageAt time.Time `json:"lastMessageAt,omitempty"`
|
||||
LastMessagePreview string `json:"preview,omitempty"`
|
||||
Members []string `json:"members,omitempty"`
|
||||
MessageCount int `json:"size,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
}
|
||||
|
||||
// Headers returns the human-readable names of properties in a ChatsInfo
|
||||
// for printing out to a terminal in a columnar display.
|
||||
func (i TeamsChatsInfo) Headers() []string {
|
||||
switch i.ItemType {
|
||||
case TeamsChat:
|
||||
return []string{"Topic", "Last message", "Last message at", "Message count", "Created", "Members"}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Values returns the values matching the Headers list for printing
|
||||
// out to a terminal in a columnar display.
|
||||
func (i TeamsChatsInfo) Values() []string {
|
||||
switch i.ItemType {
|
||||
case TeamsChat:
|
||||
members := ""
|
||||
icmLen := len(i.Chat.Members)
|
||||
|
||||
if icmLen > 0 {
|
||||
members = i.Chat.Members[0]
|
||||
}
|
||||
|
||||
if icmLen > 1 {
|
||||
members = fmt.Sprintf("%s, and %d more", members, icmLen-1)
|
||||
}
|
||||
|
||||
return []string{
|
||||
i.Chat.Topic,
|
||||
i.Chat.LastMessagePreview,
|
||||
dttm.FormatToTabularDisplay(i.Chat.LastMessageAt),
|
||||
strconv.Itoa(i.Chat.MessageCount),
|
||||
dttm.FormatToTabularDisplay(i.Chat.CreatedAt),
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (i *TeamsChatsInfo) UpdateParentPath(newLocPath *path.Builder) {
|
||||
i.ParentPath = newLocPath.String()
|
||||
}
|
||||
|
||||
func (i *TeamsChatsInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) {
|
||||
var category path.CategoryType
|
||||
|
||||
switch i.ItemType {
|
||||
case TeamsChat:
|
||||
category = path.ChatsCategory
|
||||
}
|
||||
|
||||
loc, err := NewChatsLocationIDer(category, baseLoc.Elements()...)
|
||||
|
||||
return &loc, err
|
||||
}
|
||||
|
||||
func (i *TeamsChatsInfo) updateFolder(f *FolderInfo) error {
|
||||
// Use a switch instead of a rather large if-statement. Just make sure it's an
|
||||
// Exchange type. If it's not return an error.
|
||||
switch i.ItemType {
|
||||
case TeamsChat:
|
||||
default:
|
||||
return clues.New("unsupported non-Chats ItemType").
|
||||
With("item_type", i.ItemType)
|
||||
}
|
||||
|
||||
f.DataType = i.ItemType
|
||||
|
||||
return nil
|
||||
}
|
||||
71
src/pkg/backup/details/teamsChats_test.go
Normal file
71
src/pkg/backup/details/teamsChats_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package details_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/dttm"
|
||||
)
|
||||
|
||||
type ChatsUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestChatsUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &ChatsUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *ChatsUnitSuite) TestChatsPrintable() {
|
||||
now := time.Now()
|
||||
then := now.Add(time.Minute)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
info details.TeamsChatsInfo
|
||||
expectHs []string
|
||||
expectVs []string
|
||||
}{
|
||||
{
|
||||
name: "channel message",
|
||||
info: details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
ParentPath: "parentpath",
|
||||
Chat: details.ChatInfo{
|
||||
CreatedAt: now,
|
||||
HasExternalMembers: true,
|
||||
LastMessageAt: then,
|
||||
LastMessagePreview: "last message preview",
|
||||
Members: []string{"foo@bar.baz", "fnords@smarf.zoomba"},
|
||||
MessageCount: 42,
|
||||
Topic: "chat name",
|
||||
},
|
||||
},
|
||||
expectHs: []string{"Name", "Last message", "Last message at", "Message count", "Created", "Members"},
|
||||
expectVs: []string{
|
||||
"chat name",
|
||||
"last message preview",
|
||||
dttm.FormatToTabularDisplay(then),
|
||||
"42",
|
||||
dttm.FormatToTabularDisplay(now),
|
||||
"foo@bar.baz, and 1 more",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
hs := test.info.Headers()
|
||||
vs := test.info.Values()
|
||||
|
||||
assert.Equal(t, len(hs), len(vs))
|
||||
assert.Equal(t, test.expectHs, hs)
|
||||
assert.Equal(t, test.expectVs, vs)
|
||||
})
|
||||
}
|
||||
}
|
||||
84
src/pkg/backup/details/testdata/testdata.go
vendored
84
src/pkg/backup/details/testdata/testdata.go
vendored
@ -16,11 +16,13 @@ import (
|
||||
// mustParsePath takes a string representing a resource path and returns a path
|
||||
// instance. Panics if the path cannot be parsed. Useful for simple variable
|
||||
// assignments.
|
||||
func mustParsePath(ref string, isItem, isSharepointList bool) path.Path {
|
||||
var p path.Path
|
||||
var err error
|
||||
func mustParsePath(ref string, isItem, allowPrefix bool) path.Path {
|
||||
var (
|
||||
p path.Path
|
||||
err error
|
||||
)
|
||||
|
||||
if isSharepointList {
|
||||
if allowPrefix {
|
||||
p, err = path.PrefixOrPathFromDataLayerPath(ref, isItem)
|
||||
} else {
|
||||
p, err = path.FromDataLayerPath(ref, isItem)
|
||||
@ -126,9 +128,9 @@ func (p repoRefAndLocRef) locationAsRepoRef() path.Path {
|
||||
return res
|
||||
}
|
||||
|
||||
func mustPathRep(ref string, isItem, isSharepointList bool) repoRefAndLocRef {
|
||||
func mustPathRep(ref string, isItem, allowPrefix bool) repoRefAndLocRef {
|
||||
res := repoRefAndLocRef{}
|
||||
tmp := mustParsePath(ref, isItem, isSharepointList)
|
||||
tmp := mustParsePath(ref, isItem, allowPrefix)
|
||||
|
||||
// Now append stuff to the RepoRef elements so we have distinct LocationRef
|
||||
// and RepoRef elements to simulate using IDs in the path instead of display
|
||||
@ -969,6 +971,68 @@ var (
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
TeamsChatsRootPath = mustPathRep("tenant-id/"+path.TeamsChatsService.String()+"/user-id/chats", false, true)
|
||||
|
||||
TeamsChatsChatItemPath1 = TeamsChatsRootPath.MustAppend(ItemName1, true)
|
||||
TeamsChatsChatItemPath2 = TeamsChatsRootPath.MustAppend(ItemName2, true)
|
||||
TeamsChatsChatItemPath3 = TeamsChatsRootPath.MustAppend(ItemName3, true)
|
||||
|
||||
teamsChatsChatItemsByVersion = map[int][]details.Entry{
|
||||
version.Groups9Update: {
|
||||
{
|
||||
RepoRef: TeamsChatsChatItemPath1.locationAsRepoRef().String(),
|
||||
ShortRef: TeamsChatsChatItemPath1.locationAsRepoRef().ShortRef(),
|
||||
ParentRef: TeamsChatsChatItemPath1.locationAsRepoRef().ToBuilder().Dir().ShortRef(),
|
||||
ItemRef: TeamsChatsChatItemPath1.ItemLocation(),
|
||||
LocationRef: "",
|
||||
ItemInfo: details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: Time4,
|
||||
ParentPath: "",
|
||||
Chat: details.ChatInfo{
|
||||
Topic: "item 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
RepoRef: TeamsChatsChatItemPath2.locationAsRepoRef().String(),
|
||||
ShortRef: TeamsChatsChatItemPath2.locationAsRepoRef().ShortRef(),
|
||||
ParentRef: TeamsChatsChatItemPath2.locationAsRepoRef().ToBuilder().Dir().ShortRef(),
|
||||
ItemRef: TeamsChatsChatItemPath2.ItemLocation(),
|
||||
LocationRef: "",
|
||||
ItemInfo: details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: Time3,
|
||||
ParentPath: "",
|
||||
Chat: details.ChatInfo{
|
||||
Topic: "item 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
RepoRef: TeamsChatsChatItemPath3.locationAsRepoRef().String(),
|
||||
ShortRef: TeamsChatsChatItemPath3.locationAsRepoRef().ShortRef(),
|
||||
ParentRef: TeamsChatsChatItemPath3.locationAsRepoRef().ToBuilder().Dir().ShortRef(),
|
||||
ItemRef: TeamsChatsChatItemPath3.ItemLocation(),
|
||||
LocationRef: "",
|
||||
ItemInfo: details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
ParentPath: "",
|
||||
Modified: Time4,
|
||||
Chat: details.ChatInfo{
|
||||
Topic: "item 3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func GetDetailsSetForVersion(t *testing.T, wantedVersion int) *details.Details {
|
||||
@ -987,6 +1051,9 @@ func GetDetailsSetForVersion(t *testing.T, wantedVersion int) *details.Details {
|
||||
path.SharePointService: {
|
||||
path.LibrariesCategory,
|
||||
},
|
||||
path.TeamsChatsService: {
|
||||
path.ChatsCategory,
|
||||
},
|
||||
}
|
||||
|
||||
for s, cats := range dataTypes {
|
||||
@ -1060,6 +1127,11 @@ func GetDeetsForVersion(
|
||||
if cat == path.LibrariesCategory {
|
||||
input = sharePointLibraryItemsByVersion
|
||||
}
|
||||
|
||||
case path.TeamsChatsService:
|
||||
if cat == path.ChatsCategory {
|
||||
input = teamsChatsChatItemsByVersion
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(
|
||||
|
||||
@ -42,6 +42,7 @@ const (
|
||||
CollectionNotMoved Key = "collection-state-not-moved"
|
||||
CollectionTombstoned Key = "collection-state-tombstoned"
|
||||
Collections Key = "collections"
|
||||
Containers Key = "containers"
|
||||
DeleteFolderMarker Key = "delete-folder-marker"
|
||||
DeleteItemMarker Key = "delete-item-marker"
|
||||
Drives Key = "drives"
|
||||
@ -66,6 +67,7 @@ const (
|
||||
Sites Key = "sites"
|
||||
Lists Key = "lists"
|
||||
SkippedContainers Key = "skipped-containers"
|
||||
SkippedItems Key = "skipped-items"
|
||||
StreamBytesAdded Key = "stream-bytes-added"
|
||||
StreamDirsAdded Key = "stream-dirs-added"
|
||||
StreamDirsFound Key = "stream-dirs-found"
|
||||
|
||||
@ -28,6 +28,7 @@ const (
|
||||
DetailsCategory CategoryType = 8 // details
|
||||
ChannelMessagesCategory CategoryType = 9 // channelMessages
|
||||
ConversationPostsCategory CategoryType = 10 // conversationPosts
|
||||
ChatsCategory CategoryType = 11 // chats
|
||||
)
|
||||
|
||||
var strToCat = map[string]CategoryType{
|
||||
@ -41,6 +42,7 @@ var strToCat = map[string]CategoryType{
|
||||
strings.ToLower(DetailsCategory.String()): DetailsCategory,
|
||||
strings.ToLower(ChannelMessagesCategory.String()): ChannelMessagesCategory,
|
||||
strings.ToLower(ConversationPostsCategory.String()): ConversationPostsCategory,
|
||||
strings.ToLower(ChatsCategory.String()): ChatsCategory,
|
||||
}
|
||||
|
||||
func ToCategoryType(s string) CategoryType {
|
||||
@ -63,6 +65,7 @@ var catToHuman = map[CategoryType]string{
|
||||
DetailsCategory: "Details",
|
||||
ChannelMessagesCategory: "Messages",
|
||||
ConversationPostsCategory: "Posts",
|
||||
ChatsCategory: "Chats",
|
||||
}
|
||||
|
||||
// HumanString produces a more human-readable string version of the category.
|
||||
@ -100,6 +103,9 @@ var serviceCategories = map[ServiceType]map[CategoryType]struct{}{
|
||||
ConversationPostsCategory: {},
|
||||
LibrariesCategory: {},
|
||||
},
|
||||
TeamsChatsService: {
|
||||
ChatsCategory: {},
|
||||
},
|
||||
}
|
||||
|
||||
func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) {
|
||||
|
||||
@ -35,6 +35,7 @@ func (suite *CategoryTypeUnitSuite) TestToCategoryType() {
|
||||
{input: "details", expect: 8},
|
||||
{input: "channelmessages", expect: 9},
|
||||
{input: "conversationposts", expect: 10},
|
||||
{input: "chats", expect: 11},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.input, func() {
|
||||
@ -62,6 +63,7 @@ func (suite *CategoryTypeUnitSuite) TestHumanString() {
|
||||
{input: 8, expect: "Details"},
|
||||
{input: 9, expect: "Messages"},
|
||||
{input: 10, expect: "Posts"},
|
||||
{input: 11, expect: "Chats"},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.input.String(), func() {
|
||||
|
||||
@ -19,11 +19,12 @@ func _() {
|
||||
_ = x[DetailsCategory-8]
|
||||
_ = x[ChannelMessagesCategory-9]
|
||||
_ = x[ConversationPostsCategory-10]
|
||||
_ = x[ChatsCategory-11]
|
||||
}
|
||||
|
||||
const _CategoryType_name = "UnknownCategoryemailcontactseventsfileslistslibrariespagesdetailschannelMessagesconversationPosts"
|
||||
const _CategoryType_name = "UnknownCategoryemailcontactseventsfileslistslibrariespagesdetailschannelMessagesconversationPostschats"
|
||||
|
||||
var _CategoryType_index = [...]uint8{0, 15, 20, 28, 34, 39, 44, 53, 58, 65, 80, 97}
|
||||
var _CategoryType_index = [...]uint8{0, 15, 20, 28, 34, 39, 44, 53, 58, 65, 80, 97, 102}
|
||||
|
||||
func (i CategoryType) String() string {
|
||||
if i < 0 || i >= CategoryType(len(_CategoryType_index)-1) {
|
||||
|
||||
@ -118,6 +118,14 @@ func (suite *ServiceCategoryUnitSuite) TestValidateServiceAndCategory() {
|
||||
expectedCategory: LibrariesCategory,
|
||||
check: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "ChatsChats",
|
||||
service: TeamsChatsService.String(),
|
||||
category: ChatsCategory.String(),
|
||||
expectedService: TeamsChatsService,
|
||||
expectedCategory: ChatsCategory,
|
||||
check: assert.NoError,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
@ -155,9 +163,10 @@ func (suite *ServiceCategoryUnitSuite) TestToServiceType() {
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
assert.Equal(t, test.expected, ToServiceType(test.service))
|
||||
assert.Equal(
|
||||
suite.T(),
|
||||
test.expected.String(),
|
||||
ToServiceType(test.service).String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -181,9 +190,10 @@ func (suite *ServiceCategoryUnitSuite) TestToCategoryType() {
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
assert.Equal(t, test.expected, ToCategoryType(test.category))
|
||||
assert.Equal(
|
||||
suite.T(),
|
||||
test.expected.String(),
|
||||
ToCategoryType(test.category).String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,39 +23,38 @@ type ServiceType int
|
||||
//go:generate stringer -type=ServiceType -linecomment
|
||||
const (
|
||||
UnknownService ServiceType = 0
|
||||
ExchangeService ServiceType = 1 // exchange
|
||||
OneDriveService ServiceType = 2 // onedrive
|
||||
SharePointService ServiceType = 3 // sharepoint
|
||||
ExchangeMetadataService ServiceType = 4 // exchangeMetadata
|
||||
OneDriveMetadataService ServiceType = 5 // onedriveMetadata
|
||||
SharePointMetadataService ServiceType = 6 // sharepointMetadata
|
||||
GroupsService ServiceType = 7 // groups
|
||||
GroupsMetadataService ServiceType = 8 // groupsMetadata
|
||||
ExchangeService ServiceType = 1 // exchange
|
||||
OneDriveService ServiceType = 2 // onedrive
|
||||
SharePointService ServiceType = 3 // sharepoint
|
||||
ExchangeMetadataService ServiceType = 4 // exchangeMetadata
|
||||
OneDriveMetadataService ServiceType = 5 // onedriveMetadata
|
||||
SharePointMetadataService ServiceType = 6 // sharepointMetadata
|
||||
GroupsService ServiceType = 7 // groups
|
||||
GroupsMetadataService ServiceType = 8 // groupsMetadata
|
||||
TeamsChatsService ServiceType = 9 // teamsChats
|
||||
TeamsChatsMetadataService ServiceType = 10 // teamsChatsMetadata
|
||||
)
|
||||
|
||||
func ToServiceType(service string) ServiceType {
|
||||
s := strings.ToLower(service)
|
||||
var strToSvc = map[string]ServiceType{
|
||||
strings.ToLower(ExchangeService.String()): ExchangeService,
|
||||
strings.ToLower(ExchangeMetadataService.String()): ExchangeMetadataService,
|
||||
strings.ToLower(OneDriveService.String()): OneDriveService,
|
||||
strings.ToLower(OneDriveMetadataService.String()): OneDriveMetadataService,
|
||||
strings.ToLower(SharePointService.String()): SharePointService,
|
||||
strings.ToLower(SharePointMetadataService.String()): SharePointMetadataService,
|
||||
strings.ToLower(GroupsService.String()): GroupsService,
|
||||
strings.ToLower(GroupsMetadataService.String()): GroupsMetadataService,
|
||||
strings.ToLower(TeamsChatsService.String()): TeamsChatsService,
|
||||
strings.ToLower(TeamsChatsMetadataService.String()): TeamsChatsMetadataService,
|
||||
}
|
||||
|
||||
switch s {
|
||||
case strings.ToLower(ExchangeService.String()):
|
||||
return ExchangeService
|
||||
case strings.ToLower(OneDriveService.String()):
|
||||
return OneDriveService
|
||||
case strings.ToLower(SharePointService.String()):
|
||||
return SharePointService
|
||||
case strings.ToLower(GroupsService.String()):
|
||||
return GroupsService
|
||||
case strings.ToLower(ExchangeMetadataService.String()):
|
||||
return ExchangeMetadataService
|
||||
case strings.ToLower(OneDriveMetadataService.String()):
|
||||
return OneDriveMetadataService
|
||||
case strings.ToLower(SharePointMetadataService.String()):
|
||||
return SharePointMetadataService
|
||||
case strings.ToLower(GroupsMetadataService.String()):
|
||||
return GroupsMetadataService
|
||||
default:
|
||||
return UnknownService
|
||||
func ToServiceType(service string) ServiceType {
|
||||
st, ok := strToSvc[strings.ToLower(service)]
|
||||
if !ok {
|
||||
st = UnknownService
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
var serviceToHuman = map[ServiceType]string{
|
||||
@ -63,6 +62,7 @@ var serviceToHuman = map[ServiceType]string{
|
||||
OneDriveService: "OneDrive",
|
||||
SharePointService: "SharePoint",
|
||||
GroupsService: "Groups",
|
||||
TeamsChatsService: "Chats",
|
||||
}
|
||||
|
||||
// HumanString produces a more human-readable string version of the service.
|
||||
|
||||
@ -17,11 +17,13 @@ func _() {
|
||||
_ = x[SharePointMetadataService-6]
|
||||
_ = x[GroupsService-7]
|
||||
_ = x[GroupsMetadataService-8]
|
||||
_ = x[TeamsChatsService-9]
|
||||
_ = x[TeamsChatsMetadataService-10]
|
||||
}
|
||||
|
||||
const _ServiceType_name = "UnknownServiceexchangeonedrivesharepointexchangeMetadataonedriveMetadatasharepointMetadatagroupsgroupsMetadata"
|
||||
const _ServiceType_name = "UnknownServiceexchangeonedrivesharepointexchangeMetadataonedriveMetadatasharepointMetadatagroupsgroupsMetadatachatschatsMetadata"
|
||||
|
||||
var _ServiceType_index = [...]uint8{0, 14, 22, 30, 40, 56, 72, 90, 96, 110}
|
||||
var _ServiceType_index = [...]uint8{0, 14, 22, 30, 40, 56, 72, 90, 96, 110, 115, 128}
|
||||
|
||||
func (i ServiceType) String() string {
|
||||
if i < 0 || i >= ServiceType(len(_ServiceType_index)-1) {
|
||||
|
||||
@ -591,7 +591,7 @@ func (ec exchangeCategory) isLeaf() bool {
|
||||
// pathValues transforms the two paths to maps of identified properties.
|
||||
//
|
||||
// Example:
|
||||
// [tenantID, service, userPN, category, mailFolder, mailID]
|
||||
// [tenantID, service, userID, category, mailFolder, mailID]
|
||||
// => {exchMailFolder: mailFolder, exchMail: mailID}
|
||||
func (ec exchangeCategory) pathValues(
|
||||
repo path.Path,
|
||||
@ -772,7 +772,7 @@ func (s ExchangeScope) matchesInfo(dii details.ItemInfo) bool {
|
||||
|
||||
infoCat := s.InfoCategory()
|
||||
|
||||
cfpc := categoryFromItemType(info.ItemType)
|
||||
cfpc := exchangeCategoryFromItemType(info.ItemType)
|
||||
if !typeAndCategoryMatches(infoCat, cfpc) {
|
||||
return false
|
||||
}
|
||||
@ -801,10 +801,10 @@ func (s ExchangeScope) matchesInfo(dii details.ItemInfo) bool {
|
||||
return s.Matches(infoCat, i)
|
||||
}
|
||||
|
||||
// categoryFromItemType interprets the category represented by the ExchangeInfo
|
||||
// exchangeCategoryFromItemType interprets the category represented by the ExchangeInfo
|
||||
// struct. Since every ExchangeInfo can hold all exchange data info, the exact
|
||||
// type that the struct represents must be compared using its ItemType prop.
|
||||
func categoryFromItemType(pct details.ItemType) exchangeCategory {
|
||||
func exchangeCategoryFromItemType(pct details.ItemType) exchangeCategory {
|
||||
switch pct {
|
||||
case details.ExchangeContact:
|
||||
return ExchangeContact
|
||||
|
||||
@ -1602,7 +1602,7 @@ func (suite *ExchangeSelectorSuite) TestCategoryFromItemType() {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
result := categoryFromItemType(test.input)
|
||||
result := exchangeCategoryFromItemType(test.input)
|
||||
assert.Equal(t, test.expect, result)
|
||||
})
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ const (
|
||||
ServiceOneDrive service = 2 // OneDrive
|
||||
ServiceSharePoint service = 3 // SharePoint
|
||||
ServiceGroups service = 4 // Groups
|
||||
ServiceTeamsChats service = 5 // TeamsChats
|
||||
)
|
||||
|
||||
var serviceToPathType = map[service]path.ServiceType{
|
||||
@ -34,6 +35,7 @@ var serviceToPathType = map[service]path.ServiceType{
|
||||
ServiceOneDrive: path.OneDriveService,
|
||||
ServiceSharePoint: path.SharePointService,
|
||||
ServiceGroups: path.GroupsService,
|
||||
ServiceTeamsChats: path.TeamsChatsService,
|
||||
}
|
||||
|
||||
var (
|
||||
@ -353,6 +355,9 @@ func selectorAsIface[T any](s Selector) (T, error) {
|
||||
case ServiceGroups:
|
||||
a, err = func() (any, error) { return s.ToGroupsRestore() }()
|
||||
t = a.(T)
|
||||
case ServiceTeamsChats:
|
||||
a, err = func() (any, error) { return s.ToTeamsChatsRestore() }()
|
||||
t = a.(T)
|
||||
default:
|
||||
err = clues.Stack(ErrorUnrecognizedService, clues.New(s.Service.String()))
|
||||
}
|
||||
|
||||
525
src/pkg/selectors/teamsChats.go
Normal file
525
src/pkg/selectors/teamsChats.go
Normal file
@ -0,0 +1,525 @@
|
||||
package selectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/backup/identity"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/filters"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type (
|
||||
// teamsChats provides an api for selecting
|
||||
// data scopes applicable to the TeamsChats service.
|
||||
teamsChats struct {
|
||||
Selector
|
||||
}
|
||||
|
||||
// TeamsChatsBackup provides an api for selecting
|
||||
// data scopes applicable to the TeamsChats service,
|
||||
// plus backup-specific methods.
|
||||
TeamsChatsBackup struct {
|
||||
teamsChats
|
||||
}
|
||||
|
||||
// TeamsChatsRestore provides an api for selecting
|
||||
// data scopes applicable to the TeamsChats service,
|
||||
// plus restore-specific methods.
|
||||
TeamsChatsRestore struct {
|
||||
teamsChats
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
_ Reducer = &TeamsChatsRestore{}
|
||||
_ pathCategorier = &TeamsChatsRestore{}
|
||||
_ reasoner = &TeamsChatsRestore{}
|
||||
)
|
||||
|
||||
// NewTeamsChats produces a new Selector with the service set to ServiceTeamsChats.
|
||||
func NewTeamsChatsBackup(users []string) *TeamsChatsBackup {
|
||||
src := TeamsChatsBackup{
|
||||
teamsChats{
|
||||
newSelector(ServiceTeamsChats, users),
|
||||
},
|
||||
}
|
||||
|
||||
return &src
|
||||
}
|
||||
|
||||
// ToTeamsChatsBackup transforms the generic selector into an TeamsChatsBackup.
|
||||
// Errors if the service defined by the selector is not ServiceTeamsChats.
|
||||
func (s Selector) ToTeamsChatsBackup() (*TeamsChatsBackup, error) {
|
||||
if s.Service != ServiceTeamsChats {
|
||||
return nil, badCastErr(ServiceTeamsChats, s.Service)
|
||||
}
|
||||
|
||||
src := TeamsChatsBackup{teamsChats{s}}
|
||||
|
||||
return &src, nil
|
||||
}
|
||||
|
||||
func (s TeamsChatsBackup) SplitByResourceOwner(users []string) []TeamsChatsBackup {
|
||||
sels := splitByProtectedResource[TeamsChatsScope](s.Selector, users, TeamsChatsUser)
|
||||
|
||||
ss := make([]TeamsChatsBackup, 0, len(sels))
|
||||
for _, sel := range sels {
|
||||
ss = append(ss, TeamsChatsBackup{teamsChats{sel}})
|
||||
}
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
// NewTeamsChatsRestore produces a new Selector with the service set to ServiceTeamsChats.
|
||||
func NewTeamsChatsRestore(users []string) *TeamsChatsRestore {
|
||||
src := TeamsChatsRestore{
|
||||
teamsChats{
|
||||
newSelector(ServiceTeamsChats, users),
|
||||
},
|
||||
}
|
||||
|
||||
return &src
|
||||
}
|
||||
|
||||
// ToTeamsChatsRestore transforms the generic selector into an TeamsChatsRestore.
|
||||
// Errors if the service defined by the selector is not ServiceTeamsChats.
|
||||
func (s Selector) ToTeamsChatsRestore() (*TeamsChatsRestore, error) {
|
||||
if s.Service != ServiceTeamsChats {
|
||||
return nil, badCastErr(ServiceTeamsChats, s.Service)
|
||||
}
|
||||
|
||||
src := TeamsChatsRestore{teamsChats{s}}
|
||||
|
||||
return &src, nil
|
||||
}
|
||||
|
||||
func (sr TeamsChatsRestore) SplitByResourceOwner(users []string) []TeamsChatsRestore {
|
||||
sels := splitByProtectedResource[TeamsChatsScope](sr.Selector, users, TeamsChatsUser)
|
||||
|
||||
ss := make([]TeamsChatsRestore, 0, len(sels))
|
||||
for _, sel := range sels {
|
||||
ss = append(ss, TeamsChatsRestore{teamsChats{sel}})
|
||||
}
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
// PathCategories produces the aggregation of discrete users described by each type of scope.
|
||||
func (s teamsChats) PathCategories() selectorPathCategories {
|
||||
return selectorPathCategories{
|
||||
Excludes: pathCategoriesIn[TeamsChatsScope, teamsChatsCategory](s.Excludes),
|
||||
Filters: pathCategoriesIn[TeamsChatsScope, teamsChatsCategory](s.Filters),
|
||||
Includes: pathCategoriesIn[TeamsChatsScope, teamsChatsCategory](s.Includes),
|
||||
}
|
||||
}
|
||||
|
||||
// Reasons returns a deduplicated set of the backup reasons produced
|
||||
// using the selector's discrete owner and each scopes' service and
|
||||
// category types.
|
||||
func (s teamsChats) Reasons(tenantID string, useOwnerNameForID bool) []identity.Reasoner {
|
||||
return reasonsFor(s, tenantID, useOwnerNameForID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stringers and Concealers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s TeamsChatsScope) Conceal() string { return conceal(s) }
|
||||
func (s TeamsChatsScope) Format(fs fmt.State, r rune) { format(s, fs, r) }
|
||||
func (s TeamsChatsScope) String() string { return conceal(s) }
|
||||
func (s TeamsChatsScope) PlainString() string { return plainString(s) }
|
||||
|
||||
// -------------------
|
||||
// Exclude/Includes
|
||||
|
||||
// Exclude appends the provided scopes to the selector's exclusion set.
|
||||
// Every Exclusion scope applies globally, affecting all inclusion scopes.
|
||||
// Data is excluded if it matches ANY exclusion (of the same data category).
|
||||
//
|
||||
// All parts of the scope must match for data to be exclucded.
|
||||
// Ex: Mail(u1, f1, m1) => only excludes mail if it is owned by user u1,
|
||||
// located in folder f1, and ID'd as m1. MailSender(foo) => only excludes
|
||||
// mail whose sender is foo. Use selectors.Any() to wildcard a scope value.
|
||||
// No value will match if selectors.None() is provided.
|
||||
//
|
||||
// Group-level scopes will automatically apply the Any() wildcard to
|
||||
// child properties.
|
||||
// ex: User(u1) automatically cascades to all chats,
|
||||
func (s *teamsChats) Exclude(scopes ...[]TeamsChatsScope) {
|
||||
s.Excludes = appendScopes(s.Excludes, scopes...)
|
||||
}
|
||||
|
||||
// Filter appends the provided scopes to the selector's filters set.
|
||||
// A selector with >0 filters and 0 inclusions will include any data
|
||||
// that passes all filters.
|
||||
// A selector with >0 filters and >0 inclusions will reduce the
|
||||
// inclusion set to only the data that passes all filters.
|
||||
// Data is retained if it passes ALL filters (of the same data category).
|
||||
//
|
||||
// All parts of the scope must match for data to pass the filter.
|
||||
// Ex: Mail(u1, f1, m1) => only passes mail that is owned by user u1,
|
||||
// located in folder f1, and ID'd as m1. MailSender(foo) => only passes
|
||||
// mail whose sender is foo. Use selectors.Any() to wildcard a scope value.
|
||||
// No value will match if selectors.None() is provided.
|
||||
//
|
||||
// Group-level scopes will automatically apply the Any() wildcard to
|
||||
// child properties.
|
||||
// ex: User(u1) automatically cascades to all chats,
|
||||
func (s *teamsChats) Filter(scopes ...[]TeamsChatsScope) {
|
||||
s.Filters = appendScopes(s.Filters, scopes...)
|
||||
}
|
||||
|
||||
// Include appends the provided scopes to the selector's inclusion set.
|
||||
// Data is included if it matches ANY inclusion.
|
||||
// The inclusion set is later filtered (all included data must pass ALL
|
||||
// filters) and excluded (all included data must not match ANY exclusion).
|
||||
// Data is included if it matches ANY inclusion (of the same data category).
|
||||
//
|
||||
// All parts of the scope must match for data to be included.
|
||||
// Ex: Mail(u1, f1, m1) => only includes mail if it is owned by user u1,
|
||||
// located in folder f1, and ID'd as m1. MailSender(foo) => only includes
|
||||
// mail whose sender is foo. Use selectors.Any() to wildcard a scope value.
|
||||
// No value will match if selectors.None() is provided.
|
||||
//
|
||||
// Group-level scopes will automatically apply the Any() wildcard to
|
||||
// child properties.
|
||||
// ex: User(u1) automatically cascades to all chats,
|
||||
func (s *teamsChats) Include(scopes ...[]TeamsChatsScope) {
|
||||
s.Includes = appendScopes(s.Includes, scopes...)
|
||||
}
|
||||
|
||||
// Scopes retrieves the list of teamsChatsScopes in the selector.
|
||||
func (s *teamsChats) Scopes() []TeamsChatsScope {
|
||||
return scopes[TeamsChatsScope](s.Selector)
|
||||
}
|
||||
|
||||
type TeamsChatsItemScopeConstructor func([]string, []string, ...option) []TeamsChatsScope
|
||||
|
||||
// -------------------
|
||||
// Scope Factories
|
||||
|
||||
// Chats produces one or more teamsChats scopes.
|
||||
// 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]
|
||||
// options are only applied to the folder scopes.
|
||||
func (s *teamsChats) Chats(chats []string, opts ...option) []TeamsChatsScope {
|
||||
scopes := []TeamsChatsScope{}
|
||||
|
||||
scopes = append(
|
||||
scopes,
|
||||
makeScope[TeamsChatsScope](TeamsChatsChat, chats, defaultItemOptions(s.Cfg)...))
|
||||
|
||||
return scopes
|
||||
}
|
||||
|
||||
// Retrieves all teamsChats data.
|
||||
// Each user id generates three scopes, one for each data type: contact, event, and mail.
|
||||
// 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 *teamsChats) AllData() []TeamsChatsScope {
|
||||
scopes := []TeamsChatsScope{}
|
||||
|
||||
scopes = append(scopes, makeScope[TeamsChatsScope](TeamsChatsChat, Any()))
|
||||
|
||||
return scopes
|
||||
}
|
||||
|
||||
// -------------------
|
||||
// ItemInfo Factories
|
||||
|
||||
// ChatMember produces one or more teamsChats chat member info scopes.
|
||||
// Matches any chat member whose email contains the provided string.
|
||||
// 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 (sr *TeamsChatsRestore) ChatMember(memberID string) []TeamsChatsScope {
|
||||
return []TeamsChatsScope{
|
||||
makeInfoScope[TeamsChatsScope](
|
||||
TeamsChatsChat,
|
||||
TeamsChatsInfoChatMember,
|
||||
[]string{memberID},
|
||||
filters.In),
|
||||
}
|
||||
}
|
||||
|
||||
// ChatName produces one or more teamsChats chat name info scopes.
|
||||
// Matches any chat whose name contains the provided string.
|
||||
// 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 (sr *TeamsChatsRestore) ChatName(memberID string) []TeamsChatsScope {
|
||||
return []TeamsChatsScope{
|
||||
makeInfoScope[TeamsChatsScope](
|
||||
TeamsChatsChat,
|
||||
TeamsChatsInfoChatName,
|
||||
[]string{memberID},
|
||||
filters.In),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// teamsChatsCategory enumerates the type of the lowest level
|
||||
// of data specified by the scope.
|
||||
type teamsChatsCategory string
|
||||
|
||||
// interface compliance checks
|
||||
var _ categorizer = TeamsChatsCategoryUnknown
|
||||
|
||||
const (
|
||||
TeamsChatsCategoryUnknown teamsChatsCategory = ""
|
||||
|
||||
// types of data identified by teamsChats
|
||||
TeamsChatsUser teamsChatsCategory = "TeamsChatsUser"
|
||||
TeamsChatsChat teamsChatsCategory = "TeamsChatsChat"
|
||||
|
||||
// data contained within details.ItemInfo
|
||||
TeamsChatsInfoChatMember teamsChatsCategory = "TeamsChatsInfoChatMember"
|
||||
TeamsChatsInfoChatName teamsChatsCategory = "TeamsChatsInfoChatName"
|
||||
)
|
||||
|
||||
// teamsChatsLeafProperties describes common metadata of the leaf categories
|
||||
var teamsChatsLeafProperties = map[categorizer]leafProperty{
|
||||
TeamsChatsChat: {
|
||||
pathKeys: []categorizer{TeamsChatsChat},
|
||||
pathType: path.ChatsCategory,
|
||||
},
|
||||
TeamsChatsUser: { // the root category must be represented, even though it isn't a leaf
|
||||
pathKeys: []categorizer{TeamsChatsUser},
|
||||
pathType: path.UnknownCategory,
|
||||
},
|
||||
}
|
||||
|
||||
func (ec teamsChatsCategory) String() string {
|
||||
return string(ec)
|
||||
}
|
||||
|
||||
// leafCat returns the leaf category of the receiver.
|
||||
// If the receiver category has multiple leaves (ex: User) or no leaves,
|
||||
// (ex: Unknown), the receiver itself is returned.
|
||||
// If the receiver category is an info type (ex: TeamsChatsInfoChatMember),
|
||||
// returns the category covered by the info.
|
||||
// Ex: TeamsChatsChatFolder.leafCat() => TeamsChatsChat
|
||||
// Ex: TeamsChatsUser.leafCat() => TeamsChatsUser
|
||||
func (ec teamsChatsCategory) leafCat() categorizer {
|
||||
switch ec {
|
||||
case TeamsChatsChat, TeamsChatsInfoChatMember, TeamsChatsInfoChatName:
|
||||
return TeamsChatsChat
|
||||
}
|
||||
|
||||
return ec
|
||||
}
|
||||
|
||||
// rootCat returns the root category type.
|
||||
func (ec teamsChatsCategory) rootCat() categorizer {
|
||||
return TeamsChatsUser
|
||||
}
|
||||
|
||||
// unknownCat returns the unknown category type.
|
||||
func (ec teamsChatsCategory) unknownCat() categorizer {
|
||||
return TeamsChatsCategoryUnknown
|
||||
}
|
||||
|
||||
// isUnion returns true if c is a user
|
||||
func (ec teamsChatsCategory) isUnion() bool {
|
||||
return ec == ec.rootCat()
|
||||
}
|
||||
|
||||
// isLeaf is true if the category is a mail, event, or contact category.
|
||||
func (ec teamsChatsCategory) isLeaf() bool {
|
||||
return ec == ec.leafCat()
|
||||
}
|
||||
|
||||
// pathValues transforms the two paths to maps of identified properties.
|
||||
//
|
||||
// Example:
|
||||
// [tenantID, service, userID, category, chatID]
|
||||
// => {teamsChat: chatID}
|
||||
func (ec teamsChatsCategory) pathValues(
|
||||
repo path.Path,
|
||||
ent details.Entry,
|
||||
cfg Config,
|
||||
) (map[categorizer][]string, error) {
|
||||
var itemCat categorizer
|
||||
|
||||
switch ec {
|
||||
case TeamsChatsChat:
|
||||
itemCat = TeamsChatsChat
|
||||
|
||||
default:
|
||||
return nil, clues.New("bad teamsChatCategory").With("category", ec)
|
||||
}
|
||||
|
||||
item := ent.ItemRef
|
||||
if len(item) == 0 {
|
||||
item = repo.Item()
|
||||
}
|
||||
|
||||
items := []string{ent.ShortRef, item}
|
||||
|
||||
// only include the item ID when the user is NOT matching
|
||||
// item names. TeamsChats data does not contain an item name,
|
||||
// only an ID, and we don't want to mix up the two.
|
||||
if cfg.OnlyMatchItemNames {
|
||||
items = []string{ent.ShortRef}
|
||||
}
|
||||
|
||||
result := map[categorizer][]string{
|
||||
itemCat: items,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pathKeys returns the path keys recognized by the receiver's leaf type.
|
||||
func (ec teamsChatsCategory) pathKeys() []categorizer {
|
||||
return teamsChatsLeafProperties[ec.leafCat()].pathKeys
|
||||
}
|
||||
|
||||
// PathType converts the category's leaf type into the matching path.CategoryType.
|
||||
func (ec teamsChatsCategory) PathType() path.CategoryType {
|
||||
return teamsChatsLeafProperties[ec.leafCat()].pathType
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TeamsChatsScope specifies the data available
|
||||
// when interfacing with the TeamsChats service.
|
||||
type TeamsChatsScope scope
|
||||
|
||||
// interface compliance checks
|
||||
var _ scoper = &TeamsChatsScope{}
|
||||
|
||||
// Category describes the type of the data in scope.
|
||||
func (s TeamsChatsScope) Category() teamsChatsCategory {
|
||||
return teamsChatsCategory(getCategory(s))
|
||||
}
|
||||
|
||||
// categorizer type is a generic wrapper around Category.
|
||||
// Primarily used by scopes.go to for abstract comparisons.
|
||||
func (s TeamsChatsScope) categorizer() categorizer {
|
||||
return s.Category()
|
||||
}
|
||||
|
||||
// Matches returns true if the category is included in the scope's
|
||||
// data type, and the target string matches that category's comparator.
|
||||
func (s TeamsChatsScope) Matches(cat teamsChatsCategory, target string) bool {
|
||||
return matches(s, cat, target)
|
||||
}
|
||||
|
||||
// InfoCategory returns the category enum of the scope info.
|
||||
// If the scope is not an info type, returns TeamsChatsUnknownCategory.
|
||||
func (s TeamsChatsScope) InfoCategory() teamsChatsCategory {
|
||||
return teamsChatsCategory(getInfoCategory(s))
|
||||
}
|
||||
|
||||
// IncludeCategory checks whether the scope includes a certain category of data.
|
||||
// Ex: to check if the scope includes mail data:
|
||||
// s.IncludesCategory(selector.TeamsChatsMail)
|
||||
func (s TeamsChatsScope) IncludesCategory(cat teamsChatsCategory) bool {
|
||||
return categoryMatches(s.Category(), cat)
|
||||
}
|
||||
|
||||
// returns true if the category is included in the scope's data type,
|
||||
// and the value is set to Any().
|
||||
func (s TeamsChatsScope) IsAny(cat teamsChatsCategory) bool {
|
||||
return IsAnyTarget(s, cat)
|
||||
}
|
||||
|
||||
// Get returns the data category in the scope. If the scope
|
||||
// contains all data types for a user, it'll return the
|
||||
// TeamsChatsUser category.
|
||||
func (s TeamsChatsScope) Get(cat teamsChatsCategory) []string {
|
||||
return getCatValue(s, cat)
|
||||
}
|
||||
|
||||
// kept around because it'll likely be needed again in later additions.
|
||||
// // sets a value by category to the scope. Only intended for internal use.
|
||||
// func (s TeamsChatsScope) set(cat teamsChatsCategory, v []string, opts ...option) TeamsChatsScope {
|
||||
// return set(s, cat, v, opts...)
|
||||
// }
|
||||
|
||||
// setDefaults ensures that contact folder, mail folder, and user category
|
||||
// scopes all express `AnyTgt` for their child category types.
|
||||
func (s TeamsChatsScope) setDefaults() {
|
||||
switch s.Category() {
|
||||
case TeamsChatsUser:
|
||||
s[TeamsChatsChat.String()] = passAny
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup Details Filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reduce filters the entries in a details struct to only those that match the
|
||||
// inclusions, filters, and exclusions in the selector.
|
||||
func (s teamsChats) Reduce(
|
||||
ctx context.Context,
|
||||
deets *details.Details,
|
||||
errs *fault.Bus,
|
||||
) *details.Details {
|
||||
return reduce[TeamsChatsScope](
|
||||
ctx,
|
||||
deets,
|
||||
s.Selector,
|
||||
map[path.CategoryType]teamsChatsCategory{
|
||||
path.ChatsCategory: TeamsChatsChat,
|
||||
},
|
||||
errs)
|
||||
}
|
||||
|
||||
// matchesInfo handles the standard behavior when comparing a scope and an TeamsChatsInfo
|
||||
// returns true if the scope and info match for the provided category.
|
||||
func (s TeamsChatsScope) matchesInfo(dii details.ItemInfo) bool {
|
||||
info := dii.TeamsChats
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
infoCat := s.InfoCategory()
|
||||
|
||||
cfpc := teamsChatsCategoryFromItemType(info.ItemType)
|
||||
if !typeAndCategoryMatches(infoCat, cfpc) {
|
||||
return false
|
||||
}
|
||||
|
||||
i := ""
|
||||
|
||||
switch infoCat {
|
||||
case TeamsChatsInfoChatMember:
|
||||
i = strings.Join(info.Chat.Members, ",")
|
||||
case TeamsChatsInfoChatName:
|
||||
i = info.Chat.Topic
|
||||
}
|
||||
|
||||
return s.Matches(infoCat, i)
|
||||
}
|
||||
|
||||
// teamsChatsCategoryFromItemType interprets the category represented by the TeamsChatsInfo
|
||||
// struct. Since every TeamsChatsInfo can hold all teamsChats data info, the exact
|
||||
// type that the struct represents must be compared using its ItemType prop.
|
||||
func teamsChatsCategoryFromItemType(pct details.ItemType) teamsChatsCategory {
|
||||
switch pct {
|
||||
case details.TeamsChat:
|
||||
return TeamsChatsChat
|
||||
}
|
||||
|
||||
return TeamsChatsCategoryUnknown
|
||||
}
|
||||
843
src/pkg/selectors/teamsChats_test.go
Normal file
843
src/pkg/selectors/teamsChats_test.go
Normal file
@ -0,0 +1,843 @@
|
||||
package selectors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/fault"
|
||||
"github.com/alcionai/corso/src/pkg/filters"
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
)
|
||||
|
||||
type TeamsChatsSelectorSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestTeamsChatsSelectorSuite(t *testing.T) {
|
||||
suite.Run(t, &TeamsChatsSelectorSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestNewTeamsChatsBackup() {
|
||||
t := suite.T()
|
||||
eb := NewTeamsChatsBackup(nil)
|
||||
assert.Equal(t, eb.Service, ServiceTeamsChats)
|
||||
assert.NotZero(t, eb.Scopes())
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestToTeamsChatsBackup() {
|
||||
t := suite.T()
|
||||
eb := NewTeamsChatsBackup(nil)
|
||||
s := eb.Selector
|
||||
eb, err := s.ToTeamsChatsBackup()
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
assert.Equal(t, eb.Service, ServiceTeamsChats)
|
||||
assert.NotZero(t, eb.Scopes())
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestNewTeamsChatsRestore() {
|
||||
t := suite.T()
|
||||
er := NewTeamsChatsRestore(nil)
|
||||
assert.Equal(t, er.Service, ServiceTeamsChats)
|
||||
assert.NotZero(t, er.Scopes())
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestToTeamsChatsRestore() {
|
||||
t := suite.T()
|
||||
eb := NewTeamsChatsRestore(nil)
|
||||
s := eb.Selector
|
||||
eb, err := s.ToTeamsChatsRestore()
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
assert.Equal(t, eb.Service, ServiceTeamsChats)
|
||||
assert.NotZero(t, eb.Scopes())
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Exclude_TeamsChats() {
|
||||
t := suite.T()
|
||||
|
||||
const (
|
||||
user = "user"
|
||||
folder = AnyTgt
|
||||
c1 = "c1"
|
||||
c2 = "c2"
|
||||
)
|
||||
|
||||
sel := NewTeamsChatsBackup([]string{user})
|
||||
sel.Exclude(sel.Chats([]string{c1, c2}))
|
||||
scopes := sel.Excludes
|
||||
require.Len(t, scopes, 1)
|
||||
|
||||
scopeMustHave(
|
||||
t,
|
||||
TeamsChatsScope(scopes[0]),
|
||||
map[categorizer][]string{
|
||||
TeamsChatsChat: {c1, c2},
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Include_TeamsChats() {
|
||||
t := suite.T()
|
||||
|
||||
const (
|
||||
user = "user"
|
||||
folder = AnyTgt
|
||||
c1 = "c1"
|
||||
c2 = "c2"
|
||||
)
|
||||
|
||||
sel := NewTeamsChatsBackup([]string{user})
|
||||
sel.Include(sel.Chats([]string{c1, c2}))
|
||||
scopes := sel.Includes
|
||||
require.Len(t, scopes, 1)
|
||||
|
||||
scopeMustHave(
|
||||
t,
|
||||
TeamsChatsScope(scopes[0]),
|
||||
map[categorizer][]string{
|
||||
TeamsChatsChat: {c1, c2},
|
||||
})
|
||||
|
||||
assert.Equal(t, sel.Scopes()[0].Category(), TeamsChatsChat)
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Exclude_AllData() {
|
||||
t := suite.T()
|
||||
|
||||
const (
|
||||
u1 = "u1"
|
||||
u2 = "u2"
|
||||
)
|
||||
|
||||
sel := NewTeamsChatsBackup([]string{u1, u2})
|
||||
sel.Exclude(sel.AllData())
|
||||
scopes := sel.Excludes
|
||||
require.Len(t, scopes, 1)
|
||||
|
||||
for _, sc := range scopes {
|
||||
if sc[scopeKeyCategory].Compare(TeamsChatsChat.String()) {
|
||||
scopeMustHave(
|
||||
t,
|
||||
TeamsChatsScope(sc),
|
||||
map[categorizer][]string{
|
||||
TeamsChatsChat: Any(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Include_AllData() {
|
||||
t := suite.T()
|
||||
|
||||
const (
|
||||
u1 = "u1"
|
||||
u2 = "u2"
|
||||
)
|
||||
|
||||
sel := NewTeamsChatsBackup([]string{u1, u2})
|
||||
sel.Include(sel.AllData())
|
||||
scopes := sel.Includes
|
||||
require.Len(t, scopes, 1)
|
||||
|
||||
for _, sc := range scopes {
|
||||
if sc[scopeKeyCategory].Compare(TeamsChatsChat.String()) {
|
||||
scopeMustHave(
|
||||
t,
|
||||
TeamsChatsScope(sc),
|
||||
map[categorizer][]string{
|
||||
TeamsChatsChat: Any(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsBackup_Scopes() {
|
||||
eb := NewTeamsChatsBackup(Any())
|
||||
eb.Include(eb.AllData())
|
||||
|
||||
scopes := eb.Scopes()
|
||||
assert.Len(suite.T(), scopes, 1)
|
||||
|
||||
for _, sc := range scopes {
|
||||
cat := sc.Category()
|
||||
suite.Run(cat.String(), func() {
|
||||
t := suite.T()
|
||||
|
||||
switch sc.Category() {
|
||||
case TeamsChatsChat:
|
||||
assert.True(t, sc.IsAny(TeamsChatsChat))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_Category() {
|
||||
table := []struct {
|
||||
is teamsChatsCategory
|
||||
expect teamsChatsCategory
|
||||
check assert.ComparisonAssertionFunc
|
||||
}{
|
||||
{TeamsChatsCategoryUnknown, TeamsChatsCategoryUnknown, assert.Equal},
|
||||
{TeamsChatsCategoryUnknown, TeamsChatsUser, assert.NotEqual},
|
||||
{TeamsChatsChat, TeamsChatsChat, assert.Equal},
|
||||
{TeamsChatsUser, TeamsChatsUser, assert.Equal},
|
||||
{TeamsChatsUser, TeamsChatsCategoryUnknown, assert.NotEqual},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.is.String()+test.expect.String(), func() {
|
||||
eb := NewTeamsChatsBackup(Any())
|
||||
eb.Includes = []scope{
|
||||
{scopeKeyCategory: filters.Identity(test.is.String())},
|
||||
}
|
||||
scope := eb.Scopes()[0]
|
||||
test.check(suite.T(), test.expect, scope.Category())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_IncludesCategory() {
|
||||
table := []struct {
|
||||
is teamsChatsCategory
|
||||
expect teamsChatsCategory
|
||||
check assert.BoolAssertionFunc
|
||||
}{
|
||||
{TeamsChatsCategoryUnknown, TeamsChatsCategoryUnknown, assert.False},
|
||||
{TeamsChatsCategoryUnknown, TeamsChatsUser, assert.True},
|
||||
{TeamsChatsUser, TeamsChatsUser, assert.True},
|
||||
{TeamsChatsUser, TeamsChatsCategoryUnknown, assert.True},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.is.String()+test.expect.String(), func() {
|
||||
eb := NewTeamsChatsBackup(Any())
|
||||
eb.Includes = []scope{
|
||||
{scopeKeyCategory: filters.Identity(test.is.String())},
|
||||
}
|
||||
scope := eb.Scopes()[0]
|
||||
test.check(suite.T(), scope.IncludesCategory(test.expect))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_Get() {
|
||||
eb := NewTeamsChatsBackup(Any())
|
||||
eb.Include(eb.AllData())
|
||||
|
||||
scopes := eb.Scopes()
|
||||
|
||||
table := []teamsChatsCategory{
|
||||
TeamsChatsChat,
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.String(), func() {
|
||||
t := suite.T()
|
||||
|
||||
for _, sc := range scopes {
|
||||
switch sc.Category() {
|
||||
case TeamsChatsChat:
|
||||
assert.Equal(t, Any(), sc.Get(TeamsChatsChat))
|
||||
}
|
||||
assert.Equal(t, None(), sc.Get(TeamsChatsCategoryUnknown))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesInfo() {
|
||||
cs := NewTeamsChatsRestore(Any())
|
||||
|
||||
const (
|
||||
name = "smarf mcfnords"
|
||||
member = "cooks@2many.smarf"
|
||||
subject = "I have seen the fnords!"
|
||||
)
|
||||
|
||||
var (
|
||||
now = time.Now()
|
||||
future = now.Add(1 * time.Minute)
|
||||
)
|
||||
|
||||
infoWith := func(itype details.ItemType) details.ItemInfo {
|
||||
return details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: itype,
|
||||
Chat: details.ChatInfo{
|
||||
CreatedAt: now,
|
||||
HasExternalMembers: false,
|
||||
LastMessageAt: future,
|
||||
LastMessagePreview: "preview",
|
||||
Members: []string{member},
|
||||
MessageCount: 1,
|
||||
Topic: name,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
itype details.ItemType
|
||||
scope []TeamsChatsScope
|
||||
expect assert.BoolAssertionFunc
|
||||
}{
|
||||
{"chat with a different member", details.TeamsChat, cs.ChatMember("blarps"), assert.False},
|
||||
{"chat with the same member", details.TeamsChat, cs.ChatMember(member), assert.True},
|
||||
{"chat with a member submatch search", details.TeamsChat, cs.ChatMember(member[2:5]), assert.True},
|
||||
{"chat with a different name", details.TeamsChat, cs.ChatName("blarps"), assert.False},
|
||||
{"chat with the same name", details.TeamsChat, cs.ChatName(name), assert.True},
|
||||
{"chat with a subname search", details.TeamsChat, cs.ChatName(name[2:5]), assert.True},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
scopes := setScopesToDefault(test.scope)
|
||||
for _, scope := range scopes {
|
||||
test.expect(t, scope.matchesInfo(infoWith(test.itype)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesPath() {
|
||||
const (
|
||||
user = "userID"
|
||||
chat = "chatID"
|
||||
)
|
||||
|
||||
repoRef, err := path.Build("tid", user, path.TeamsChatsService, path.ChatsCategory, true, chat)
|
||||
require.NoError(suite.T(), err, clues.ToCore(err))
|
||||
|
||||
var (
|
||||
loc = strings.Join([]string{chat}, "/")
|
||||
short = "thisisahashofsomekind"
|
||||
cs = NewTeamsChatsRestore(Any())
|
||||
ent = details.Entry{
|
||||
RepoRef: repoRef.String(),
|
||||
ShortRef: short,
|
||||
ItemRef: chat,
|
||||
LocationRef: loc,
|
||||
}
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
scope []TeamsChatsScope
|
||||
shortRef string
|
||||
expect assert.BoolAssertionFunc
|
||||
}{
|
||||
{"all items", cs.AllData(), "", assert.True},
|
||||
{"all chats", cs.Chats(Any()), "", assert.True},
|
||||
{"no chats", cs.Chats(None()), "", assert.False},
|
||||
{"matching chats", cs.Chats([]string{chat}), "", assert.True},
|
||||
{"non-matching chats", cs.Chats([]string{"smarf"}), "", assert.False},
|
||||
{"one of multiple chats", cs.Chats([]string{"smarf", chat}), "", assert.True},
|
||||
{"chats short ref", cs.Chats([]string{short}), short, assert.True},
|
||||
{"non-leaf short ref", cs.Chats([]string{"foo"}), short, assert.False},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
scopes := setScopesToDefault(test.scope)
|
||||
var aMatch bool
|
||||
for _, scope := range scopes {
|
||||
pvs, err := TeamsChatsChat.pathValues(repoRef, ent, Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
if matchesPathValues(scope, TeamsChatsChat, pvs) {
|
||||
aMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
test.expect(t, aMatch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsRestore_Reduce() {
|
||||
chat, err := path.Build("tid", "uid", path.TeamsChatsService, path.ChatsCategory, true, "cid")
|
||||
require.NoError(suite.T(), err, clues.ToCore(err))
|
||||
|
||||
toRR := func(p path.Path) string {
|
||||
newElems := []string{}
|
||||
|
||||
for _, e := range p.Folders() {
|
||||
newElems = append(newElems, e+".d")
|
||||
}
|
||||
|
||||
joinedFldrs := strings.Join(newElems, "/")
|
||||
|
||||
return stubRepoRef(p.Service(), p.Category(), p.ProtectedResource(), joinedFldrs, p.Item())
|
||||
}
|
||||
|
||||
makeDeets := func(refs ...path.Path) *details.Details {
|
||||
deets := &details.Details{
|
||||
DetailsModel: details.DetailsModel{
|
||||
Entries: []details.Entry{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, r := range refs {
|
||||
itype := details.UnknownType
|
||||
|
||||
switch r {
|
||||
case chat:
|
||||
itype = details.TeamsChat
|
||||
}
|
||||
|
||||
deets.Entries = append(deets.Entries, details.Entry{
|
||||
RepoRef: toRR(r),
|
||||
// Don't escape because we assume nice paths.
|
||||
LocationRef: r.Folder(false),
|
||||
ItemInfo: details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: itype,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return deets
|
||||
}
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
deets *details.Details
|
||||
makeSelector func() *TeamsChatsRestore
|
||||
expect []string
|
||||
}{
|
||||
{
|
||||
"no refs",
|
||||
makeDeets(),
|
||||
func() *TeamsChatsRestore {
|
||||
er := NewTeamsChatsRestore(Any())
|
||||
er.Include(er.AllData())
|
||||
return er
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"chat only",
|
||||
makeDeets(chat),
|
||||
func() *TeamsChatsRestore {
|
||||
er := NewTeamsChatsRestore(Any())
|
||||
er.Include(er.AllData())
|
||||
return er
|
||||
},
|
||||
[]string{toRR(chat)},
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
sel := test.makeSelector()
|
||||
results := sel.Reduce(ctx, test.deets, fault.New(true))
|
||||
paths := results.Paths()
|
||||
assert.Equal(t, test.expect, paths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsRestore_Reduce_locationRef() {
|
||||
var (
|
||||
chat = stubRepoRef(path.TeamsChatsService, path.ChatsCategory, "uid", "", "cid")
|
||||
chatLocation = "chatname"
|
||||
)
|
||||
|
||||
makeDeets := func(refs ...string) *details.Details {
|
||||
deets := &details.Details{
|
||||
DetailsModel: details.DetailsModel{
|
||||
Entries: []details.Entry{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, r := range refs {
|
||||
var (
|
||||
location string
|
||||
itype = details.UnknownType
|
||||
)
|
||||
|
||||
switch r {
|
||||
case chat:
|
||||
itype = details.TeamsChat
|
||||
location = chatLocation
|
||||
}
|
||||
|
||||
deets.Entries = append(deets.Entries, details.Entry{
|
||||
RepoRef: r,
|
||||
LocationRef: location,
|
||||
ItemInfo: details.ItemInfo{
|
||||
TeamsChats: &details.TeamsChatsInfo{
|
||||
ItemType: itype,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return deets
|
||||
}
|
||||
|
||||
arr := func(s ...string) []string {
|
||||
return s
|
||||
}
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
deets *details.Details
|
||||
makeSelector func() *TeamsChatsRestore
|
||||
expect []string
|
||||
}{
|
||||
{
|
||||
"no refs",
|
||||
makeDeets(),
|
||||
func() *TeamsChatsRestore {
|
||||
er := NewTeamsChatsRestore(Any())
|
||||
er.Include(er.AllData())
|
||||
return er
|
||||
},
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"chat only",
|
||||
makeDeets(chat),
|
||||
func() *TeamsChatsRestore {
|
||||
er := NewTeamsChatsRestore(Any())
|
||||
er.Include(er.AllData())
|
||||
return er
|
||||
},
|
||||
arr(chat),
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
sel := test.makeSelector()
|
||||
results := sel.Reduce(ctx, test.deets, fault.New(true))
|
||||
paths := results.Paths()
|
||||
assert.Equal(t, test.expect, paths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestScopesByCategory() {
|
||||
var (
|
||||
cs = NewTeamsChatsRestore(Any())
|
||||
teamsChats = cs.Chats(Any())
|
||||
)
|
||||
|
||||
type expect struct {
|
||||
chat int
|
||||
}
|
||||
|
||||
type input []scope
|
||||
|
||||
makeInput := func(cs ...[]TeamsChatsScope) []scope {
|
||||
mss := []scope{}
|
||||
|
||||
for _, sl := range cs {
|
||||
for _, s := range sl {
|
||||
mss = append(mss, scope(s))
|
||||
}
|
||||
}
|
||||
|
||||
return mss
|
||||
}
|
||||
cats := map[path.CategoryType]teamsChatsCategory{
|
||||
path.ChatsCategory: TeamsChatsChat,
|
||||
}
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
scopes input
|
||||
expect expect
|
||||
}{
|
||||
{"teamsChats only", makeInput(teamsChats), expect{1}},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
result := scopesByCategory[TeamsChatsScope](test.scopes, cats, false)
|
||||
assert.Len(t, result[TeamsChatsChat], test.expect.chat)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestPasses() {
|
||||
const (
|
||||
chatID = "chatID"
|
||||
cat = TeamsChatsChat
|
||||
)
|
||||
|
||||
short := "thisisahashofsomekind"
|
||||
entry := details.Entry{
|
||||
ShortRef: short,
|
||||
ItemRef: chatID,
|
||||
}
|
||||
|
||||
repoRef, err := path.Build("tid", "user", path.TeamsChatsService, path.ChatsCategory, true, chatID)
|
||||
require.NoError(suite.T(), err, clues.ToCore(err))
|
||||
|
||||
var (
|
||||
cs = NewTeamsChatsRestore(Any())
|
||||
otherChat = setScopesToDefault(cs.Chats([]string{"smarf"}))
|
||||
chat = setScopesToDefault(cs.Chats([]string{chatID}))
|
||||
noChat = setScopesToDefault(cs.Chats(None()))
|
||||
allChats = setScopesToDefault(cs.Chats(Any()))
|
||||
ent = details.Entry{
|
||||
RepoRef: repoRef.String(),
|
||||
}
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
excludes, filters, includes []TeamsChatsScope
|
||||
expect assert.BoolAssertionFunc
|
||||
}{
|
||||
{"empty", nil, nil, nil, assert.False},
|
||||
{"in Chat", nil, nil, chat, assert.True},
|
||||
{"in Other", nil, nil, otherChat, assert.False},
|
||||
{"in no Chat", nil, nil, noChat, assert.False},
|
||||
{"ex None filter chat", allChats, chat, nil, assert.False},
|
||||
{"ex Chat", chat, nil, allChats, assert.False},
|
||||
{"ex Other", otherChat, nil, allChats, assert.True},
|
||||
{"in and ex Chat", chat, nil, chat, assert.False},
|
||||
{"filter Chat", nil, chat, allChats, assert.True},
|
||||
{"filter Other", nil, otherChat, allChats, assert.False},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
pvs, err := cat.pathValues(repoRef, ent, Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result := passes(
|
||||
cat,
|
||||
pvs,
|
||||
entry,
|
||||
test.excludes,
|
||||
test.filters,
|
||||
test.includes)
|
||||
test.expect(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestContains() {
|
||||
target := "fnords"
|
||||
|
||||
var (
|
||||
cs = NewTeamsChatsRestore(Any())
|
||||
noChat = setScopesToDefault(cs.Chats(None()))
|
||||
does = setScopesToDefault(cs.Chats([]string{target}))
|
||||
doesNot = setScopesToDefault(cs.Chats([]string{"smarf"}))
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
scopes []TeamsChatsScope
|
||||
expect assert.BoolAssertionFunc
|
||||
}{
|
||||
{"no chat", noChat, assert.False},
|
||||
{"does contain", does, assert.True},
|
||||
{"does not contain", doesNot, assert.False},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
var result bool
|
||||
for _, scope := range test.scopes {
|
||||
if scope.Matches(TeamsChatsChat, target) {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
test.expect(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestIsAny() {
|
||||
var (
|
||||
cs = NewTeamsChatsRestore(Any())
|
||||
specificChat = setScopesToDefault(cs.Chats([]string{"chat"}))
|
||||
anyChat = setScopesToDefault(cs.Chats(Any()))
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
name string
|
||||
scopes []TeamsChatsScope
|
||||
cat teamsChatsCategory
|
||||
expect assert.BoolAssertionFunc
|
||||
}{
|
||||
{"specific chat", specificChat, TeamsChatsChat, assert.False},
|
||||
{"any chat", anyChat, TeamsChatsChat, assert.True},
|
||||
{"wrong category", anyChat, TeamsChatsUser, assert.False},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
var result bool
|
||||
for _, scope := range test.scopes {
|
||||
if scope.IsAny(test.cat) {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
}
|
||||
test.expect(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsCategory_leafCat() {
|
||||
table := []struct {
|
||||
cat teamsChatsCategory
|
||||
expect teamsChatsCategory
|
||||
}{
|
||||
{teamsChatsCategory("foo"), teamsChatsCategory("foo")},
|
||||
{TeamsChatsCategoryUnknown, TeamsChatsCategoryUnknown},
|
||||
{TeamsChatsUser, TeamsChatsUser},
|
||||
{TeamsChatsChat, TeamsChatsChat},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.cat.String(), func() {
|
||||
assert.Equal(suite.T(), test.expect, test.cat.leafCat(), test.cat.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsCategory_PathValues() {
|
||||
t := suite.T()
|
||||
|
||||
chatPath, err := path.Build("tid", "u", path.TeamsChatsService, path.ChatsCategory, true, "chatitem.d")
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
chatLoc, err := path.Build("tid", "u", path.TeamsChatsService, path.ChatsCategory, true, "chatitem")
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
var (
|
||||
chatMap = map[categorizer][]string{
|
||||
TeamsChatsChat: {chatPath.Item(), "chat-short"},
|
||||
}
|
||||
chatOnlyNameMap = map[categorizer][]string{
|
||||
TeamsChatsChat: {"chat-short"},
|
||||
}
|
||||
)
|
||||
|
||||
table := []struct {
|
||||
cat teamsChatsCategory
|
||||
path path.Path
|
||||
loc path.Path
|
||||
short string
|
||||
expect map[categorizer][]string
|
||||
expectOnlyName map[categorizer][]string
|
||||
}{
|
||||
{TeamsChatsChat, chatPath, chatLoc, "chat-short", chatMap, chatOnlyNameMap},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(string(test.cat), func() {
|
||||
t := suite.T()
|
||||
ent := details.Entry{
|
||||
RepoRef: test.path.String(),
|
||||
ShortRef: test.short,
|
||||
LocationRef: test.loc.Folder(true),
|
||||
ItemRef: test.path.Item(),
|
||||
}
|
||||
|
||||
pvs, err := test.cat.pathValues(test.path, ent, Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
for k := range test.expect {
|
||||
assert.ElementsMatch(t, test.expect[k], pvs[k])
|
||||
}
|
||||
|
||||
pvs, err = test.cat.pathValues(test.path, ent, Config{OnlyMatchItemNames: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
for k := range test.expectOnlyName {
|
||||
assert.ElementsMatch(t, test.expectOnlyName[k], pvs[k], k)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestTeamsChatsCategory_PathKeys() {
|
||||
chat := []categorizer{TeamsChatsChat}
|
||||
user := []categorizer{TeamsChatsUser}
|
||||
|
||||
var empty []categorizer
|
||||
|
||||
table := []struct {
|
||||
cat teamsChatsCategory
|
||||
expect []categorizer
|
||||
}{
|
||||
{TeamsChatsCategoryUnknown, empty},
|
||||
{TeamsChatsChat, chat},
|
||||
{TeamsChatsUser, user},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(string(test.cat), func() {
|
||||
assert.Equal(suite.T(), test.cat.pathKeys(), test.expect)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestCategoryFromItemType() {
|
||||
table := []struct {
|
||||
name string
|
||||
input details.ItemType
|
||||
expect teamsChatsCategory
|
||||
}{
|
||||
{
|
||||
name: "chat",
|
||||
input: details.TeamsChat,
|
||||
expect: TeamsChatsChat,
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
input: details.UnknownType,
|
||||
expect: TeamsChatsCategoryUnknown,
|
||||
},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
|
||||
result := teamsChatsCategoryFromItemType(test.input)
|
||||
assert.Equal(t, test.expect, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *TeamsChatsSelectorSuite) TestCategory_PathType() {
|
||||
table := []struct {
|
||||
cat teamsChatsCategory
|
||||
pathType path.CategoryType
|
||||
}{
|
||||
{TeamsChatsCategoryUnknown, path.UnknownCategory},
|
||||
{TeamsChatsChat, path.ChatsCategory},
|
||||
{TeamsChatsUser, path.UnknownCategory},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.Run(test.cat.String(), func() {
|
||||
assert.Equal(suite.T(), test.pathType, test.cat.PathType())
|
||||
})
|
||||
}
|
||||
}
|
||||
11
src/pkg/selectors/testdata/groups.go
vendored
11
src/pkg/selectors/testdata/groups.go
vendored
@ -4,7 +4,10 @@ import (
|
||||
"github.com/alcionai/corso/src/pkg/selectors"
|
||||
)
|
||||
|
||||
const TestChannelName = "Test"
|
||||
const (
|
||||
TestChannelName = "Test"
|
||||
TestChatTopic = "Test"
|
||||
)
|
||||
|
||||
// GroupsBackupFolderScope is the standard folder scope that should be used
|
||||
// in integration backups with groups when interacting with libraries.
|
||||
@ -24,3 +27,9 @@ func GroupsBackupConversationScope(sel *selectors.GroupsBackup) []selectors.Grou
|
||||
// there's no way to easily specify a test conversation by name.
|
||||
return sel.Conversation(selectors.Any())
|
||||
}
|
||||
|
||||
// TeamsChatsBackupChatScope is the standard folder scope that should be used
|
||||
// in integration backups with teams chats when interacting with chats.
|
||||
func TeamsChatsBackupChatScope(sel *selectors.TeamsChatsBackup) []selectors.TeamsChatsScope {
|
||||
return sel.Chats([]string{TestChatTopic})
|
||||
}
|
||||
|
||||
@ -162,7 +162,7 @@ func channelMessageInfo(
|
||||
modTime = lastReplyAt
|
||||
}
|
||||
|
||||
preview, contentLen, err := getChatMessageContentPreview(msg)
|
||||
preview, contentLen, err := getChatMessageContentPreview(msg, msg)
|
||||
if err != nil {
|
||||
preview = "malformed or unparseable html" + preview
|
||||
}
|
||||
@ -180,7 +180,7 @@ func channelMessageInfo(
|
||||
var lr details.ChannelMessageInfo
|
||||
|
||||
if lastReply != nil {
|
||||
preview, contentLen, err = getChatMessageContentPreview(lastReply)
|
||||
preview, contentLen, err = getChatMessageContentPreview(lastReply, lastReply)
|
||||
if err != nil {
|
||||
preview = "malformed or unparseable html: " + preview
|
||||
}
|
||||
@ -239,12 +239,28 @@ func GetChatMessageFrom(msg models.ChatMessageable) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getChatMessageContentPreview(msg models.ChatMessageable) (string, int64, error) {
|
||||
content, origSize, err := stripChatMessageHTML(msg)
|
||||
// a hack for fulfilling getAttachmentser when the model doesn't
|
||||
// provide GetAttachments()
|
||||
type noAttachments struct{}
|
||||
|
||||
func (noAttachments) GetAttachments() []models.ChatMessageAttachmentable {
|
||||
return []models.ChatMessageAttachmentable{}
|
||||
}
|
||||
|
||||
type getBodyer interface {
|
||||
GetBody() models.ItemBodyable
|
||||
}
|
||||
|
||||
type getAttachmentser interface {
|
||||
GetAttachments() []models.ChatMessageAttachmentable
|
||||
}
|
||||
|
||||
func getChatMessageContentPreview(msg getBodyer, atts getAttachmentser) (string, int64, error) {
|
||||
content, origSize, err := stripChatMessageHTML(msg, atts)
|
||||
return str.Preview(content, 128), origSize, clues.Stack(err).OrNil()
|
||||
}
|
||||
|
||||
func stripChatMessageHTML(msg models.ChatMessageable) (string, int64, error) {
|
||||
func stripChatMessageHTML(msg getBodyer, atts getAttachmentser) (string, int64, error) {
|
||||
var (
|
||||
content string
|
||||
origSize int64
|
||||
@ -256,7 +272,7 @@ func stripChatMessageHTML(msg models.ChatMessageable) (string, int64, error) {
|
||||
|
||||
origSize = int64(len(content))
|
||||
|
||||
content = replaceAttachmentMarkup(content, msg.GetAttachments())
|
||||
content = replaceAttachmentMarkup(content, atts.GetAttachments())
|
||||
content, err := html2text.FromString(content)
|
||||
|
||||
return content, origSize, clues.Stack(err).OrNil()
|
||||
|
||||
@ -712,7 +712,7 @@ func (suite *ChannelsAPIUnitSuite) TestStripChatMessageContent() {
|
||||
msg.SetAttachments(test.attachments)
|
||||
|
||||
// not testing len; it's effectively covered by the content assertion
|
||||
result, _, err := stripChatMessageHTML(msg)
|
||||
result, _, err := stripChatMessageHTML(msg, msg)
|
||||
assert.Equal(t, test.expect, result)
|
||||
test.expectErr(t, err, clues.ToCore(err))
|
||||
})
|
||||
|
||||
@ -12,6 +12,10 @@ type GetLastModifiedDateTimer interface {
|
||||
GetLastModifiedDateTime() *time.Time
|
||||
}
|
||||
|
||||
type GetLastUpdatedDateTimer interface {
|
||||
GetLastUpdatedDateTime() *time.Time
|
||||
}
|
||||
|
||||
type GetAdditionalDataer interface {
|
||||
GetAdditionalData() map[string]any
|
||||
}
|
||||
|
||||
109
src/pkg/services/m365/api/teamsChats.go
Normal file
109
src/pkg/services/m365/api/teamsChats.go
Normal file
@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/chats"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c Client) Chats() Chats {
|
||||
return Chats{c}
|
||||
}
|
||||
|
||||
// Chats is an interface-compliant provider of the client.
|
||||
type Chats struct {
|
||||
Client
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c Chats) GetChatByID(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
cc CallConfig,
|
||||
) (models.Chatable, *details.TeamsChatsInfo, error) {
|
||||
config := &chats.ChatItemRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: &chats.ChatItemRequestBuilderGetQueryParameters{},
|
||||
}
|
||||
|
||||
if len(cc.Select) > 0 {
|
||||
config.QueryParameters.Select = cc.Select
|
||||
}
|
||||
|
||||
if len(cc.Expand) > 0 {
|
||||
config.QueryParameters.Expand = cc.Expand
|
||||
}
|
||||
|
||||
resp, err := c.Stable.
|
||||
Client().
|
||||
Chats().
|
||||
ByChatId(chatID).
|
||||
Get(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, graph.Stack(ctx, err)
|
||||
}
|
||||
|
||||
return resp, TeamsChatInfo(resp), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TeamsChatInfo(chat models.Chatable) *details.TeamsChatsInfo {
|
||||
var (
|
||||
// in case of an empty chat, we want to use Val instead of OrNow
|
||||
lastModTime = ptr.Val(chat.GetLastUpdatedDateTime())
|
||||
lastMsgPreview = chat.GetLastMessagePreview()
|
||||
lastMsgCreatedAt time.Time
|
||||
members = chat.GetMembers()
|
||||
memberNames = []string{}
|
||||
msgs = chat.GetMessages()
|
||||
preview string
|
||||
err error
|
||||
)
|
||||
|
||||
if lastMsgPreview != nil {
|
||||
preview, _, err = getChatMessageContentPreview(lastMsgPreview, noAttachments{})
|
||||
if err != nil {
|
||||
preview = "malformed or unparseable html" + preview
|
||||
}
|
||||
|
||||
// in case of an empty mod time, we want to use the chat's mod time
|
||||
// therefore Val instaed of OrNow
|
||||
lastMsgCreatedAt = ptr.Val(lastMsgPreview.GetCreatedDateTime())
|
||||
if lastModTime.Before(lastMsgCreatedAt) {
|
||||
lastModTime = lastMsgCreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
memberNames = append(memberNames, ptr.Val(m.GetDisplayName()))
|
||||
}
|
||||
|
||||
return &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: lastModTime,
|
||||
|
||||
Chat: details.ChatInfo{
|
||||
CreatedAt: ptr.OrNow(chat.GetCreatedDateTime()),
|
||||
LastMessageAt: lastMsgCreatedAt,
|
||||
LastMessagePreview: preview,
|
||||
Members: memberNames,
|
||||
MessageCount: len(msgs),
|
||||
Topic: ptr.Val(chat.GetTopic()),
|
||||
},
|
||||
}
|
||||
}
|
||||
225
src/pkg/services/m365/api/teamsChats_pager.go
Normal file
225
src/pkg/services/m365/api/teamsChats_pager.go
Normal file
@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/alcionai/clues"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/chats"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/users"
|
||||
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
|
||||
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat members pager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// delta queries are not supported
|
||||
var _ pagers.NonDeltaHandler[models.ConversationMemberable] = &chatMembersPageCtrl{}
|
||||
|
||||
type chatMembersPageCtrl struct {
|
||||
chatID string
|
||||
gs graph.Servicer
|
||||
builder *chats.ItemMembersRequestBuilder
|
||||
options *chats.ItemMembersRequestBuilderGetRequestConfiguration
|
||||
}
|
||||
|
||||
func (p *chatMembersPageCtrl) SetNextLink(nextLink string) {
|
||||
p.builder = chats.NewItemMembersRequestBuilder(nextLink, p.gs.Adapter())
|
||||
}
|
||||
|
||||
func (p *chatMembersPageCtrl) GetPage(
|
||||
ctx context.Context,
|
||||
) (pagers.NextLinkValuer[models.ConversationMemberable], error) {
|
||||
resp, err := p.builder.Get(ctx, p.options)
|
||||
return resp, graph.Stack(ctx, err).OrNil()
|
||||
}
|
||||
|
||||
func (p *chatMembersPageCtrl) ValidModTimes() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c Chats) NewChatMembersPager(
|
||||
chatID string,
|
||||
cc CallConfig,
|
||||
) *chatMembersPageCtrl {
|
||||
builder := c.Stable.
|
||||
Client().
|
||||
Chats().
|
||||
ByChatId(chatID).
|
||||
Members()
|
||||
|
||||
options := &chats.ItemMembersRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: &chats.ItemMembersRequestBuilderGetQueryParameters{},
|
||||
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)),
|
||||
}
|
||||
|
||||
if len(cc.Select) > 0 {
|
||||
options.QueryParameters.Select = cc.Select
|
||||
}
|
||||
|
||||
if len(cc.Expand) > 0 {
|
||||
options.QueryParameters.Expand = cc.Expand
|
||||
}
|
||||
|
||||
return &chatMembersPageCtrl{
|
||||
chatID: chatID,
|
||||
builder: builder,
|
||||
gs: c.Stable,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// GetChatMembers fetches a delta of all members in the chat.
|
||||
func (c Chats) GetChatMembers(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
cc CallConfig,
|
||||
) ([]models.ConversationMemberable, error) {
|
||||
ctx = clues.Add(ctx, "chat_id", chatID)
|
||||
pager := c.NewChatMembersPager(chatID, cc)
|
||||
items, err := pagers.BatchEnumerateItems[models.ConversationMemberable](ctx, pager)
|
||||
|
||||
return items, graph.Stack(ctx, err).OrNil()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat message pager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// delta queries are not supported
|
||||
var _ pagers.NonDeltaHandler[models.ChatMessageable] = &chatMessagePageCtrl{}
|
||||
|
||||
type chatMessagePageCtrl struct {
|
||||
chatID string
|
||||
gs graph.Servicer
|
||||
builder *chats.ItemMessagesRequestBuilder
|
||||
options *chats.ItemMessagesRequestBuilderGetRequestConfiguration
|
||||
}
|
||||
|
||||
func (p *chatMessagePageCtrl) SetNextLink(nextLink string) {
|
||||
p.builder = chats.NewItemMessagesRequestBuilder(nextLink, p.gs.Adapter())
|
||||
}
|
||||
|
||||
func (p *chatMessagePageCtrl) GetPage(
|
||||
ctx context.Context,
|
||||
) (pagers.NextLinkValuer[models.ChatMessageable], error) {
|
||||
resp, err := p.builder.Get(ctx, p.options)
|
||||
return resp, graph.Stack(ctx, err).OrNil()
|
||||
}
|
||||
|
||||
func (p *chatMessagePageCtrl) ValidModTimes() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c Chats) NewChatMessagePager(
|
||||
chatID string,
|
||||
cc CallConfig,
|
||||
) *chatMessagePageCtrl {
|
||||
builder := c.Stable.
|
||||
Client().
|
||||
Chats().
|
||||
ByChatId(chatID).
|
||||
Messages()
|
||||
|
||||
options := &chats.ItemMessagesRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: &chats.ItemMessagesRequestBuilderGetQueryParameters{},
|
||||
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)),
|
||||
}
|
||||
|
||||
if len(cc.Select) > 0 {
|
||||
options.QueryParameters.Select = cc.Select
|
||||
}
|
||||
|
||||
if len(cc.Expand) > 0 {
|
||||
options.QueryParameters.Expand = cc.Expand
|
||||
}
|
||||
|
||||
return &chatMessagePageCtrl{
|
||||
chatID: chatID,
|
||||
builder: builder,
|
||||
gs: c.Stable,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// GetChatMessages fetches a delta of all messages in the chat.
|
||||
func (c Chats) GetChatMessages(
|
||||
ctx context.Context,
|
||||
chatID string,
|
||||
cc CallConfig,
|
||||
) ([]models.ChatMessageable, error) {
|
||||
ctx = clues.Add(ctx, "chat_id", chatID)
|
||||
pager := c.NewChatMessagePager(chatID, cc)
|
||||
items, err := pagers.BatchEnumerateItems[models.ChatMessageable](ctx, pager)
|
||||
|
||||
return items, graph.Stack(ctx, err).OrNil()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat pager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var _ pagers.NonDeltaHandler[models.Chatable] = &chatPageCtrl{}
|
||||
|
||||
type chatPageCtrl struct {
|
||||
gs graph.Servicer
|
||||
builder *users.ItemChatsRequestBuilder
|
||||
options *users.ItemChatsRequestBuilderGetRequestConfiguration
|
||||
}
|
||||
|
||||
func (p *chatPageCtrl) SetNextLink(nextLink string) {
|
||||
p.builder = users.NewItemChatsRequestBuilder(nextLink, p.gs.Adapter())
|
||||
}
|
||||
|
||||
func (p *chatPageCtrl) GetPage(
|
||||
ctx context.Context,
|
||||
) (pagers.NextLinkValuer[models.Chatable], error) {
|
||||
resp, err := p.builder.Get(ctx, p.options)
|
||||
return resp, graph.Stack(ctx, err).OrNil()
|
||||
}
|
||||
|
||||
func (p *chatPageCtrl) ValidModTimes() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Chats) NewChatPager(
|
||||
userID string,
|
||||
cc CallConfig,
|
||||
) *chatPageCtrl {
|
||||
options := &users.ItemChatsRequestBuilderGetRequestConfiguration{
|
||||
QueryParameters: &users.ItemChatsRequestBuilderGetQueryParameters{},
|
||||
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)),
|
||||
}
|
||||
|
||||
if len(cc.Select) > 0 {
|
||||
options.QueryParameters.Select = cc.Select
|
||||
}
|
||||
|
||||
if len(cc.Expand) > 0 {
|
||||
options.QueryParameters.Expand = cc.Expand
|
||||
}
|
||||
|
||||
res := &chatPageCtrl{
|
||||
gs: c.Stable,
|
||||
options: options,
|
||||
builder: c.Stable.
|
||||
Client().
|
||||
Users().
|
||||
ByUserId(userID).
|
||||
Chats(),
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetChats fetches all chats in the team.
|
||||
func (c Chats) GetChats(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
cc CallConfig,
|
||||
) ([]models.Chatable, error) {
|
||||
return pagers.BatchEnumerateItems[models.Chatable](ctx, c.NewChatPager(userID, cc))
|
||||
}
|
||||
149
src/pkg/services/m365/api/teamsChats_pager_test.go
Normal file
149
src/pkg/services/m365/api/teamsChats_pager_test.go
Normal file
@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/internal/tester/tconfig"
|
||||
)
|
||||
|
||||
type ChatsPagerIntgSuite struct {
|
||||
tester.Suite
|
||||
its intgTesterSetup
|
||||
}
|
||||
|
||||
func TestChatsPagerIntgSuite(t *testing.T) {
|
||||
suite.Run(t, &ChatsPagerIntgSuite{
|
||||
Suite: tester.NewIntegrationSuite(
|
||||
t,
|
||||
[][]string{tconfig.M365AcctCredEnvs}),
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *ChatsPagerIntgSuite) SetupSuite() {
|
||||
suite.its = newIntegrationTesterSetup(suite.T())
|
||||
}
|
||||
|
||||
func (suite *ChatsPagerIntgSuite) TestEnumerateChats() {
|
||||
var (
|
||||
t = suite.T()
|
||||
ac = suite.its.ac.Chats()
|
||||
)
|
||||
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
cc := CallConfig{
|
||||
Expand: []string{"lastMessagePreview"},
|
||||
}
|
||||
|
||||
chats, err := ac.GetChats(ctx, suite.its.user.id, cc)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
require.NotEmpty(t, chats)
|
||||
|
||||
for _, chat := range chats {
|
||||
chatID := ptr.Val(chat.GetId())
|
||||
|
||||
suite.Run("chat_"+chatID, func() {
|
||||
testGetChatByID(suite.T(), ac, chatID)
|
||||
})
|
||||
|
||||
suite.Run("chat_messages_"+chatID, func() {
|
||||
testEnumerateChatMessages(
|
||||
suite.T(),
|
||||
ac,
|
||||
chatID,
|
||||
chat.GetLastMessagePreview())
|
||||
|
||||
testEnumerateChatMembers(
|
||||
suite.T(),
|
||||
ac,
|
||||
chatID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGetChatByID(
|
||||
t *testing.T,
|
||||
ac Chats,
|
||||
chatID string,
|
||||
) {
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
cc := CallConfig{}
|
||||
|
||||
chat, _, err := ac.GetChatByID(ctx, chatID, cc)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
require.NotNil(t, chat)
|
||||
}
|
||||
|
||||
func testEnumerateChatMessages(
|
||||
t *testing.T,
|
||||
ac Chats,
|
||||
chatID string,
|
||||
lastMessagePreview models.ChatMessageInfoable,
|
||||
) {
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
cc := CallConfig{}
|
||||
|
||||
messages, err := ac.GetChatMessages(ctx, chatID, cc)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
var lastID string
|
||||
if lastMessagePreview != nil {
|
||||
lastID = ptr.Val(lastMessagePreview.GetId())
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
msgID := ptr.Val(msg.GetId())
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
chatID,
|
||||
ptr.Val(msg.GetChatId()),
|
||||
"message:",
|
||||
msgID)
|
||||
|
||||
if msgID == lastID {
|
||||
previewContent := ptr.Val(lastMessagePreview.GetBody().GetContent())
|
||||
msgContent := ptr.Val(msg.GetBody().GetContent())
|
||||
|
||||
previewContent = replaceAttachmentMarkup(previewContent, nil)
|
||||
msgContent = replaceAttachmentMarkup(msgContent, nil)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
previewContent,
|
||||
msgContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testEnumerateChatMembers(
|
||||
t *testing.T,
|
||||
ac Chats,
|
||||
chatID string,
|
||||
) {
|
||||
ctx, flush := tester.NewContext(t)
|
||||
defer flush()
|
||||
|
||||
cc := CallConfig{}
|
||||
|
||||
members, err := ac.GetChatMembers(ctx, chatID, cc)
|
||||
require.NoError(t, err, clues.ToCore(err))
|
||||
|
||||
// no good way to test members right now. Even though
|
||||
// the graph api response contains the `userID` and `email`
|
||||
// properties, we can't access them in the sdk model
|
||||
assert.NotEmpty(t, members)
|
||||
}
|
||||
158
src/pkg/services/m365/api/teamsChats_test.go
Normal file
158
src/pkg/services/m365/api/teamsChats_test.go
Normal file
@ -0,0 +1,158 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/alcionai/corso/src/internal/common/ptr"
|
||||
"github.com/alcionai/corso/src/internal/m365/collection/teamschats/testdata"
|
||||
"github.com/alcionai/corso/src/internal/tester"
|
||||
"github.com/alcionai/corso/src/pkg/backup/details"
|
||||
)
|
||||
|
||||
type ChatsAPIUnitSuite struct {
|
||||
tester.Suite
|
||||
}
|
||||
|
||||
func TestChatsAPIUnitSuite(t *testing.T) {
|
||||
suite.Run(t, &ChatsAPIUnitSuite{Suite: tester.NewUnitSuite(t)})
|
||||
}
|
||||
|
||||
func (suite *ChatsAPIUnitSuite) TestChatsInfo() {
|
||||
start := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected func() (models.Chatable, *details.TeamsChatsInfo)
|
||||
}{
|
||||
{
|
||||
name: "Empty chat",
|
||||
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
|
||||
chat := models.NewChat()
|
||||
|
||||
i := &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: ptr.Val(chat.GetLastUpdatedDateTime()),
|
||||
Chat: details.ChatInfo{},
|
||||
}
|
||||
|
||||
return chat, i
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All fields",
|
||||
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
|
||||
now := time.Now()
|
||||
then := now.Add(1 * time.Hour)
|
||||
id := uuid.NewString()
|
||||
|
||||
chat := testdata.StubChats(id)[0]
|
||||
chat.SetTopic(ptr.To("Hello world"))
|
||||
chat.SetCreatedDateTime(&now)
|
||||
chat.SetLastUpdatedDateTime(&now)
|
||||
chat.GetLastMessagePreview().SetCreatedDateTime(&then)
|
||||
|
||||
msgs := testdata.StubChatMessages(ptr.Val(chat.GetLastMessagePreview().GetId()))
|
||||
chat.SetMessages(msgs)
|
||||
|
||||
i := &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: then,
|
||||
Chat: details.ChatInfo{
|
||||
Topic: "Hello world",
|
||||
LastMessageAt: then,
|
||||
LastMessagePreview: id,
|
||||
Members: []string{},
|
||||
MessageCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
return chat, i
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last message preview, but no messages",
|
||||
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
|
||||
now := time.Now()
|
||||
then := now.Add(1 * time.Hour)
|
||||
id := uuid.NewString()
|
||||
|
||||
chat := testdata.StubChats(id)[0]
|
||||
chat.SetTopic(ptr.To("Hello world"))
|
||||
chat.SetCreatedDateTime(&now)
|
||||
chat.SetLastUpdatedDateTime(&now)
|
||||
chat.GetLastMessagePreview().SetCreatedDateTime(&then)
|
||||
|
||||
i := &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: then,
|
||||
Chat: details.ChatInfo{
|
||||
Topic: "Hello world",
|
||||
LastMessageAt: then,
|
||||
LastMessagePreview: id,
|
||||
Members: []string{},
|
||||
MessageCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
return chat, i
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chat only, no messages",
|
||||
expected: func() (models.Chatable, *details.TeamsChatsInfo) {
|
||||
now := time.Now()
|
||||
then := now.Add(1 * time.Hour)
|
||||
|
||||
chat := testdata.StubChats(uuid.NewString())[0]
|
||||
chat.SetTopic(ptr.To("Hello world"))
|
||||
chat.SetCreatedDateTime(&now)
|
||||
chat.SetLastUpdatedDateTime(&then)
|
||||
chat.SetLastMessagePreview(nil)
|
||||
chat.SetMessages(nil)
|
||||
|
||||
i := &details.TeamsChatsInfo{
|
||||
ItemType: details.TeamsChat,
|
||||
Modified: then,
|
||||
Chat: details.ChatInfo{
|
||||
Topic: "Hello world",
|
||||
LastMessageAt: time.Time{},
|
||||
LastMessagePreview: "",
|
||||
Members: []string{},
|
||||
MessageCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
return chat, i
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
suite.Run(test.name, func() {
|
||||
t := suite.T()
|
||||
chat, expected := test.expected()
|
||||
result := TeamsChatInfo(chat)
|
||||
|
||||
assert.Equal(t, expected.Chat.Topic, result.Chat.Topic)
|
||||
|
||||
expectCreated := chat.GetCreatedDateTime()
|
||||
if expectCreated != nil {
|
||||
assert.Equal(t, ptr.Val(expectCreated), result.Chat.CreatedAt)
|
||||
} else {
|
||||
assert.True(t, result.Chat.CreatedAt.After(start))
|
||||
}
|
||||
|
||||
assert.Truef(
|
||||
t,
|
||||
expected.Modified.Equal(result.Modified),
|
||||
"modified time doesn't match\nexpected %v\ngot %v",
|
||||
expected.Modified,
|
||||
result.Modified)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user