diff --git a/src/internal/m365/export.go b/src/internal/m365/export.go index 424f79458..d1c489600 100644 --- a/src/internal/m365/export.go +++ b/src/internal/m365/export.go @@ -7,6 +7,7 @@ import ( "github.com/alcionai/corso/src/internal/m365/service/groups" "github.com/alcionai/corso/src/internal/m365/service/onedrive" "github.com/alcionai/corso/src/internal/m365/service/sharepoint" + "github.com/alcionai/corso/src/internal/m365/service/teamschats" "github.com/alcionai/corso/src/internal/operations/inject" "github.com/alcionai/corso/src/pkg/path" ) @@ -30,6 +31,9 @@ func (ctrl *Controller) NewServiceHandler( case path.ExchangeService: return exchange.NewExchangeHandler(ctrl.AC, ctrl.resourceHandler), nil + + case path.TeamsChatsService: + return teamschats.NewTeamsChatsHandler(ctrl.AC, ctrl.resourceHandler), nil } return nil, clues.New("unrecognized service"). diff --git a/src/internal/m365/service/groups/export_test.go b/src/internal/m365/service/groups/export_test.go index cb885f0d9..6397c0fb6 100644 --- a/src/internal/m365/service/groups/export_test.go +++ b/src/internal/m365/service/groups/export_test.go @@ -8,6 +8,7 @@ import ( "github.com/alcionai/clues" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/alcionai/corso/src/internal/data" @@ -79,7 +80,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() { ) p, err := path.Build("t", "pr", path.GroupsService, path.ChannelMessagesCategory, false, containerName) - assert.NoError(t, err, "build path") + require.NoError(t, err, clues.ToCore(err)) dcs := []data.RestoreCollection{ data.FetchRestoreCollection{ @@ -106,7 +107,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() { dcs, stats, fault.New(true)) - assert.NoError(t, err, "export collections error") + require.NoError(t, err, clues.ToCore(err)) assert.Len(t, ecs, 1, "num of collections") assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir") @@ -117,7 +118,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_messages() { for item := range ecs[0].Items(ctx) { b, err := io.ReadAll(item.Body) - assert.NoError(t, err, clues.ToCore(err)) + require.NoError(t, err, clues.ToCore(err)) // count up size for tests size += len(b) @@ -181,7 +182,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() { false, odConsts.SitesPathDir, siteID) - assert.NoError(t, err, "build path") + require.NoError(t, err, clues.ToCore(err)) dcs := []data.RestoreCollection{ data.FetchRestoreCollection{ @@ -210,7 +211,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() { dcs, stats, fault.New(true)) - assert.NoError(t, err, "export collections error") + require.NoError(t, err, clues.ToCore(err)) assert.Len(t, ecs, 1, "num of collections") assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir") @@ -222,7 +223,7 @@ func (suite *ExportUnitSuite) TestExportRestoreCollections_libraries() { for item := range ecs[0].Items(ctx) { // unwrap the body from stats reader b, err := io.ReadAll(item.Body) - assert.NoError(t, err, clues.ToCore(err)) + require.NoError(t, err, clues.ToCore(err)) size += len(b) bitem := io.NopCloser(bytes.NewBuffer(b)) diff --git a/src/internal/m365/service/teamschats/export.go b/src/internal/m365/service/teamschats/export.go new file mode 100644 index 000000000..2e6effc48 --- /dev/null +++ b/src/internal/m365/service/teamschats/export.go @@ -0,0 +1,119 @@ +package teamschats + +import ( + "context" + + "github.com/alcionai/clues" + + "github.com/alcionai/corso/src/internal/common/idname" + "github.com/alcionai/corso/src/internal/data" + "github.com/alcionai/corso/src/internal/m365/collection/teamschats" + "github.com/alcionai/corso/src/internal/m365/resource" + "github.com/alcionai/corso/src/internal/operations/inject" + "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/control" + "github.com/alcionai/corso/src/pkg/export" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/metrics" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/services/m365/api" +) + +var _ inject.ServiceHandler = &teamsChatsHandler{} + +func NewTeamsChatsHandler( + apiClient api.Client, + resourceGetter idname.GetResourceIDAndNamer, +) *teamsChatsHandler { + return &teamsChatsHandler{ + baseTeamsChatsHandler: baseTeamsChatsHandler{}, + apiClient: apiClient, + resourceGetter: resourceGetter, + } +} + +// ========================================================================== // +// baseTeamsChatsHandler +// ========================================================================== // + +// baseTeamsChatsHandler contains logic for tracking data and doing operations +// (e.x. export) that don't require contact with external M356 services. +type baseTeamsChatsHandler struct{} + +func (h *baseTeamsChatsHandler) CacheItemInfo(v details.ItemInfo) {} + +// ProduceExportCollections will create the export collections for the +// given restore collections. +func (h *baseTeamsChatsHandler) ProduceExportCollections( + ctx context.Context, + backupVersion int, + exportCfg control.ExportConfig, + dcs []data.RestoreCollection, + stats *metrics.ExportStats, + errs *fault.Bus, +) ([]export.Collectioner, error) { + var ( + el = errs.Local() + ec = make([]export.Collectioner, 0, len(dcs)) + ) + + for _, dc := range dcs { + category := dc.FullPath().Category() + + switch category { + case path.ChatsCategory: + folders := dc.FullPath().Folders() + pth := path.Builder{}.Append(category.HumanString()).Append(folders...) + + ec = append( + ec, + teamschats.NewExportCollection( + pth.String(), + []data.RestoreCollection{dc}, + backupVersion, + exportCfg, + stats)) + default: + return nil, clues.NewWC(ctx, "data category not supported"). + With("category", category) + } + } + + return ec, el.Failure() +} + +// ========================================================================== // +// teamschatsHandler +// ========================================================================== // + +// teamsChatsHandler contains logic for handling data and performing operations +// (e.x. restore) regardless of whether they require contact with external M365 +// services or not. +type teamsChatsHandler struct { + baseTeamsChatsHandler + apiClient api.Client + resourceGetter idname.GetResourceIDAndNamer +} + +func (h *teamsChatsHandler) IsServiceEnabled( + ctx context.Context, + resourceID string, +) (bool, error) { + // TODO(ashmrtn): Move free function implementation to this function. + res, err := IsServiceEnabled(ctx, h.apiClient.Users(), resourceID) + return res, clues.Stack(err).OrNil() +} + +func (h *teamsChatsHandler) PopulateProtectedResourceIDAndName( + ctx context.Context, + resourceID string, // Can be either ID or name. + ins idname.Cacher, +) (idname.Provider, error) { + if h.resourceGetter == nil { + return nil, clues.StackWC(ctx, resource.ErrNoResourceLookup) + } + + pr, err := h.resourceGetter.GetResourceIDAndNameFrom(ctx, resourceID, ins) + + return pr, clues.Wrap(err, "identifying resource owner").OrNil() +} diff --git a/src/internal/m365/service/teamschats/export_test.go b/src/internal/m365/service/teamschats/export_test.go new file mode 100644 index 000000000..8ffe56a40 --- /dev/null +++ b/src/internal/m365/service/teamschats/export_test.go @@ -0,0 +1,140 @@ +package teamschats + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/alcionai/clues" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/data" + dataMock "github.com/alcionai/corso/src/internal/data/mock" + teamschatMock "github.com/alcionai/corso/src/internal/m365/service/teamschats/mock" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/version" + "github.com/alcionai/corso/src/pkg/control" + "github.com/alcionai/corso/src/pkg/export" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/metrics" + "github.com/alcionai/corso/src/pkg/path" + "github.com/alcionai/corso/src/pkg/services/m365/api" +) + +type ExportUnitSuite struct { + tester.Suite +} + +func TestExportUnitSuite(t *testing.T) { + suite.Run(t, &ExportUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +type finD struct { + id string + key string + name string + err error +} + +func (fd finD) FetchItemByName(ctx context.Context, name string) (data.Item, error) { + if fd.err != nil { + return nil, fd.err + } + + if name == fd.id { + return &dataMock.Item{ + ItemID: fd.id, + Reader: io.NopCloser(bytes.NewBufferString(`{"` + fd.key + `": "` + fd.name + `"}`)), + }, nil + } + + return nil, assert.AnError +} + +func (suite *ExportUnitSuite) TestExportRestoreCollections_chats() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + var ( + category = path.ChatsCategory + itemID = "itemID" + dii = teamschatMock.ItemInfo() + content = `{"topic": "` + dii.TeamsChats.Chat.Topic + `"}` + body = io.NopCloser(bytes.NewBufferString(content)) + exportCfg = control.ExportConfig{} + expectedPath = category.HumanString() + expectedItems = []export.Item{ + { + ID: itemID, + Name: itemID + ".json", + // Body: body, not checked + }, + } + ) + + p, err := path.BuildPrefix("t", "pr", path.TeamsChatsService, category) + require.NoError(t, err, clues.ToCore(err)) + + dcs := []data.RestoreCollection{ + data.FetchRestoreCollection{ + Collection: dataMock.Collection{ + Path: p, + ItemData: []data.Item{ + &dataMock.Item{ + ItemID: itemID, + Reader: body, + }, + }, + }, + FetchItemByNamer: finD{ + id: itemID, + key: "id", + name: itemID, + }, + }, + } + + stats := metrics.NewExportStats() + + ecs, err := NewTeamsChatsHandler(api.Client{}, nil). + ProduceExportCollections( + ctx, + int(version.Backup), + exportCfg, + dcs, + stats, + fault.New(true)) + require.NoError(t, err, clues.ToCore(err)) + assert.Len(t, ecs, 1, "num of collections") + + assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir") + + fitems := []export.Item{} + + size := 0 + + for item := range ecs[0].Items(ctx) { + b, err := io.ReadAll(item.Body) + require.NoError(t, err, clues.ToCore(err)) + + // count up size for tests + size += len(b) + + // have to nil out body, otherwise assert fails due to + // pointer memory location differences + item.Body = nil + fitems = append(fitems, item) + } + + assert.Equal(t, expectedItems, fitems, "items") + + expectedStats := metrics.NewExportStats() + expectedStats.UpdateBytes(category, int64(size)) + expectedStats.UpdateResourceCount(category) + assert.Equal(t, expectedStats.GetStats(), stats.GetStats(), "stats") +} diff --git a/src/pkg/backup/details/testdata/testdata.go b/src/pkg/backup/details/testdata/testdata.go index bb496e3dd..5f5c134f8 100644 --- a/src/pkg/backup/details/testdata/testdata.go +++ b/src/pkg/backup/details/testdata/testdata.go @@ -16,11 +16,13 @@ import ( // mustParsePath takes a string representing a resource path and returns a path // instance. Panics if the path cannot be parsed. Useful for simple variable // assignments. -func mustParsePath(ref string, isItem, isSharepointList bool) path.Path { - var p path.Path - var err error +func mustParsePath(ref string, isItem, allowPrefix bool) path.Path { + var ( + p path.Path + err error + ) - if isSharepointList { + if allowPrefix { p, err = path.PrefixOrPathFromDataLayerPath(ref, isItem) } else { p, err = path.FromDataLayerPath(ref, isItem) @@ -126,9 +128,9 @@ func (p repoRefAndLocRef) locationAsRepoRef() path.Path { return res } -func mustPathRep(ref string, isItem, isSharepointList bool) repoRefAndLocRef { +func mustPathRep(ref string, isItem, allowPrefix bool) repoRefAndLocRef { res := repoRefAndLocRef{} - tmp := mustParsePath(ref, isItem, isSharepointList) + tmp := mustParsePath(ref, isItem, allowPrefix) // Now append stuff to the RepoRef elements so we have distinct LocationRef // and RepoRef elements to simulate using IDs in the path instead of display @@ -969,6 +971,68 @@ var ( }, }, } + + TeamsChatsRootPath = mustPathRep("tenant-id/"+path.TeamsChatsService.String()+"/user-id/chats", false, true) + + TeamsChatsChatItemPath1 = TeamsChatsRootPath.MustAppend(ItemName1, true) + TeamsChatsChatItemPath2 = TeamsChatsRootPath.MustAppend(ItemName2, true) + TeamsChatsChatItemPath3 = TeamsChatsRootPath.MustAppend(ItemName3, true) + + teamsChatsChatItemsByVersion = map[int][]details.Entry{ + version.Groups9Update: { + { + RepoRef: TeamsChatsChatItemPath1.locationAsRepoRef().String(), + ShortRef: TeamsChatsChatItemPath1.locationAsRepoRef().ShortRef(), + ParentRef: TeamsChatsChatItemPath1.locationAsRepoRef().ToBuilder().Dir().ShortRef(), + ItemRef: TeamsChatsChatItemPath1.ItemLocation(), + LocationRef: "", + ItemInfo: details.ItemInfo{ + TeamsChats: &details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + Modified: Time4, + ParentPath: "", + Chat: details.ChatInfo{ + Topic: "item 1", + }, + }, + }, + }, + { + RepoRef: TeamsChatsChatItemPath2.locationAsRepoRef().String(), + ShortRef: TeamsChatsChatItemPath2.locationAsRepoRef().ShortRef(), + ParentRef: TeamsChatsChatItemPath2.locationAsRepoRef().ToBuilder().Dir().ShortRef(), + ItemRef: TeamsChatsChatItemPath2.ItemLocation(), + LocationRef: "", + ItemInfo: details.ItemInfo{ + TeamsChats: &details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + Modified: Time3, + ParentPath: "", + Chat: details.ChatInfo{ + Topic: "item 2", + }, + }, + }, + }, + { + RepoRef: TeamsChatsChatItemPath3.locationAsRepoRef().String(), + ShortRef: TeamsChatsChatItemPath3.locationAsRepoRef().ShortRef(), + ParentRef: TeamsChatsChatItemPath3.locationAsRepoRef().ToBuilder().Dir().ShortRef(), + ItemRef: TeamsChatsChatItemPath3.ItemLocation(), + LocationRef: "", + ItemInfo: details.ItemInfo{ + TeamsChats: &details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + ParentPath: "", + Modified: Time4, + Chat: details.ChatInfo{ + Topic: "item 3", + }, + }, + }, + }, + }, + } ) func GetDetailsSetForVersion(t *testing.T, wantedVersion int) *details.Details { @@ -987,6 +1051,9 @@ func GetDetailsSetForVersion(t *testing.T, wantedVersion int) *details.Details { path.SharePointService: { path.LibrariesCategory, }, + path.TeamsChatsService: { + path.ChatsCategory, + }, } for s, cats := range dataTypes { @@ -1060,6 +1127,11 @@ func GetDeetsForVersion( if cat == path.LibrariesCategory { input = sharePointLibraryItemsByVersion } + + case path.TeamsChatsService: + if cat == path.ChatsCategory { + input = teamsChatsChatItemsByVersion + } } require.NotNil(