add export support to teamschats service layer

This commit is contained in:
ryanfkeepers 2024-01-25 11:22:09 -07:00
parent 7ab1276d61
commit b9f71280bf
5 changed files with 348 additions and 12 deletions

View File

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

View File

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

View File

@ -0,0 +1,119 @@
package teamschats
import (
"context"
"github.com/alcionai/clues"
"github.com/alcionai/corso/src/internal/common/idname"
"github.com/alcionai/corso/src/internal/data"
"github.com/alcionai/corso/src/internal/m365/collection/teamschats"
"github.com/alcionai/corso/src/internal/m365/resource"
"github.com/alcionai/corso/src/internal/operations/inject"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/export"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/metrics"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
var _ inject.ServiceHandler = &teamsChatsHandler{}
func NewTeamsChatsHandler(
apiClient api.Client,
resourceGetter idname.GetResourceIDAndNamer,
) *teamsChatsHandler {
return &teamsChatsHandler{
baseTeamsChatsHandler: baseTeamsChatsHandler{},
apiClient: apiClient,
resourceGetter: resourceGetter,
}
}
// ========================================================================== //
// baseTeamsChatsHandler
// ========================================================================== //
// baseTeamsChatsHandler contains logic for tracking data and doing operations
// (e.x. export) that don't require contact with external M356 services.
type baseTeamsChatsHandler struct{}
func (h *baseTeamsChatsHandler) CacheItemInfo(v details.ItemInfo) {}
// ProduceExportCollections will create the export collections for the
// given restore collections.
func (h *baseTeamsChatsHandler) ProduceExportCollections(
ctx context.Context,
backupVersion int,
exportCfg control.ExportConfig,
dcs []data.RestoreCollection,
stats *metrics.ExportStats,
errs *fault.Bus,
) ([]export.Collectioner, error) {
var (
el = errs.Local()
ec = make([]export.Collectioner, 0, len(dcs))
)
for _, dc := range dcs {
category := dc.FullPath().Category()
switch category {
case path.ChatsCategory:
folders := dc.FullPath().Folders()
pth := path.Builder{}.Append(category.HumanString()).Append(folders...)
ec = append(
ec,
teamschats.NewExportCollection(
pth.String(),
[]data.RestoreCollection{dc},
backupVersion,
exportCfg,
stats))
default:
return nil, clues.NewWC(ctx, "data category not supported").
With("category", category)
}
}
return ec, el.Failure()
}
// ========================================================================== //
// teamschatsHandler
// ========================================================================== //
// teamsChatsHandler contains logic for handling data and performing operations
// (e.x. restore) regardless of whether they require contact with external M365
// services or not.
type teamsChatsHandler struct {
baseTeamsChatsHandler
apiClient api.Client
resourceGetter idname.GetResourceIDAndNamer
}
func (h *teamsChatsHandler) IsServiceEnabled(
ctx context.Context,
resourceID string,
) (bool, error) {
// TODO(ashmrtn): Move free function implementation to this function.
res, err := IsServiceEnabled(ctx, h.apiClient.Users(), resourceID)
return res, clues.Stack(err).OrNil()
}
func (h *teamsChatsHandler) PopulateProtectedResourceIDAndName(
ctx context.Context,
resourceID string, // Can be either ID or name.
ins idname.Cacher,
) (idname.Provider, error) {
if h.resourceGetter == nil {
return nil, clues.StackWC(ctx, resource.ErrNoResourceLookup)
}
pr, err := h.resourceGetter.GetResourceIDAndNameFrom(ctx, resourceID, ins)
return pr, clues.Wrap(err, "identifying resource owner").OrNil()
}

View File

@ -0,0 +1,140 @@
package teamschats
import (
"bytes"
"context"
"io"
"testing"
"github.com/alcionai/clues"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/data"
dataMock "github.com/alcionai/corso/src/internal/data/mock"
teamschatMock "github.com/alcionai/corso/src/internal/m365/service/teamschats/mock"
"github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/internal/version"
"github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/export"
"github.com/alcionai/corso/src/pkg/fault"
"github.com/alcionai/corso/src/pkg/metrics"
"github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
)
type ExportUnitSuite struct {
tester.Suite
}
func TestExportUnitSuite(t *testing.T) {
suite.Run(t, &ExportUnitSuite{Suite: tester.NewUnitSuite(t)})
}
type finD struct {
id string
key string
name string
err error
}
func (fd finD) FetchItemByName(ctx context.Context, name string) (data.Item, error) {
if fd.err != nil {
return nil, fd.err
}
if name == fd.id {
return &dataMock.Item{
ItemID: fd.id,
Reader: io.NopCloser(bytes.NewBufferString(`{"` + fd.key + `": "` + fd.name + `"}`)),
}, nil
}
return nil, assert.AnError
}
func (suite *ExportUnitSuite) TestExportRestoreCollections_chats() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
var (
category = path.ChatsCategory
itemID = "itemID"
dii = teamschatMock.ItemInfo()
content = `{"topic": "` + dii.TeamsChats.Chat.Topic + `"}`
body = io.NopCloser(bytes.NewBufferString(content))
exportCfg = control.ExportConfig{}
expectedPath = category.HumanString()
expectedItems = []export.Item{
{
ID: itemID,
Name: itemID + ".json",
// Body: body, not checked
},
}
)
p, err := path.BuildPrefix("t", "pr", path.TeamsChatsService, category)
require.NoError(t, err, clues.ToCore(err))
dcs := []data.RestoreCollection{
data.FetchRestoreCollection{
Collection: dataMock.Collection{
Path: p,
ItemData: []data.Item{
&dataMock.Item{
ItemID: itemID,
Reader: body,
},
},
},
FetchItemByNamer: finD{
id: itemID,
key: "id",
name: itemID,
},
},
}
stats := metrics.NewExportStats()
ecs, err := NewTeamsChatsHandler(api.Client{}, nil).
ProduceExportCollections(
ctx,
int(version.Backup),
exportCfg,
dcs,
stats,
fault.New(true))
require.NoError(t, err, clues.ToCore(err))
assert.Len(t, ecs, 1, "num of collections")
assert.Equal(t, expectedPath, ecs[0].BasePath(), "base dir")
fitems := []export.Item{}
size := 0
for item := range ecs[0].Items(ctx) {
b, err := io.ReadAll(item.Body)
require.NoError(t, err, clues.ToCore(err))
// count up size for tests
size += len(b)
// have to nil out body, otherwise assert fails due to
// pointer memory location differences
item.Body = nil
fitems = append(fitems, item)
}
assert.Equal(t, expectedItems, fitems, "items")
expectedStats := metrics.NewExportStats()
expectedStats.UpdateBytes(category, int64(size))
expectedStats.UpdateResourceCount(category)
assert.Equal(t, expectedStats.GetStats(), stats.GetStats(), "stats")
}

View File

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