Compare commits

...

16 Commits

Author SHA1 Message Date
ryanfkeepers
e298881a6c add chat members to the backup
lazily fetch the chat members and add them to the chat
during backup item downloads.
2024-01-24 16:20:15 -07:00
ryanfkeepers
14817de592 get details working (and other cleanup)
details wasn't properly listing backed up items.  This fixes
the details display, and contains some code clean-up that occurred
along the way.
2024-01-24 15:17:14 -07:00
ryanfkeepers
4cba77343a lazily fetch messages in chat
once we're past kopia assits, the chat can
download all of its messages and store them in the body
uploded to kopia.
2024-01-24 12:48:13 -07:00
ryanfkeepers
94b02ed6f3 fix item filtering, remove prefetch coll 2024-01-24 12:47:38 -07:00
ryanfkeepers
7e3d5d2074 fixes for running backup chats from the cli
Some necessary changes to get a green backup creation from the cli.
2024-01-23 17:01:24 -07:00
ryanfkeepers
bd50d8eeaa adds boilerplate cli for chats backup
All code is copied and amended from existing cli boilerplate.
2024-01-23 16:13:14 -07:00
ryanfkeepers
7d9d0e04cc add teamschats to m365 backup
tests will arrive in a later PR
2024-01-23 14:34:43 -07:00
ryanfkeepers
9831fb8f91 fix package name 2024-01-23 13:16:20 -07:00
ryanfkeepers
db14816ac8 add teamschat boilerplate to m365/service 2024-01-22 17:19:42 -07:00
ryanfkeepers
8344fe60be some linter fixes and folder/file renames 2024-01-22 16:57:57 -07:00
ryanfkeepers
a17ed85220 boilerplate teamschat collection package
seems like a lot of code, but this is 95% boilerplate additions copied
from other similar collections packages.  Some noteworthy differences:
* teamsChats does not handle metadata, so all metadata, delta, and previous
path handling was removed
* teamsChats does not produce tombstones
* chats are never deleted, so no "removed" items are tracked
* all chats gets stored at the prefix root, so no "containers" are iterated, and therefore only one collection is ever produced.

This means that, overall, the boilerplate here is much reduced compared to
boilerplate in similar packages.
2024-01-22 16:46:53 -07:00
ryanfkeepers
c999a3072f add boilerplate chats api calls
standard graph api boilerplate.
2024-01-19 17:38:01 -07:00
ryanfkeepers
0a6355eeb1 add teams chats selectors boilerplate
standard boilerplate additions, largely copy-paste and renamed from other files.
2024-01-19 13:36:00 -07:00
ryanfkeepers
07997b0987 add chats boilerplate to details 2024-01-18 17:58:08 -07:00
ryanfkeepers
eefce75f1d bump service name 2024-01-18 16:47:58 -07:00
ryanfkeepers
f61045f5ea add chats service and category to paths
introduces the Chats service and Chats category
2024-01-18 16:28:24 -07:00
55 changed files with 5500 additions and 169 deletions

View File

@ -46,6 +46,7 @@ var serviceCommands = []func(cmd *cobra.Command) *cobra.Command{
addOneDriveCommands, addOneDriveCommands,
addSharePointCommands, addSharePointCommands,
addGroupsCommands, addGroupsCommands,
addTeamsChatsCommands,
} }
// AddCommands attaches all `corso backup * *` commands to the parent. // AddCommands attaches all `corso backup * *` commands to the parent.

View File

@ -310,7 +310,7 @@ func groupsBackupCreateSelectors(
group, cats []string, group, cats []string,
) *selectors.GroupsBackup { ) *selectors.GroupsBackup {
if filters.PathContains(group).Compare(flags.Wildcard) { if filters.PathContains(group).Compare(flags.Wildcard) {
return includeAllGroupWithCategories(ins, cats) return includeAllGroupsWithCategories(ins, cats)
} }
sel := selectors.NewGroupsBackup(slices.Clone(group)) sel := selectors.NewGroupsBackup(slices.Clone(group))
@ -318,6 +318,6 @@ func groupsBackupCreateSelectors(
return utils.AddGroupsCategories(sel, cats) 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) return utils.AddGroupsCategories(selectors.NewGroupsBackup(ins.IDs()), categories)
} }

View 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 service 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 TeamsChats service 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 TeamsChats service 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 TeamsChats service 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)
}

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

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

View File

@ -0,0 +1,13 @@
package flags
import (
"github.com/spf13/cobra"
)
const (
DataChats = "chats"
)
func AddTeamsChatsDetailsAndRestoreFlags(cmd *cobra.Command) {
// TODO: add details flags
}

View File

@ -21,6 +21,7 @@ var (
ExchangeCategoryDataInput = []string{"email", "events", "contacts"} ExchangeCategoryDataInput = []string{"email", "events", "contacts"}
SharepointCategoryDataInput = []string{"files", "lists", "pages"} SharepointCategoryDataInput = []string{"files", "lists", "pages"}
GroupsCategoryDataInput = []string{"files", "lists", "pages", "messages"} GroupsCategoryDataInput = []string{"files", "lists", "pages", "messages"}
TeamsChatsCategoryDataInput = []string{"chats"}
ChannelInput = []string{"channel1", "channel2"} ChannelInput = []string{"channel1", "channel2"}
MessageInput = []string{"message1", "message2"} MessageInput = []string{"message1", "message2"}

25
src/cli/flags/testdata/teamschats.go vendored Normal file
View 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
View 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
}

View File

@ -13,7 +13,10 @@ import (
"github.com/alcionai/corso/src/internal/m365/service/groups" "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/onedrive"
"github.com/alcionai/corso/src/internal/m365/service/sharepoint" "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/internal/operations/inject"
"github.com/alcionai/corso/src/pkg/account"
bupMD "github.com/alcionai/corso/src/pkg/backup/metadata" bupMD "github.com/alcionai/corso/src/pkg/backup/metadata"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/count" "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/filters"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/selectors" "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/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 // Data Collections
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -63,67 +88,40 @@ func (ctrl *Controller) ProduceBackupCollections(
canUsePreviousBackup bool canUsePreviousBackup bool
) )
var handler backupHandler
switch service { switch service {
case path.ExchangeService: case path.ExchangeService:
colls, excludeItems, canUsePreviousBackup, err = exchange.ProduceBackupCollections( handler = exchange.NewBackup()
ctx,
bpc,
ctrl.AC,
ctrl.credentials,
ctrl.UpdateStatus,
counter,
errs)
if err != nil {
return nil, nil, false, err
}
case path.OneDriveService: case path.OneDriveService:
colls, excludeItems, canUsePreviousBackup, err = onedrive.ProduceBackupCollections( handler = onedrive.NewBackup()
ctx,
bpc,
ctrl.AC,
ctrl.credentials,
ctrl.UpdateStatus,
counter,
errs)
if err != nil {
return nil, nil, false, err
}
case path.SharePointService: case path.SharePointService:
colls, excludeItems, canUsePreviousBackup, err = sharepoint.ProduceBackupCollections( handler = sharepoint.NewBackup()
ctx,
bpc,
ctrl.AC,
ctrl.credentials,
ctrl.UpdateStatus,
counter,
errs)
if err != nil {
return nil, nil, false, err
}
case path.GroupsService: case path.GroupsService:
colls, excludeItems, err = groups.ProduceBackupCollections( handler = groups.NewBackup()
ctx,
bpc,
ctrl.AC,
ctrl.credentials,
ctrl.UpdateStatus,
counter,
errs)
if err != nil {
return nil, nil, false, err
}
// canUsePreviousBacukp can be always returned true for groups as we case path.TeamsChatsService:
// return a tombstone collection in case the metadata read fails handler = teamschats.NewBackup()
canUsePreviousBackup = true
default: default:
return nil, nil, false, clues.Wrap(clues.NewWC(ctx, service.String()), "service not supported") 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 { for _, c := range colls {
// kopia doesn't stream Items() from deleted collections, // kopia doesn't stream Items() from deleted collections,
// and so they never end up calling the UpdateStatus closer. // 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) return sharepoint.IsServiceEnabled(ctx, ctrl.AC.Sites(), resourceOwner)
case path.GroupsService: case path.GroupsService:
return groups.IsServiceEnabled(ctx, ctrl.AC.Groups(), resourceOwner) 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") 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 var ids []string
switch sels.Service { switch sel.Service {
case selectors.ServiceExchange, selectors.ServiceOneDrive: case selectors.ServiceExchange, selectors.ServiceOneDrive:
// Exchange and OneDrive user existence now checked in checkServiceEnabled. // Exchange and OneDrive user existence now checked in checkServiceEnabled.
return nil return nil
case selectors.ServiceSharePoint, selectors.ServiceGroups: case selectors.ServiceSharePoint, selectors.ServiceGroups, selectors.ServiceTeamsChats:
ids = cachedIDs ids = cachedIDs
} }
if !filters.Contains(ids).Compare(sels.ID()) { if !filters.Contains(ids).Compare(sel.ID()) {
return clues.Stack(core.ErrNotFound).With("selector_protected_resource", sels.DiscreteOwner) return clues.Stack(core.ErrNotFound).With("selector_protected_resource", sel.ID())
} }
return nil return nil

View File

@ -139,7 +139,7 @@ func (suite *DataCollectionIntgSuite) TestExchangeDataCollection() {
Selector: sel, Selector: sel,
} }
collections, excludes, canUsePreviousBackup, err := exchange.ProduceBackupCollections( collections, excludes, canUsePreviousBackup, err := exchange.NewBackup().ProduceBackupCollections(
ctx, ctx,
bpc, bpc,
suite.ac, suite.ac,
@ -309,7 +309,7 @@ func (suite *DataCollectionIntgSuite) TestSharePointDataCollection() {
Selector: sel, Selector: sel,
} }
collections, excludes, canUsePreviousBackup, err := sharepoint.ProduceBackupCollections( collections, excludes, canUsePreviousBackup, err := sharepoint.NewBackup().ProduceBackupCollections(
ctx, ctx,
bpc, bpc,
suite.ac, suite.ac,

View File

@ -0,0 +1,163 @@
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"
"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,
}
)
cc := api.CallConfig{
CanMakeDeltaQueries: false,
}
container, err := bh.getContainer(ctx, cc)
if err != nil {
return nil, false, clues.Stack(err)
}
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,
container.humanLocation.Builder(),
ctrlOpts,
false,
cl),
bh,
qp.ProtectedResource.ID(),
includedItems,
container,
statusUpdater)
return collection, clues.Stack(errs.Failure()).OrNil()
}

View File

@ -0,0 +1,355 @@
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"
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 chatContainer()
}
//lint:ignore U1000 required for interface compliance
func (bh mockBackupHandler) getContainer(
context.Context,
api.CallConfig,
) (container[models.Chatable], error) {
return chatContainer(), nil
}
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) getItem(
_ context.Context,
_ string,
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}
handler = NewUsersChatsBackupHandler(tenant, protectedResource, 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")
})
}
}

View File

@ -0,0 +1,117 @@
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"
)
var _ backupHandler[models.Chatable] = &usersChatsBackupHandler{}
type usersChatsBackupHandler struct {
ac api.Chats
protectedResourceID string
tenantID string
}
func NewUsersChatsBackupHandler(
tenantID, protectedResourceID string,
ac api.Chats,
) usersChatsBackupHandler {
return usersChatsBackupHandler{
ac: ac,
protectedResourceID: protectedResourceID,
tenantID: tenantID,
}
}
// chats have no containers. Everything is stored at the root.
//
//lint:ignore U1000 required for interface compliance
func (bh usersChatsBackupHandler) getContainer(
ctx context.Context,
_ api.CallConfig,
) (container[models.Chatable], error) {
return chatContainer(), nil
}
//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.protectedResourceID,
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.tenantID,
bh.protectedResourceID,
path.TeamsChatsService,
path.ChatsCategory)
}
//lint:ignore U1000 false linter issue due to generics
func (bh usersChatsBackupHandler) getItem(
ctx context.Context,
userID string,
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
}
func chatContainer() container[models.Chatable] {
return container[models.Chatable]{
storageDirFolders: path.Elements{},
humanLocation: path.Elements{},
}
}

View File

@ -0,0 +1,256 @@
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,
getter getItemer[I],
protectedResource string,
items []I,
contains container[I],
statusUpdater support.StatusUpdater,
) data.BackupCollection {
return &lazyFetchCollection[I]{
BaseCollection: baseCol,
items: items,
contains: contains,
getter: getter,
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
getter getItemer[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,
&lazyItemGetter[I]{
modTime: modTime,
getter: col.getter,
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 lazyItemGetter[I chatsItemer] struct {
getter getItemer[I]
resourceID string
item I
parentPath string
containerIDs path.Elements
modTime time.Time
contains container[I]
}
func (lig *lazyItemGetter[I]) GetData(
ctx context.Context,
errs *fault.Bus,
) (io.ReadCloser, *details.ItemInfo, bool, error) {
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
item, info, err := lig.getter.getItem(
ctx,
lig.resourceID,
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
}

View File

@ -0,0 +1,400 @@
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 getAndAugmentChat struct {
err error
}
//lint:ignore U1000 false linter issue due to generics
func (m getAndAugmentChat) getItem(
_ context.Context,
_ string,
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()
getterAugmenter := &getAndAugmentChat{}
col := &lazyFetchCollection[models.Chatable]{
BaseCollection: data.NewBaseCollection(
fullPath,
nil,
locPath.ToBuilder(),
control.DefaultOptions(),
false,
count.New()),
items: test.items,
contains: container[models.Chatable]{},
getter: getterAugmenter,
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 := getAndAugmentChat{
err: test.getErr,
}
li := data.NewLazyItemWithInfo(
ctx,
&lazyItemGetter[models.Chatable]{
resourceID: "resourceID",
item: chat,
getter: &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 := getAndAugmentChat{
err: core.ErrNotFound,
}
li := data.NewLazyItemWithInfo(
ctx,
&lazyItemGetter[models.Chatable]{
resourceID: "resourceID",
item: chat,
getter: &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 := getAndAugmentChat{}
li := data.NewLazyItemWithInfo(
ctx,
&lazyItemGetter[models.Chatable]{
resourceID: "resourceID",
item: chat,
getter: &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())
}

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

View File

@ -0,0 +1,81 @@
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"
"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]
getItemer[I]
getItemer[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(
ctx context.Context,
cc api.CallConfig,
) (container[I], error)
}
// gets all item IDs in the container
type getItemIDser[I chatsItemer] interface {
getItemIDs(
ctx context.Context,
) ([]I, error)
}
type getItemer[I chatsItemer] interface {
getItem(
ctx context.Context,
protectedResource string,
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 {
storageDirFolders path.Elements
humanLocation path.Elements
container I
}

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

View File

@ -112,7 +112,7 @@ func (ctrl *Controller) setResourceHandler(
var rh *resourceGetter var rh *resourceGetter
switch serviceInOperation { switch serviceInOperation {
case path.ExchangeService, path.OneDriveService: case path.ExchangeService, path.OneDriveService, path.TeamsChatsService:
rh = &resourceGetter{ rh = &resourceGetter{
enum: resource.Users, enum: resource.Users,
getter: ctrl.AC.Users(), getter: ctrl.AC.Users(),

View File

@ -9,6 +9,7 @@ import (
"github.com/alcionai/corso/src/internal/m365/collection/drive" "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/exchange"
"github.com/alcionai/corso/src/internal/m365/collection/groups" "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/count"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/store" "github.com/alcionai/corso/src/pkg/store"
@ -34,6 +35,8 @@ func (ctrl *Controller) DeserializeMetadataFiles(
return drive.DeserializeMetadataFiles(ctx, colls, count.New()) return drive.DeserializeMetadataFiles(ctx, colls, count.New())
case path.GroupsService, path.GroupsMetadataService: case path.GroupsService, path.GroupsMetadataService:
return groups.DeserializeMetadataFiles(ctx, colls) return groups.DeserializeMetadataFiles(ctx, colls)
case path.TeamsChatsService, path.TeamsChatsMetadataService:
return teamschats.DeserializeMetadataFiles(ctx, colls)
default: default:
return nil, clues.NewWC(ctx, "unrecognized service").With("service", service) return nil, clues.NewWC(ctx, "unrecognized service").With("service", service)
} }

View File

@ -19,9 +19,17 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "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 // ProduceBackupCollections returns a DataCollection which the caller can
// use to read mailbox data out for the specified user // use to read mailbox data out for the specified user
func ProduceBackupCollections( func (exchangeBackup) ProduceBackupCollections(
ctx context.Context, ctx context.Context,
bpc inject.BackupProducerConfig, bpc inject.BackupProducerConfig,
ac api.Client, ac api.Client,

View File

@ -33,7 +33,15 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "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, ctx context.Context,
bpc inject.BackupProducerConfig, bpc inject.BackupProducerConfig,
ac api.Client, ac api.Client,
@ -41,10 +49,10 @@ func ProduceBackupCollections(
su support.StatusUpdater, su support.StatusUpdater,
counter *count.Bus, counter *count.Bus,
errs *fault.Bus, errs *fault.Bus,
) ([]data.BackupCollection, *prefixmatcher.StringSetMatcher, error) { ) ([]data.BackupCollection, *prefixmatcher.StringSetMatcher, bool, error) {
b, err := bpc.Selector.ToGroupsBackup() b, err := bpc.Selector.ToGroupsBackup()
if err != nil { if err != nil {
return nil, nil, clues.Wrap(err, "groupsDataCollection: parsing selector") return nil, nil, true, clues.Wrap(err, "groupsDataCollection: parsing selector")
} }
var ( var (
@ -65,7 +73,7 @@ func ProduceBackupCollections(
bpc.ProtectedResource.ID(), bpc.ProtectedResource.ID(),
api.CallConfig{}) api.CallConfig{})
if err != nil { 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} bc := backupCommon{ac, bpc, creds, group, sitesPreviousPaths, su}
@ -128,7 +136,7 @@ func ProduceBackupCollections(
counter, counter,
errs) errs)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, true, err
} }
collections = append(collections, baseCols...) collections = append(collections, baseCols...)
@ -142,7 +150,7 @@ func ProduceBackupCollections(
su, su,
counter) counter)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, true, err
} }
collections = append(collections, md) collections = append(collections, md)
@ -151,7 +159,7 @@ func ProduceBackupCollections(
logger.Ctx(ctx).Infow("produced collections", "stats", counter.Values()) 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 { type backupCommon struct {

View File

@ -22,7 +22,15 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "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, ctx context.Context,
bpc inject.BackupProducerConfig, bpc inject.BackupProducerConfig,
ac api.Client, ac api.Client,

View File

@ -20,7 +20,15 @@ import (
"github.com/alcionai/corso/src/pkg/services/m365/api/graph" "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, ctx context.Context,
bpc inject.BackupProducerConfig, bpc inject.BackupProducerConfig,
ac api.Client, ac api.Client,

View File

@ -0,0 +1,168 @@
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)
bh := teamschats.NewUsersChatsBackupHandler(
bc.creds.AzureTenantID,
bc.producerConfig.ProtectedResource.ID(),
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()
}

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

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

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

View File

@ -141,26 +141,23 @@ func (de Entry) MinimumPrintable() any {
// Headers returns the human-readable names of properties in a DetailsEntry // Headers returns the human-readable names of properties in a DetailsEntry
// for printing out to a terminal in a columnar display. // for printing out to a terminal in a columnar display.
func (de Entry) Headers(skipID bool) []string { 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() hs = de.ItemInfo.Folder.Headers()
} case de.ItemInfo.Exchange != nil:
if de.ItemInfo.Exchange != nil {
hs = de.ItemInfo.Exchange.Headers() hs = de.ItemInfo.Exchange.Headers()
} case de.ItemInfo.SharePoint != nil:
if de.ItemInfo.SharePoint != nil {
hs = de.ItemInfo.SharePoint.Headers() hs = de.ItemInfo.SharePoint.Headers()
} case de.ItemInfo.OneDrive != nil:
if de.ItemInfo.OneDrive != nil {
hs = de.ItemInfo.OneDrive.Headers() hs = de.ItemInfo.OneDrive.Headers()
} case de.ItemInfo.Groups != nil:
if de.ItemInfo.Groups != nil {
hs = de.ItemInfo.Groups.Headers() hs = de.ItemInfo.Groups.Headers()
case de.ItemInfo.TeamsChats != nil:
hs = de.ItemInfo.TeamsChats.Headers()
default:
hs = []string{"ERROR - Service not recognized"}
} }
if skipID { if skipID {
@ -172,26 +169,23 @@ func (de Entry) Headers(skipID bool) []string {
// Values returns the values matching the Headers list. // Values returns the values matching the Headers list.
func (de Entry) Values(skipID bool) []string { 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() vs = de.ItemInfo.Folder.Values()
} case de.ItemInfo.Exchange != nil:
if de.ItemInfo.Exchange != nil {
vs = de.ItemInfo.Exchange.Values() vs = de.ItemInfo.Exchange.Values()
} case de.ItemInfo.SharePoint != nil:
if de.ItemInfo.SharePoint != nil {
vs = de.ItemInfo.SharePoint.Values() vs = de.ItemInfo.SharePoint.Values()
} case de.ItemInfo.OneDrive != nil:
if de.ItemInfo.OneDrive != nil {
vs = de.ItemInfo.OneDrive.Values() vs = de.ItemInfo.OneDrive.Values()
} case de.ItemInfo.Groups != nil:
if de.ItemInfo.Groups != nil {
vs = de.ItemInfo.Groups.Values() vs = de.ItemInfo.Groups.Values()
case de.ItemInfo.TeamsChats != nil:
vs = de.ItemInfo.TeamsChats.Values()
default:
vs = []string{"ERROR - Service not recognized"}
} }
if skipID { if skipID {

View File

@ -78,7 +78,7 @@ type ChannelMessageInfo struct {
Subject string `json:"subject,omitempty"` 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. // for printing out to a terminal in a columnar display.
func (i GroupsInfo) Headers() []string { func (i GroupsInfo) Headers() []string {
switch i.ItemType { switch i.ItemType {

View File

@ -41,6 +41,9 @@ const (
// Groups/Teams(40x) // Groups/Teams(40x)
GroupsChannelMessage ItemType = 401 GroupsChannelMessage ItemType = 401
GroupsConversationPost ItemType = 402 GroupsConversationPost ItemType = 402
// Teams Chat
TeamsChat ItemType = 501
) )
func UpdateItem(item *ItemInfo, newLocPath *path.Builder) { func UpdateItem(item *ItemInfo, newLocPath *path.Builder) {
@ -73,6 +76,7 @@ type ItemInfo struct {
SharePoint *SharePointInfo `json:"sharePoint,omitempty"` SharePoint *SharePointInfo `json:"sharePoint,omitempty"`
OneDrive *OneDriveInfo `json:"oneDrive,omitempty"` OneDrive *OneDriveInfo `json:"oneDrive,omitempty"`
Groups *GroupsInfo `json:"groups,omitempty"` Groups *GroupsInfo `json:"groups,omitempty"`
TeamsChats *TeamsChatsInfo `json:"teamsChats,omitempty"`
// Optional item extension data // Optional item extension data
Extension *ExtensionData `json:"extension,omitempty"` Extension *ExtensionData `json:"extension,omitempty"`
} }
@ -87,18 +91,16 @@ func (i ItemInfo) infoType() ItemType {
switch { switch {
case i.Folder != nil: case i.Folder != nil:
return i.Folder.ItemType return i.Folder.ItemType
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.ItemType return i.Exchange.ItemType
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.ItemType return i.SharePoint.ItemType
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.ItemType return i.OneDrive.ItemType
case i.Groups != nil: case i.Groups != nil:
return i.Groups.ItemType return i.Groups.ItemType
case i.TeamsChats != nil:
return i.TeamsChats.ItemType
} }
return UnknownType return UnknownType
@ -108,18 +110,16 @@ func (i ItemInfo) size() int64 {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.Size return i.Exchange.Size
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.Size return i.OneDrive.Size
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.Size return i.SharePoint.Size
case i.Groups != nil: case i.Groups != nil:
return i.Groups.Size return i.Groups.Size
case i.Folder != nil: case i.Folder != nil:
return i.Folder.Size return i.Folder.Size
case i.TeamsChats != nil:
return int64(i.TeamsChats.Chat.MessageCount)
} }
return 0 return 0
@ -129,18 +129,16 @@ func (i ItemInfo) Modified() time.Time {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.Modified return i.Exchange.Modified
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.Modified return i.OneDrive.Modified
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.Modified return i.SharePoint.Modified
case i.Groups != nil: case i.Groups != nil:
return i.Groups.Modified return i.Groups.Modified
case i.Folder != nil: case i.Folder != nil:
return i.Folder.Modified return i.Folder.Modified
case i.TeamsChats != nil:
return i.TeamsChats.Modified
} }
return time.Time{} return time.Time{}
@ -150,16 +148,14 @@ func (i ItemInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.uniqueLocation(baseLoc) return i.Exchange.uniqueLocation(baseLoc)
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.uniqueLocation(baseLoc) return i.OneDrive.uniqueLocation(baseLoc)
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.uniqueLocation(baseLoc) return i.SharePoint.uniqueLocation(baseLoc)
case i.Groups != nil: case i.Groups != nil:
return i.Groups.uniqueLocation(baseLoc) return i.Groups.uniqueLocation(baseLoc)
case i.TeamsChats != nil:
return i.TeamsChats.uniqueLocation(baseLoc)
default: default:
return nil, clues.New("unsupported type") return nil, clues.New("unsupported type")
} }
@ -169,16 +165,14 @@ func (i ItemInfo) updateFolder(f *FolderInfo) error {
switch { switch {
case i.Exchange != nil: case i.Exchange != nil:
return i.Exchange.updateFolder(f) return i.Exchange.updateFolder(f)
case i.OneDrive != nil: case i.OneDrive != nil:
return i.OneDrive.updateFolder(f) return i.OneDrive.updateFolder(f)
case i.SharePoint != nil: case i.SharePoint != nil:
return i.SharePoint.updateFolder(f) return i.SharePoint.updateFolder(f)
case i.Groups != nil: case i.Groups != nil:
return i.Groups.updateFolder(f) return i.Groups.updateFolder(f)
case i.TeamsChats != nil:
return i.TeamsChats.updateFolder(f)
default: default:
return clues.New("unsupported type") return clues.New("unsupported type")
} }

View File

@ -71,12 +71,21 @@ func (suite *ItemInfoUnitSuite) TestItemInfo_IsDriveItem() {
{ {
name: "exchange anything", name: "exchange anything",
ii: ItemInfo{ ii: ItemInfo{
Groups: &GroupsInfo{ Exchange: &ExchangeInfo{
ItemType: ExchangeMail, ItemType: ExchangeMail,
}, },
}, },
expect: assert.False, expect: assert.False,
}, },
{
name: "teams chat",
ii: ItemInfo{
TeamsChats: &TeamsChatsInfo{
ItemType: TeamsChat,
},
},
expect: assert.False,
},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {

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

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

View File

@ -42,6 +42,7 @@ const (
CollectionNotMoved Key = "collection-state-not-moved" CollectionNotMoved Key = "collection-state-not-moved"
CollectionTombstoned Key = "collection-state-tombstoned" CollectionTombstoned Key = "collection-state-tombstoned"
Collections Key = "collections" Collections Key = "collections"
Containers Key = "containers"
DeleteFolderMarker Key = "delete-folder-marker" DeleteFolderMarker Key = "delete-folder-marker"
DeleteItemMarker Key = "delete-item-marker" DeleteItemMarker Key = "delete-item-marker"
Drives Key = "drives" Drives Key = "drives"
@ -66,6 +67,7 @@ const (
Sites Key = "sites" Sites Key = "sites"
Lists Key = "lists" Lists Key = "lists"
SkippedContainers Key = "skipped-containers" SkippedContainers Key = "skipped-containers"
SkippedItems Key = "skipped-items"
StreamBytesAdded Key = "stream-bytes-added" StreamBytesAdded Key = "stream-bytes-added"
StreamDirsAdded Key = "stream-dirs-added" StreamDirsAdded Key = "stream-dirs-added"
StreamDirsFound Key = "stream-dirs-found" StreamDirsFound Key = "stream-dirs-found"

View File

@ -28,6 +28,7 @@ const (
DetailsCategory CategoryType = 8 // details DetailsCategory CategoryType = 8 // details
ChannelMessagesCategory CategoryType = 9 // channelMessages ChannelMessagesCategory CategoryType = 9 // channelMessages
ConversationPostsCategory CategoryType = 10 // conversationPosts ConversationPostsCategory CategoryType = 10 // conversationPosts
ChatsCategory CategoryType = 11 // chats
) )
var strToCat = map[string]CategoryType{ var strToCat = map[string]CategoryType{
@ -41,6 +42,7 @@ var strToCat = map[string]CategoryType{
strings.ToLower(DetailsCategory.String()): DetailsCategory, strings.ToLower(DetailsCategory.String()): DetailsCategory,
strings.ToLower(ChannelMessagesCategory.String()): ChannelMessagesCategory, strings.ToLower(ChannelMessagesCategory.String()): ChannelMessagesCategory,
strings.ToLower(ConversationPostsCategory.String()): ConversationPostsCategory, strings.ToLower(ConversationPostsCategory.String()): ConversationPostsCategory,
strings.ToLower(ChatsCategory.String()): ChatsCategory,
} }
func ToCategoryType(s string) CategoryType { func ToCategoryType(s string) CategoryType {
@ -63,6 +65,7 @@ var catToHuman = map[CategoryType]string{
DetailsCategory: "Details", DetailsCategory: "Details",
ChannelMessagesCategory: "Messages", ChannelMessagesCategory: "Messages",
ConversationPostsCategory: "Posts", ConversationPostsCategory: "Posts",
ChatsCategory: "Chats",
} }
// HumanString produces a more human-readable string version of the category. // HumanString produces a more human-readable string version of the category.
@ -100,6 +103,9 @@ var serviceCategories = map[ServiceType]map[CategoryType]struct{}{
ConversationPostsCategory: {}, ConversationPostsCategory: {},
LibrariesCategory: {}, LibrariesCategory: {},
}, },
TeamsChatsService: {
ChatsCategory: {},
},
} }
func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) { func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) {

View File

@ -35,6 +35,7 @@ func (suite *CategoryTypeUnitSuite) TestToCategoryType() {
{input: "details", expect: 8}, {input: "details", expect: 8},
{input: "channelmessages", expect: 9}, {input: "channelmessages", expect: 9},
{input: "conversationposts", expect: 10}, {input: "conversationposts", expect: 10},
{input: "chats", expect: 11},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.input, func() { suite.Run(test.input, func() {
@ -62,6 +63,7 @@ func (suite *CategoryTypeUnitSuite) TestHumanString() {
{input: 8, expect: "Details"}, {input: 8, expect: "Details"},
{input: 9, expect: "Messages"}, {input: 9, expect: "Messages"},
{input: 10, expect: "Posts"}, {input: 10, expect: "Posts"},
{input: 11, expect: "Chats"},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.input.String(), func() { suite.Run(test.input.String(), func() {

View File

@ -19,11 +19,12 @@ func _() {
_ = x[DetailsCategory-8] _ = x[DetailsCategory-8]
_ = x[ChannelMessagesCategory-9] _ = x[ChannelMessagesCategory-9]
_ = x[ConversationPostsCategory-10] _ = 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 { func (i CategoryType) String() string {
if i < 0 || i >= CategoryType(len(_CategoryType_index)-1) { if i < 0 || i >= CategoryType(len(_CategoryType_index)-1) {

View File

@ -118,6 +118,14 @@ func (suite *ServiceCategoryUnitSuite) TestValidateServiceAndCategory() {
expectedCategory: LibrariesCategory, expectedCategory: LibrariesCategory,
check: assert.NoError, check: assert.NoError,
}, },
{
name: "ChatsChats",
service: TeamsChatsService.String(),
category: ChatsCategory.String(),
expectedService: TeamsChatsService,
expectedCategory: ChatsCategory,
check: assert.NoError,
},
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {
@ -155,9 +163,10 @@ func (suite *ServiceCategoryUnitSuite) TestToServiceType() {
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {
t := suite.T() assert.Equal(
suite.T(),
assert.Equal(t, test.expected, ToServiceType(test.service)) test.expected.String(),
ToServiceType(test.service).String())
}) })
} }
} }
@ -181,9 +190,10 @@ func (suite *ServiceCategoryUnitSuite) TestToCategoryType() {
} }
for _, test := range table { for _, test := range table {
suite.Run(test.name, func() { suite.Run(test.name, func() {
t := suite.T() assert.Equal(
suite.T(),
assert.Equal(t, test.expected, ToCategoryType(test.category)) test.expected.String(),
ToCategoryType(test.category).String())
}) })
} }
} }

View File

@ -31,31 +31,30 @@ const (
SharePointMetadataService ServiceType = 6 // sharepointMetadata SharePointMetadataService ServiceType = 6 // sharepointMetadata
GroupsService ServiceType = 7 // groups GroupsService ServiceType = 7 // groups
GroupsMetadataService ServiceType = 8 // groupsMetadata GroupsMetadataService ServiceType = 8 // groupsMetadata
TeamsChatsService ServiceType = 9 // teamsChats
TeamsChatsMetadataService ServiceType = 10 // teamsChatsMetadata
) )
func ToServiceType(service string) ServiceType { var strToSvc = map[string]ServiceType{
s := strings.ToLower(service) 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 { func ToServiceType(service string) ServiceType {
case strings.ToLower(ExchangeService.String()): st, ok := strToSvc[strings.ToLower(service)]
return ExchangeService if !ok {
case strings.ToLower(OneDriveService.String()): st = UnknownService
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
} }
return st
} }
var serviceToHuman = map[ServiceType]string{ var serviceToHuman = map[ServiceType]string{
@ -63,6 +62,7 @@ var serviceToHuman = map[ServiceType]string{
OneDriveService: "OneDrive", OneDriveService: "OneDrive",
SharePointService: "SharePoint", SharePointService: "SharePoint",
GroupsService: "Groups", GroupsService: "Groups",
TeamsChatsService: "Chats",
} }
// HumanString produces a more human-readable string version of the service. // HumanString produces a more human-readable string version of the service.

View File

@ -17,11 +17,13 @@ func _() {
_ = x[SharePointMetadataService-6] _ = x[SharePointMetadataService-6]
_ = x[GroupsService-7] _ = x[GroupsService-7]
_ = x[GroupsMetadataService-8] _ = 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 { func (i ServiceType) String() string {
if i < 0 || i >= ServiceType(len(_ServiceType_index)-1) { if i < 0 || i >= ServiceType(len(_ServiceType_index)-1) {

View File

@ -591,7 +591,7 @@ func (ec exchangeCategory) isLeaf() bool {
// pathValues transforms the two paths to maps of identified properties. // pathValues transforms the two paths to maps of identified properties.
// //
// Example: // Example:
// [tenantID, service, userPN, category, mailFolder, mailID] // [tenantID, service, userID, category, mailFolder, mailID]
// => {exchMailFolder: mailFolder, exchMail: mailID} // => {exchMailFolder: mailFolder, exchMail: mailID}
func (ec exchangeCategory) pathValues( func (ec exchangeCategory) pathValues(
repo path.Path, repo path.Path,
@ -772,7 +772,7 @@ func (s ExchangeScope) matchesInfo(dii details.ItemInfo) bool {
infoCat := s.InfoCategory() infoCat := s.InfoCategory()
cfpc := categoryFromItemType(info.ItemType) cfpc := exchangeCategoryFromItemType(info.ItemType)
if !typeAndCategoryMatches(infoCat, cfpc) { if !typeAndCategoryMatches(infoCat, cfpc) {
return false return false
} }
@ -801,10 +801,10 @@ func (s ExchangeScope) matchesInfo(dii details.ItemInfo) bool {
return s.Matches(infoCat, i) 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 // struct. Since every ExchangeInfo can hold all exchange data info, the exact
// type that the struct represents must be compared using its ItemType prop. // 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 { switch pct {
case details.ExchangeContact: case details.ExchangeContact:
return ExchangeContact return ExchangeContact

View File

@ -1602,7 +1602,7 @@ func (suite *ExchangeSelectorSuite) TestCategoryFromItemType() {
suite.Run(test.name, func() { suite.Run(test.name, func() {
t := suite.T() t := suite.T()
result := categoryFromItemType(test.input) result := exchangeCategoryFromItemType(test.input)
assert.Equal(t, test.expect, result) assert.Equal(t, test.expect, result)
}) })
} }

View File

@ -26,6 +26,7 @@ const (
ServiceOneDrive service = 2 // OneDrive ServiceOneDrive service = 2 // OneDrive
ServiceSharePoint service = 3 // SharePoint ServiceSharePoint service = 3 // SharePoint
ServiceGroups service = 4 // Groups ServiceGroups service = 4 // Groups
ServiceTeamsChats service = 5 // TeamsChats
) )
var serviceToPathType = map[service]path.ServiceType{ var serviceToPathType = map[service]path.ServiceType{
@ -34,6 +35,7 @@ var serviceToPathType = map[service]path.ServiceType{
ServiceOneDrive: path.OneDriveService, ServiceOneDrive: path.OneDriveService,
ServiceSharePoint: path.SharePointService, ServiceSharePoint: path.SharePointService,
ServiceGroups: path.GroupsService, ServiceGroups: path.GroupsService,
ServiceTeamsChats: path.TeamsChatsService,
} }
var ( var (
@ -353,6 +355,9 @@ func selectorAsIface[T any](s Selector) (T, error) {
case ServiceGroups: case ServiceGroups:
a, err = func() (any, error) { return s.ToGroupsRestore() }() a, err = func() (any, error) { return s.ToGroupsRestore() }()
t = a.(T) t = a.(T)
case ServiceTeamsChats:
a, err = func() (any, error) { return s.ToTeamsChatsRestore() }()
t = a.(T)
default: default:
err = clues.Stack(ErrorUnrecognizedService, clues.New(s.Service.String())) err = clues.Stack(ErrorUnrecognizedService, clues.New(s.Service.String()))
} }

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

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

View File

@ -4,7 +4,10 @@ import (
"github.com/alcionai/corso/src/pkg/selectors" "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 // GroupsBackupFolderScope is the standard folder scope that should be used
// in integration backups with groups when interacting with libraries. // 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. // there's no way to easily specify a test conversation by name.
return sel.Conversation(selectors.Any()) 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})
}

View File

@ -162,7 +162,7 @@ func channelMessageInfo(
modTime = lastReplyAt modTime = lastReplyAt
} }
preview, contentLen, err := getChatMessageContentPreview(msg) preview, contentLen, err := getChatMessageContentPreview(msg, msg)
if err != nil { if err != nil {
preview = "malformed or unparseable html" + preview preview = "malformed or unparseable html" + preview
} }
@ -180,7 +180,7 @@ func channelMessageInfo(
var lr details.ChannelMessageInfo var lr details.ChannelMessageInfo
if lastReply != nil { if lastReply != nil {
preview, contentLen, err = getChatMessageContentPreview(lastReply) preview, contentLen, err = getChatMessageContentPreview(lastReply, lastReply)
if err != nil { if err != nil {
preview = "malformed or unparseable html: " + preview preview = "malformed or unparseable html: " + preview
} }
@ -239,12 +239,28 @@ func GetChatMessageFrom(msg models.ChatMessageable) string {
return "" return ""
} }
func getChatMessageContentPreview(msg models.ChatMessageable) (string, int64, error) { // a hack for fulfilling getAttachmentser when the model doesn't
content, origSize, err := stripChatMessageHTML(msg) // 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() 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 ( var (
content string content string
origSize int64 origSize int64
@ -256,7 +272,7 @@ func stripChatMessageHTML(msg models.ChatMessageable) (string, int64, error) {
origSize = int64(len(content)) origSize = int64(len(content))
content = replaceAttachmentMarkup(content, msg.GetAttachments()) content = replaceAttachmentMarkup(content, atts.GetAttachments())
content, err := html2text.FromString(content) content, err := html2text.FromString(content)
return content, origSize, clues.Stack(err).OrNil() return content, origSize, clues.Stack(err).OrNil()

View File

@ -712,7 +712,7 @@ func (suite *ChannelsAPIUnitSuite) TestStripChatMessageContent() {
msg.SetAttachments(test.attachments) msg.SetAttachments(test.attachments)
// not testing len; it's effectively covered by the content assertion // 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) assert.Equal(t, test.expect, result)
test.expectErr(t, err, clues.ToCore(err)) test.expectErr(t, err, clues.ToCore(err))
}) })

View File

@ -12,6 +12,10 @@ type GetLastModifiedDateTimer interface {
GetLastModifiedDateTime() *time.Time GetLastModifiedDateTime() *time.Time
} }
type GetLastUpdatedDateTimer interface {
GetLastUpdatedDateTime() *time.Time
}
type GetAdditionalDataer interface { type GetAdditionalDataer interface {
GetAdditionalData() map[string]any GetAdditionalData() map[string]any
} }

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

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

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

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