diff --git a/src/cli/backup/sharepoint_test.go b/src/cli/backup/sharepoint_test.go index 08f891074..ac20df2f4 100644 --- a/src/cli/backup/sharepoint_test.go +++ b/src/cli/backup/sharepoint_test.go @@ -253,7 +253,7 @@ func (suite *SharePointUnitSuite) TestValidateSharePointBackupCreateFlags() { cats: []string{"invalid category"}, expect: assert.Error, }, - // [TODO]: Uncomment when lists are enabled + // [TODO](hitesh): Uncomment when lists are enabled // { // name: "site with lists category", diff --git a/src/cli/export/sharepoint_test.go b/src/cli/export/sharepoint_test.go index 76439cf13..0545f2e9a 100644 --- a/src/cli/export/sharepoint_test.go +++ b/src/cli/export/sharepoint_test.go @@ -61,6 +61,10 @@ func (suite *SharePointUnitSuite) TestAddSharePointCommands() { "--" + flags.FileModifiedAfterFN, flagsTD.FileModifiedAfterInput, "--" + flags.FileModifiedBeforeFN, flagsTD.FileModifiedBeforeInput, "--" + flags.ListFN, flagsTD.FlgInputs(flagsTD.ListsInput), + "--" + flags.ListCreatedAfterFN, flagsTD.ListCreatedAfterInput, + "--" + flags.ListCreatedBeforeFN, flagsTD.ListCreatedBeforeInput, + "--" + flags.ListModifiedAfterFN, flagsTD.ListModifiedAfterInput, + "--" + flags.ListModifiedBeforeFN, flagsTD.ListModifiedBeforeInput, "--" + flags.PageFN, flagsTD.FlgInputs(flagsTD.PageInput), "--" + flags.PageFolderFN, flagsTD.FlgInputs(flagsTD.PageFolderInput), "--" + flags.FormatFN, flagsTD.FormatType, @@ -88,6 +92,10 @@ func (suite *SharePointUnitSuite) TestAddSharePointCommands() { assert.Equal(t, flagsTD.FileModifiedAfterInput, opts.FileModifiedAfter) assert.Equal(t, flagsTD.FileModifiedBeforeInput, opts.FileModifiedBefore) assert.ElementsMatch(t, flagsTD.ListsInput, opts.Lists) + assert.Equal(t, flagsTD.ListCreatedAfterInput, opts.ListCreatedAfter) + assert.Equal(t, flagsTD.ListCreatedBeforeInput, opts.ListCreatedBefore) + assert.Equal(t, flagsTD.ListModifiedAfterInput, opts.ListModifiedAfter) + assert.Equal(t, flagsTD.ListModifiedBeforeInput, opts.ListModifiedBefore) assert.ElementsMatch(t, flagsTD.PageInput, opts.Page) assert.ElementsMatch(t, flagsTD.PageFolderInput, opts.PageFolder) assert.Equal(t, flagsTD.Archive, opts.ExportCfg.Archive) diff --git a/src/cli/flags/sharepoint.go b/src/cli/flags/sharepoint.go index 30af71975..2e8ff1180 100644 --- a/src/cli/flags/sharepoint.go +++ b/src/cli/flags/sharepoint.go @@ -11,21 +11,35 @@ const ( ) const ( - LibraryFN = "library" - ListFN = "list" + LibraryFN = "library" + + ListFN = "list" + ListModifiedAfterFN = "list-modified-after" + ListModifiedBeforeFN = "list-modified-before" + ListCreatedAfterFN = "list-created-after" + ListCreatedBeforeFN = "list-created-before" + PageFolderFN = "page-folder" PageFN = "page" - SiteFN = "site" // site only accepts WebURL values - SiteIDFN = "site-id" // site-id accepts actual site ids + + SiteFN = "site" // site only accepts WebURL values + SiteIDFN = "site-id" // site-id accepts actual site ids ) var ( - LibraryFV string - ListFV []string + LibraryFV string + + ListFV []string + ListModifiedAfterFV string + ListModifiedBeforeFV string + ListCreatedAfterFV string + ListCreatedBeforeFV string + PageFolderFV []string PageFV []string - SiteIDFV []string - WebURLFV []string + + SiteIDFV []string + WebURLFV []string ) // AddSharePointDetailsAndRestoreFlags adds flags that are common to both the @@ -68,8 +82,23 @@ func AddSharePointDetailsAndRestoreFlags(cmd *cobra.Command) { fs.StringSliceVar( &ListFV, ListFN, nil, - "Select lists by name; accepts '"+Wildcard+"' to select all lists.") - cobra.CheckErr(fs.MarkHidden(ListFN)) + "Select lists by name.") + fs.StringVar( + &ListModifiedAfterFV, + ListModifiedAfterFN, "", + "Select lists modified after this datetime.") + fs.StringVar( + &ListModifiedBeforeFV, + ListModifiedBeforeFN, "", + "Select lists modified before this datetime.") + fs.StringVar( + &ListCreatedAfterFV, + ListCreatedAfterFN, "", + "Select lists created after this datetime.") + fs.StringVar( + &ListCreatedBeforeFV, + ListCreatedBeforeFN, "", + "Select lists created before this datetime.") // pages diff --git a/src/cli/flags/testdata/flags.go b/src/cli/flags/testdata/flags.go index 09a9c12b4..26ea8085b 100644 --- a/src/cli/flags/testdata/flags.go +++ b/src/cli/flags/testdata/flags.go @@ -59,7 +59,11 @@ var ( FileModifiedAfterInput = "fileModifiedAfter" FileModifiedBeforeInput = "fileModifiedBefore" - ListsInput = []string{"listName1", "listName2"} + ListsInput = []string{"listName1", "listName2"} + ListCreatedAfterInput = "listCreatedAfter" + ListCreatedBeforeInput = "listCreatedBefore" + ListModifiedAfterInput = "listModifiedAfter" + ListModifiedBeforeInput = "listModifiedBefore" PageFolderInput = []string{"pageFolder1", "pageFolder2"} PageInput = []string{"page1", "page2"} diff --git a/src/cli/restore/sharepoint_test.go b/src/cli/restore/sharepoint_test.go index 05e83df0c..6b6e21018 100644 --- a/src/cli/restore/sharepoint_test.go +++ b/src/cli/restore/sharepoint_test.go @@ -60,6 +60,10 @@ func (suite *SharePointUnitSuite) TestAddSharePointCommands() { "--" + flags.FileModifiedAfterFN, flagsTD.FileModifiedAfterInput, "--" + flags.FileModifiedBeforeFN, flagsTD.FileModifiedBeforeInput, "--" + flags.ListFN, flagsTD.FlgInputs(flagsTD.ListsInput), + "--" + flags.ListCreatedAfterFN, flagsTD.ListCreatedAfterInput, + "--" + flags.ListCreatedBeforeFN, flagsTD.ListCreatedBeforeInput, + "--" + flags.ListModifiedAfterFN, flagsTD.ListModifiedAfterInput, + "--" + flags.ListModifiedBeforeFN, flagsTD.ListModifiedBeforeInput, "--" + flags.PageFN, flagsTD.FlgInputs(flagsTD.PageInput), "--" + flags.PageFolderFN, flagsTD.FlgInputs(flagsTD.PageFolderInput), "--" + flags.CollisionsFN, flagsTD.Collisions, @@ -89,6 +93,10 @@ func (suite *SharePointUnitSuite) TestAddSharePointCommands() { assert.Equal(t, flagsTD.FileModifiedAfterInput, opts.FileModifiedAfter) assert.Equal(t, flagsTD.FileModifiedBeforeInput, opts.FileModifiedBefore) assert.ElementsMatch(t, flagsTD.ListsInput, opts.Lists) + assert.Equal(t, flagsTD.ListCreatedAfterInput, opts.ListCreatedAfter) + assert.Equal(t, flagsTD.ListCreatedBeforeInput, opts.ListCreatedBefore) + assert.Equal(t, flagsTD.ListModifiedAfterInput, opts.ListModifiedAfter) + assert.Equal(t, flagsTD.ListModifiedBeforeInput, opts.ListModifiedBefore) assert.ElementsMatch(t, flagsTD.PageInput, opts.Page) assert.ElementsMatch(t, flagsTD.PageFolderInput, opts.PageFolder) assert.Equal(t, flagsTD.Collisions, opts.RestoreCfg.Collisions) diff --git a/src/cli/utils/flags.go b/src/cli/utils/flags.go index 9fa87793f..5d3663cbc 100644 --- a/src/cli/utils/flags.go +++ b/src/cli/utils/flags.go @@ -50,6 +50,10 @@ func validateCommonTimeFlags(opts any) error { flags.FileCreatedBeforeFN, flags.FileModifiedAfterFN, flags.FileModifiedBeforeFN, + flags.ListCreatedAfterFN, + flags.ListCreatedBeforeFN, + flags.ListModifiedAfterFN, + flags.ListModifiedBeforeFN, } isFlagPopulated := func(opts any, flag string) bool { diff --git a/src/cli/utils/sharepoint.go b/src/cli/utils/sharepoint.go index e6609f446..2a3448dd8 100644 --- a/src/cli/utils/sharepoint.go +++ b/src/cli/utils/sharepoint.go @@ -25,7 +25,11 @@ type SharePointOpts struct { FileModifiedAfter string FileModifiedBefore string - Lists []string + Lists []string + ListModifiedAfter string + ListModifiedBefore string + ListCreatedBefore string + ListCreatedAfter string PageFolder []string Page []string @@ -46,6 +50,14 @@ func (s SharePointOpts) GetFileTimeField(flag string) string { return s.FileModifiedAfter case flags.FileModifiedBeforeFN: return s.FileModifiedBefore + case flags.ListModifiedAfterFN: + return s.ListModifiedAfter + case flags.ListModifiedBeforeFN: + return s.ListModifiedBefore + case flags.ListCreatedBeforeFN: + return s.ListCreatedBefore + case flags.ListCreatedAfterFN: + return s.ListCreatedAfter default: return "" } @@ -64,7 +76,11 @@ func MakeSharePointOpts(cmd *cobra.Command) SharePointOpts { FileModifiedAfter: flags.FileModifiedAfterFV, FileModifiedBefore: flags.FileModifiedBeforeFV, - Lists: flags.ListFV, + Lists: flags.ListFV, + ListModifiedAfter: flags.ListModifiedAfterFV, + ListModifiedBefore: flags.ListModifiedBeforeFV, + ListCreatedAfter: flags.ListCreatedAfterFV, + ListCreatedBefore: flags.ListCreatedBeforeFV, Page: flags.PageFV, PageFolder: flags.PageFolderFV, @@ -238,4 +254,8 @@ func FilterSharePointRestoreInfoSelectors( AddSharePointInfo(sel, opts.FileCreatedBefore, sel.CreatedBefore) AddSharePointInfo(sel, opts.FileModifiedAfter, sel.ModifiedAfter) AddSharePointInfo(sel, opts.FileModifiedBefore, sel.ModifiedBefore) + AddSharePointInfo(sel, opts.ListModifiedAfter, sel.ListModifiedAfter) + AddSharePointInfo(sel, opts.ListModifiedBefore, sel.ListModifiedBefore) + AddSharePointInfo(sel, opts.ListCreatedAfter, sel.ListCreatedAfter) + AddSharePointInfo(sel, opts.ListCreatedBefore, sel.ListCreatedBefore) } diff --git a/src/cli/utils/sharepoint_test.go b/src/cli/utils/sharepoint_test.go index 030d8a62d..f119e2a5a 100644 --- a/src/cli/utils/sharepoint_test.go +++ b/src/cli/utils/sharepoint_test.go @@ -279,12 +279,20 @@ func (suite *SharePointUtilsSuite) TestValidateSharePointRestoreFlags() { FileCreatedBefore: dttm.Now(), FileModifiedAfter: dttm.Now(), FileModifiedBefore: dttm.Now(), + ListCreatedAfter: dttm.Now(), + ListCreatedBefore: dttm.Now(), + ListModifiedAfter: dttm.Now(), + ListModifiedBefore: dttm.Now(), Populated: flags.PopulatedFlags{ flags.SiteFN: struct{}{}, flags.FileCreatedAfterFN: struct{}{}, flags.FileCreatedBeforeFN: struct{}{}, flags.FileModifiedAfterFN: struct{}{}, flags.FileModifiedBeforeFN: struct{}{}, + flags.ListCreatedAfterFN: struct{}{}, + flags.ListCreatedBeforeFN: struct{}{}, + flags.ListModifiedAfterFN: struct{}{}, + flags.ListModifiedBeforeFN: struct{}{}, }, }, expect: assert.NoError, @@ -350,6 +358,50 @@ func (suite *SharePointUtilsSuite) TestValidateSharePointRestoreFlags() { }, expect: assert.Error, }, + { + name: "invalid list created after", + backupID: "id", + opts: utils.SharePointOpts{ + ListCreatedAfter: "1235", + Populated: flags.PopulatedFlags{ + flags.ListCreatedAfterFN: struct{}{}, + }, + }, + expect: assert.Error, + }, + { + name: "invalid list created before", + backupID: "id", + opts: utils.SharePointOpts{ + ListCreatedBefore: "1235", + Populated: flags.PopulatedFlags{ + flags.ListCreatedBeforeFN: struct{}{}, + }, + }, + expect: assert.Error, + }, + { + name: "invalid list modified after", + backupID: "id", + opts: utils.SharePointOpts{ + ListModifiedAfter: "1235", + Populated: flags.PopulatedFlags{ + flags.ListModifiedAfterFN: struct{}{}, + }, + }, + expect: assert.Error, + }, + { + name: "invalid list modified before", + backupID: "id", + opts: utils.SharePointOpts{ + ListModifiedBefore: "1235", + Populated: flags.PopulatedFlags{ + flags.ListModifiedBeforeFN: struct{}{}, + }, + }, + expect: assert.Error, + }, } for _, test := range table { suite.Run(test.name, func() { @@ -359,7 +411,7 @@ func (suite *SharePointUtilsSuite) TestValidateSharePointRestoreFlags() { } } -// [TODO] uncomment the test cases once sharepoint list backup is enabled +// [TODO]hitesh uncomment the test cases once sharepoint list backup is enabled func (suite *SharePointUtilsSuite) TestAddSharepointCategories() { table := []struct { name string diff --git a/src/internal/m365/collection/site/handlers.go b/src/internal/m365/collection/site/handlers.go index bb47c6d07..bbc19ed3a 100644 --- a/src/internal/m365/collection/site/handlers.go +++ b/src/internal/m365/collection/site/handlers.go @@ -36,7 +36,10 @@ type getItemser interface { type restoreHandler interface { PostLister + PatchLister DeleteLister + GetLister + GetListsByCollisionKeyser } type PostLister interface { @@ -48,9 +51,35 @@ type PostLister interface { ) (models.Listable, error) } +type PatchLister interface { + PatchList( + ctx context.Context, + listID string, + list models.Listable, + ) (models.Listable, error) +} + type DeleteLister interface { DeleteList( ctx context.Context, listID string, ) error } + +type GetLister interface { + GetList( + ctx context.Context, + listID string, + ) (models.Listable, *details.SharePointInfo, error) +} + +type GetListsByCollisionKeyser interface { + // GetListsByCollisionKey looks up all lists currently in + // the site, and returns them in a map[collisionKey]listID. + // The collision key is displayName of the list + // which uniquely identifies the list. + // Collision key checks are used during restore to handle the on- + // collision restore configurations that cause the list restore to get + // skipped, replaced, or copied. + GetListsByCollisionKey(ctx context.Context) (map[string]string, error) +} diff --git a/src/internal/m365/collection/site/lists_handler.go b/src/internal/m365/collection/site/lists_handler.go index 4e4d08cd3..ad466ebdd 100644 --- a/src/internal/m365/collection/site/lists_handler.go +++ b/src/internal/m365/collection/site/lists_handler.go @@ -73,9 +73,28 @@ func (rh listsRestoreHandler) PostList( return rh.ac.PostList(ctx, rh.protectedResource, listName, storedList, errs) } +func (rh listsRestoreHandler) PatchList( + ctx context.Context, + listID string, + list models.Listable, +) (models.Listable, error) { + return rh.ac.PatchList(ctx, rh.protectedResource, listID, list) +} + func (rh listsRestoreHandler) DeleteList( ctx context.Context, listID string, ) error { return rh.ac.DeleteList(ctx, rh.protectedResource, listID) } + +func (rh listsRestoreHandler) GetList( + ctx context.Context, + listID string, +) (models.Listable, *details.SharePointInfo, error) { + return rh.ac.GetListByID(ctx, rh.protectedResource, listID) +} + +func (rh listsRestoreHandler) GetListsByCollisionKey(ctx context.Context) (map[string]string, error) { + return rh.ac.GetListsByCollisionKey(ctx, rh.protectedResource) +} diff --git a/src/internal/m365/collection/site/mock/list.go b/src/internal/m365/collection/site/mock/list.go index ede41d372..349344555 100644 --- a/src/internal/m365/collection/site/mock/list.go +++ b/src/internal/m365/collection/site/mock/list.go @@ -11,6 +11,7 @@ import ( "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/services/m365/api" ) @@ -94,21 +95,53 @@ func (lh *ListHandler) Check(t *testing.T, expected []string) { } type ListRestoreHandler struct { - List models.Listable - Err error + deleteListErr error + postListErr error + patchListErr error +} + +func NewListRestoreHandler(deleteListErr error, postListErr, patchListErr error) *ListRestoreHandler { + return &ListRestoreHandler{ + deleteListErr: deleteListErr, + postListErr: postListErr, + patchListErr: patchListErr, + } } func (lh *ListRestoreHandler) PostList( ctx context.Context, listName string, - storedListBytes []byte, + storedList models.Listable, + _ *fault.Bus, ) (models.Listable, error) { + ls, _ := api.ToListable(storedList, listName) + return ls, lh.postListErr +} + +func (lh *ListRestoreHandler) PatchList( + ctx context.Context, + listID string, + list models.Listable, +) (models.Listable, error) { + return nil, lh.patchListErr +} + +func (lh *ListRestoreHandler) GetList( + ctx context.Context, + listID string, +) (models.Listable, *details.SharePointInfo, error) { ls := models.NewList() + ls.SetId(ptr.To(listID)) - lh.List = ls - lh.List.SetDisplayName(ptr.To(listName)) + return ls, api.ListToSPInfo(ls), nil +} - return lh.List, lh.Err +func (lh *ListRestoreHandler) DeleteList(_ context.Context, _ string) error { + return lh.deleteListErr +} + +func (lh *ListRestoreHandler) GetListsByCollisionKey(ctx context.Context) (map[string]string, error) { + return map[string]string{}, nil } func StubLists(ids ...string) []models.Listable { diff --git a/src/internal/m365/collection/site/restore.go b/src/internal/m365/collection/site/restore.go index 5721dcbf5..81a7308a3 100644 --- a/src/internal/m365/collection/site/restore.go +++ b/src/internal/m365/collection/site/restore.go @@ -10,18 +10,15 @@ import ( "github.com/alcionai/clues" "github.com/microsoftgraph/msgraph-sdk-go/models" - "github.com/alcionai/corso/src/internal/common/idname" "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/diagnostics" - "github.com/alcionai/corso/src/internal/m365/collection/drive" betaAPI "github.com/alcionai/corso/src/internal/m365/service/sharepoint/api" "github.com/alcionai/corso/src/internal/m365/support" - "github.com/alcionai/corso/src/internal/operations/inject" "github.com/alcionai/corso/src/pkg/backup/details" "github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/count" - "github.com/alcionai/corso/src/pkg/dttm" + "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" @@ -29,107 +26,6 @@ import ( "github.com/alcionai/corso/src/pkg/services/m365/api/graph" ) -// ConsumeRestoreCollections will restore the specified data collections into OneDrive -func ConsumeRestoreCollections( - ctx context.Context, - rcc inject.RestoreConsumerConfig, - ac api.Client, - backupDriveIDNames idname.Cacher, - dcs []data.RestoreCollection, - deets *details.Builder, - errs *fault.Bus, - ctr *count.Bus, -) (*support.ControllerOperationStatus, error) { - var ( - lrh = drive.NewSiteRestoreHandler(ac, rcc.Selector.PathService()) - listsRh = NewListsRestoreHandler(rcc.ProtectedResource.ID(), ac.Lists()) - restoreMetrics support.CollectionMetrics - caches = drive.NewRestoreCaches(backupDriveIDNames) - el = errs.Local() - ) - - err := caches.Populate(ctx, ac.Users(), ac.Groups(), lrh, rcc.ProtectedResource.ID(), errs) - if err != nil { - return nil, clues.Wrap(err, "initializing restore caches") - } - - // Reorder collections so that the parents directories are created - // before the child directories; a requirement for permissions. - data.SortRestoreCollections(dcs) - - // Iterate through the data collections and restore the contents of each - for _, dc := range dcs { - if el.Failure() != nil { - break - } - - var ( - err error - category = dc.FullPath().Category() - metrics support.CollectionMetrics - ictx = clues.Add(ctx, - "category", category, - "restore_location", clues.Hide(rcc.RestoreConfig.Location), - "resource_owner", clues.Hide(dc.FullPath().ProtectedResource()), - "full_path", dc.FullPath()) - ) - - switch dc.FullPath().Category() { - case path.LibrariesCategory: - metrics, err = drive.RestoreCollection( - ictx, - lrh, - rcc, - dc, - caches, - deets, - control.DefaultRestoreContainerName(dttm.HumanReadableDriveItem), - errs, - ctr) - - case path.ListsCategory: - metrics, err = RestoreListCollection( - ictx, - listsRh, - dc, - rcc.RestoreConfig.Location, - deets, - errs) - - case path.PagesCategory: - metrics, err = RestorePageCollection( - ictx, - ac.Stable, - dc, - rcc.RestoreConfig.Location, - deets, - errs) - - default: - return nil, clues.Wrap(clues.New(category.String()), "category not supported").With("category", category) - } - - restoreMetrics = support.CombineMetrics(restoreMetrics, metrics) - - if err != nil { - el.AddRecoverable(ctx, err) - } - - if errors.Is(err, context.Canceled) { - break - } - } - - status := support.CreateStatus( - ctx, - support.Restore, - len(dcs), - restoreMetrics, - rcc.RestoreConfig.Location) - - return status, el.Failure() -} - // restoreListItem utility function restores a List to the siteID. // The name is changed to to {DestName}_{name} // API Reference: https://learn.microsoft.com/en-us/graph/api/list-create?view=graph-rest-1.0&tabs=http @@ -138,12 +34,17 @@ func restoreListItem( ctx context.Context, rh restoreHandler, itemData data.Item, - siteID, destName string, + siteID string, + restoreCfg control.RestoreConfig, + collisionKeyToItemID map[string]string, + ctr *count.Bus, errs *fault.Bus, ) (details.ItemInfo, error) { var ( - dii = details.ItemInfo{} - itemID = itemData.ID() + dii = details.ItemInfo{} + itemID = itemData.ID() + destName = restoreCfg.Location + collisionPolicy = restoreCfg.OnCollision ) ctx, end := diagnostics.Span(ctx, "m365:sharepoint:restoreList", diagnostics.Label("item_uuid", itemData.ID())) @@ -161,25 +62,101 @@ func restoreListItem( return dii, clues.WrapWC(ctx, err, "generating list from stored bytes") } - newName := formatListsRestoreDestination(destName, itemID, storedList) + var ( + collisionKey = api.ListCollisionKey(storedList) + collisionID string + restoredList models.Listable + newName = formatListsRestoreDestination(destName, itemID, storedList) + ) + + if id, ok := collisionKeyToItemID[collisionKey]; ok { + log := logger.Ctx(ctx).With("collision_key", clues.Hide(collisionKey)) + log.Debug("item collision") + + if collisionPolicy == control.Skip { + ctr.Inc(count.CollisionSkip) + log.Debug("skipping item with collision") + + return dii, clues.Stack(core.ErrAlreadyExists) + } + + collisionID = id + } + + if collisionPolicy != control.Replace { + restoredList, err = rh.PostList(ctx, newName, storedList, errs) + if err != nil { + return dii, clues.Wrap(err, "restoring list") + } + } else { + restoredList, err = handleListReplace( + ctx, + collisionID, + storedList, + newName, + rh, + ctr, + errs) + if err != nil { + return dii, clues.Stack(err) + } + } // Restore to List base to M365 back store - restoredList, err := rh.PostList(ctx, newName, storedList, errs) - if err != nil { - return dii, graph.Wrap(ctx, err, "restoring list") - } dii.SharePoint = api.ListToSPInfo(restoredList) return dii, nil } +func handleListReplace( + ctx context.Context, + listID string, + listFromBackup models.Listable, + newName string, + rh restoreHandler, + ctr *count.Bus, + errs *fault.Bus, +) (models.Listable, error) { + restoredList, err := rh.PostList( + ctx, + newName, + listFromBackup, + errs) + if err != nil { + return nil, clues.WrapWC(ctx, err, "restoring list") + } + + err = rh.DeleteList(ctx, listID) + if err != nil { + return nil, clues.WrapWC(ctx, err, "deleting collided list") + } + + patchList := models.NewList() + patchList.SetDisplayName(listFromBackup.GetDisplayName()) + _, err = rh.PatchList( + ctx, + ptr.Val(restoredList.GetId()), + patchList) + + if err != nil { + return nil, clues.WrapWC(ctx, err, "patching list") + } + + restoredList.SetDisplayName(listFromBackup.GetDisplayName()) + ctr.Inc(count.CollisionReplace) + + return restoredList, nil +} + func RestoreListCollection( ctx context.Context, rh restoreHandler, dc data.RestoreCollection, - restoreContainerName string, + restoreCfg control.RestoreConfig, deets *details.Builder, + collisionKeyToItemID map[string]string, + ctr *count.Bus, errs *fault.Bus, ) (support.CollectionMetrics, error) { ctx, end := diagnostics.Span(ctx, "m365:sharepoint:restoreListCollection", diagnostics.Label("path", dc.FullPath())) @@ -215,7 +192,9 @@ func RestoreListCollection( rh, itemData, siteID, - restoreContainerName, + restoreCfg, + collisionKeyToItemID, + ctr, errs) if errors.Is(err, api.ErrSkippableListTemplate) { // should never be encountered as lists with skippable template are not backed up diff --git a/src/internal/m365/collection/site/restore_test.go b/src/internal/m365/collection/site/restore_test.go index 2d6dae751..0271d021f 100644 --- a/src/internal/m365/collection/site/restore_test.go +++ b/src/internal/m365/collection/site/restore_test.go @@ -3,6 +3,7 @@ package site import ( "bytes" "context" + "errors" "fmt" "io" "testing" @@ -17,6 +18,7 @@ import ( "github.com/alcionai/corso/src/internal/common/readers" "github.com/alcionai/corso/src/internal/data" dataMock "github.com/alcionai/corso/src/internal/data/mock" + siteMock "github.com/alcionai/corso/src/internal/m365/collection/site/mock" spMock "github.com/alcionai/corso/src/internal/m365/service/sharepoint/mock" "github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester/tconfig" @@ -26,6 +28,7 @@ import ( "github.com/alcionai/corso/src/pkg/control/testdata" "github.com/alcionai/corso/src/pkg/count" "github.com/alcionai/corso/src/pkg/dttm" + "github.com/alcionai/corso/src/pkg/errs/core" "github.com/alcionai/corso/src/pkg/fault" "github.com/alcionai/corso/src/pkg/services/m365/api" "github.com/alcionai/corso/src/pkg/services/m365/api/graph" @@ -127,16 +130,30 @@ func (suite *SharePointRestoreSuite) TestListCollection_Restore() { ctx, flush := tester.NewContext(t) defer flush() - testName, lrh, destName, mockData := setupDependencies( - suite, - suite.ac, - suite.siteID, - suite.creds, - "genericList") + var ( + listName = "MockListing" + listTemplate = "genericList" + restoreCfg = testdata.DefaultRestoreConfig("") + destName = restoreCfg.Location + lrh = NewListsRestoreHandler(suite.siteID, suite.ac.Lists()) + service = createTestService(t, suite.creds) + list = stubList(listTemplate, listName) + mockData = generateListData(t, service, list) + ) - deets, err := restoreListItem(ctx, lrh, mockData, suite.siteID, destName, fault.New(true)) + restoreCfg.OnCollision = control.Copy + + deets, err := restoreListItem( + ctx, + lrh, + mockData, + suite.siteID, + restoreCfg, + nil, + count.New(), + fault.New(true)) require.NoError(t, err, clues.ToCore(err)) - assert.Equal(t, fmt.Sprintf("%s_%s", destName, testName), deets.SharePoint.List.Name) + assert.Equal(t, fmt.Sprintf("%s_%s", destName, listName), deets.SharePoint.List.Name) // Clean-Up deleteList(ctx, t, suite.siteID, lrh, deets) @@ -148,37 +165,28 @@ func (suite *SharePointRestoreSuite) TestListCollection_Restore_invalidListTempl ctx, flush := tester.NewContext(t) defer flush() + var ( + lrh = NewListsRestoreHandler(suite.siteID, suite.ac.Lists()) + listName = "MockListing" + restoreCfg = testdata.DefaultRestoreConfig("") + service = createTestService(t, suite.creds) + ) + + restoreCfg.OnCollision = control.Copy + tests := []struct { - name string - getParams func() (listsRestoreHandler, string, *dataMock.Item) - expect assert.ErrorAssertionFunc + name string + list models.Listable + expect assert.ErrorAssertionFunc }{ { - name: "list with template documentLibrary", - getParams: func() (listsRestoreHandler, string, *dataMock.Item) { - _, lrh, destName, mockData := setupDependencies( - suite, - suite.ac, - suite.siteID, - suite.creds, - api.DocumentLibraryListTemplate) - - return lrh, destName, mockData - }, + name: "list with template documentLibrary", + list: stubList(api.DocumentLibraryListTemplate, listName), expect: assert.Error, }, { - name: "list with template webTemplateExtensionsList", - getParams: func() (listsRestoreHandler, string, *dataMock.Item) { - _, lrh, destName, mockData := setupDependencies( - suite, - suite.ac, - suite.siteID, - suite.creds, - api.WebTemplateExtensionsListTemplate) - - return lrh, destName, mockData - }, + name: "list with template webTemplateExtensionsList", + list: stubList(api.WebTemplateExtensionsListTemplate, listName), expect: assert.Error, }, } @@ -187,14 +195,16 @@ func (suite *SharePointRestoreSuite) TestListCollection_Restore_invalidListTempl suite.Run(test.name, func() { t := suite.T() - lrh, destName, mockData := test.getParams() + listData := generateListData(t, service, test.list) _, err := restoreListItem( ctx, lrh, - mockData, + listData, suite.siteID, - destName, + restoreCfg, + nil, + count.New(), fault.New(false)) require.Error(t, err) assert.Contains(t, err.Error(), api.ErrSkippableListTemplate.Error()) @@ -202,6 +212,169 @@ func (suite *SharePointRestoreSuite) TestListCollection_Restore_invalidListTempl } } +func (suite *SharePointRestoreSuite) TestListCollection_RestoreInPlace_skip() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + var ( + listName = "MockListing" + listTemplate = "genericList" + restoreCfg = testdata.DefaultRestoreConfig("") + lrh = NewListsRestoreHandler(suite.siteID, suite.ac.Lists()) + service = createTestService(t, suite.creds) + list = stubList(listTemplate, listName) + newList = stubList(listTemplate, listName) + cl = count.New() + ) + + mockData := generateListData(t, service, list) + + collisionKeyToItemID := map[string]string{ + api.ListCollisionKey(newList): "some-list-id", + } + + deets, err := restoreListItem( + ctx, + lrh, + mockData, + suite.siteID, + restoreCfg, // OnCollision is skip by default + collisionKeyToItemID, + cl, + fault.New(true)) + require.Error(t, err, clues.ToCore(err)) + assert.Equal(t, core.ErrAlreadyExists.Error(), err.Error()) + assert.Empty(t, deets) + assert.Less(t, int64(0), cl.Get(count.CollisionSkip)) +} + +func (suite *SharePointRestoreSuite) TestListCollection_RestoreInPlace_copy() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + var ( + listName = "MockListing" + listTemplate = "genericList" + listID = "some-list-id" + restoreCfg = testdata.DefaultRestoreConfig("") + service = createTestService(t, suite.creds) + + policyToKey = map[control.CollisionPolicy]count.Key{ + control.Replace: count.CollisionReplace, + control.Skip: count.CollisionSkip, + } + ) + + list := stubList(listTemplate, listName) + list.SetId(ptr.To(listID)) + + newList := stubList(listTemplate, listName) + newList.SetId(ptr.To(listID)) + + collisionKeyToItemID := map[string]string{ + api.ListCollisionKey(newList): listID, + } + + tests := []struct { + name string + lrh *siteMock.ListRestoreHandler + expectErr assert.ErrorAssertionFunc + collisionPolicy control.CollisionPolicy + expectCollisionCount int64 + }{ + { + name: "PostList fails for stored list", + lrh: siteMock.NewListRestoreHandler( + nil, + errors.New("failed to create list"), + nil), + collisionPolicy: control.Replace, + expectErr: assert.Error, + }, + { + name: "DeleteList fails", + lrh: siteMock.NewListRestoreHandler( + errors.New("failed to delete list"), + nil, + nil), + collisionPolicy: control.Replace, + expectErr: assert.Error, + }, + { + name: "PatchList fails", + lrh: siteMock.NewListRestoreHandler( + nil, + nil, + errors.New("failed to patch list")), + collisionPolicy: control.Replace, + expectErr: assert.Error, + }, + { + name: "PostList passes for stored list", + lrh: siteMock.NewListRestoreHandler( + nil, + nil, + nil), + collisionPolicy: control.Replace, + expectErr: assert.NoError, + expectCollisionCount: 1, + }, + { + name: "Skip collison policy", + lrh: siteMock.NewListRestoreHandler( + nil, + nil, + nil), + collisionPolicy: control.Skip, + expectErr: assert.Error, + expectCollisionCount: 1, + }, + { + name: "Copy collison policy", + lrh: siteMock.NewListRestoreHandler( + nil, + nil, + nil), + collisionPolicy: control.Copy, + expectErr: assert.NoError, + }, + } + + for _, test := range tests { + suite.Run(test.name, func() { + mockData := generateListData(t, service, list) + cl := count.New() + restoreCfg.OnCollision = test.collisionPolicy + + _, err := restoreListItem( + ctx, + test.lrh, + mockData, + suite.siteID, + restoreCfg, + collisionKeyToItemID, + cl, + fault.New(true)) + test.expectErr(t, err) + + if test.collisionPolicy == control.Skip { + assert.Equal(t, core.ErrAlreadyExists.Error(), err.Error()) + } + + if test.collisionPolicy == control.Copy { + assert.Zero(t, cl.Get(count.CollisionSkip)) + assert.Zero(t, cl.Get(count.CollisionReplace)) + } + + assert.Equal(t, test.expectCollisionCount, cl.Get(policyToKey[test.collisionPolicy])) + }) + } +} + func deleteList( ctx context.Context, t *testing.T, @@ -234,46 +407,40 @@ func deleteList( } } -func setupDependencies( - suite tester.Suite, - ac api.Client, - siteID string, - creds account.M365Config, - listTemplate string) ( - string, listsRestoreHandler, string, *dataMock.Item, -) { - t := suite.T() - testName := "MockListing" +func generateListData( + t *testing.T, + service *graph.Service, + list models.Listable, +) *dataMock.Item { + listName := ptr.Val(list.GetDisplayName()) - lrh := NewListsRestoreHandler(siteID, ac.Lists()) - - service := createTestService(t, creds) - - listInfo := models.NewListInfo() - listInfo.SetTemplate(ptr.To(listTemplate)) - - listing := spMock.ListDefault("Mock List") - listing.SetDisplayName(&testName) - listing.SetList(listInfo) - - byteArray, err := service.Serialize(listing) + byteArray, err := service.Serialize(list) require.NoError(t, err, clues.ToCore(err)) - destName := testdata.DefaultRestoreConfig("").Location - listData, err := data.NewPrefetchedItemWithInfo( io.NopCloser(bytes.NewReader(byteArray)), - testName, - details.ItemInfo{SharePoint: api.ListToSPInfo(listing)}) + listName, + details.ItemInfo{SharePoint: api.ListToSPInfo(list)}) require.NoError(t, err, clues.ToCore(err)) r, err := readers.NewVersionedRestoreReader(listData.ToReader()) require.NoError(t, err) mockData := &dataMock.Item{ - ItemID: testName, + ItemID: listName, Reader: r, } - return testName, lrh, destName, mockData + return mockData +} + +func stubList(listTemplate, listDisplayName string) models.Listable { + listInfo := models.NewListInfo() + listInfo.SetTemplate(ptr.To(listTemplate)) + + listing := spMock.ListDefault("Mock List") + listing.SetDisplayName(ptr.To(listDisplayName)) + listing.SetList(listInfo) + + return listing } diff --git a/src/internal/m365/service/sharepoint/restore.go b/src/internal/m365/service/sharepoint/restore.go index 1568c4c5f..eb519eb65 100644 --- a/src/internal/m365/service/sharepoint/restore.go +++ b/src/internal/m365/service/sharepoint/restore.go @@ -56,6 +56,7 @@ func (h *sharepointHandler) ConsumeRestoreCollections( caches = drive.NewRestoreCaches(h.backupDriveIDNames) el = errs.Local() + cl = ctr.Local() ) // Reorder collections so that the parents directories are created @@ -77,6 +78,7 @@ func (h *sharepointHandler) ConsumeRestoreCollections( "restore_location", clues.Hide(rcc.RestoreConfig.Location), "resource_owner", clues.Hide(dc.FullPath().ProtectedResource()), "full_path", dc.FullPath()) + collisionKeyToItemID map[string]string ) switch dc.FullPath().Category() { @@ -98,12 +100,20 @@ func (h *sharepointHandler) ConsumeRestoreCollections( ctr) case path.ListsCategory: + collisionKeyToItemID, err = listsRh.GetListsByCollisionKey(ictx) + if err != nil { + el.AddRecoverable(ictx, clues.Wrap(err, "building lists collision map")) + continue + } + metrics, err = site.RestoreListCollection( ictx, listsRh, dc, - rcc.RestoreConfig.Location, + rcc.RestoreConfig, deets, + collisionKeyToItemID, + cl, errs) case path.PagesCategory: diff --git a/src/internal/m365/service/sharepoint/restore_test.go b/src/internal/m365/service/sharepoint/restore_test.go deleted file mode 100644 index 0551397b9..000000000 --- a/src/internal/m365/service/sharepoint/restore_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package sharepoint - -import ( - "testing" - - "github.com/alcionai/clues" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" - - "github.com/alcionai/corso/src/internal/common/idname" - "github.com/alcionai/corso/src/internal/data" - "github.com/alcionai/corso/src/internal/data/mock" - "github.com/alcionai/corso/src/internal/operations/inject" - "github.com/alcionai/corso/src/internal/tester" - "github.com/alcionai/corso/src/pkg/fault" - "github.com/alcionai/corso/src/pkg/path" - "github.com/alcionai/corso/src/pkg/services/m365/api" -) - -type SharepointRestoreUnitSuite struct { - tester.Suite -} - -func TestSharepointRestoreUnitSuite(t *testing.T) { - suite.Run(t, &SharepointRestoreUnitSuite{Suite: tester.NewUnitSuite(t)}) -} - -func (suite *SharepointRestoreUnitSuite) TestSharePointHandler_ConsumeRestoreCollections_noErrorOnLists() { - t := suite.T() - siteID := "site-id" - - ctx, flush := tester.NewContext(t) - defer flush() - - pr := idname.NewProvider(siteID, siteID) - rcc := inject.RestoreConsumerConfig{ - ProtectedResource: pr, - } - pth, err := path.Builder{}. - Append("lists"). - ToDataLayerPath( - "tenant", - siteID, - path.SharePointService, - path.ListsCategory, - false) - require.NoError(t, err, clues.ToCore(err)) - - dcs := []data.RestoreCollection{ - mock.Collection{Path: pth}, - } - - sh := NewSharePointHandler(api.Client{}, nil) - - _, _, err = sh.ConsumeRestoreCollections( - ctx, - rcc, - dcs, - fault.New(false), - nil) - require.NoError(t, err, "Sharepoint lists restore") -} diff --git a/src/pkg/backup/details/details_test.go b/src/pkg/backup/details/details_test.go index a0ba8d077..957207ebc 100644 --- a/src/pkg/backup/details/details_test.go +++ b/src/pkg/backup/details/details_test.go @@ -158,13 +158,13 @@ func (suite *DetailsUnitSuite) TestDetailsEntry_HeadersValues() { ItemInfo: ItemInfo{ SharePoint: &SharePointInfo{ ItemType: SharePointList, + Created: now, + Modified: now, + WebURL: "https://example.com/Lists/list1", List: &ListInfo{ Name: "list1", ItemCount: 50, Template: "genericList", - Created: now, - Modified: now, - WebURL: "https://10rqc2.sharepoint.com/sites/site-4754-small-lists/Lists/list1", }, }, }, @@ -188,13 +188,13 @@ func (suite *DetailsUnitSuite) TestDetailsEntry_HeadersValues() { ItemInfo: ItemInfo{ SharePoint: &SharePointInfo{ ItemType: SharePointList, + Created: now, + Modified: now, + WebURL: "https://example.com/Lists/Shared%20Documents", List: &ListInfo{ Name: "Shared%20Documents", ItemCount: 50, Template: "documentLibrary", - Created: now, - Modified: now, - WebURL: "https://10rqc2.sharepoint.com/sites/site-4754-small-lists/Lists/Shared%20Documents", }, }, }, diff --git a/src/pkg/backup/details/sharepoint.go b/src/pkg/backup/details/sharepoint.go index 1c51d428e..3dd6fc27b 100644 --- a/src/pkg/backup/details/sharepoint.go +++ b/src/pkg/backup/details/sharepoint.go @@ -52,12 +52,9 @@ type SharePointInfo struct { } type ListInfo struct { - Name string `json:"name,omitempty"` - ItemCount int64 `json:"itemCount,omitempty"` - Template string `json:"template,omitempty"` - WebURL string `json:"webUrl,omitempty"` - Created time.Time `json:"created,omitempty"` - Modified time.Time `json:"modified,omitempty"` + Name string `json:"name,omitempty"` + ItemCount int64 `json:"itemCount,omitempty"` + Template string `json:"template,omitempty"` } // Headers returns the human-readable names of properties in a SharePointInfo @@ -91,8 +88,8 @@ func (i SharePointInfo) Values() []string { return []string{ i.List.Name, fmt.Sprintf("%d", i.List.ItemCount), - dttm.FormatToTabularDisplay(i.List.Created), - dttm.FormatToTabularDisplay(i.List.Modified), + dttm.FormatToTabularDisplay(i.Created), + dttm.FormatToTabularDisplay(i.Modified), } } diff --git a/src/pkg/selectors/sharepoint.go b/src/pkg/selectors/sharepoint.go index 8b5951aef..7f14696a6 100644 --- a/src/pkg/selectors/sharepoint.go +++ b/src/pkg/selectors/sharepoint.go @@ -410,6 +410,46 @@ func (s *sharePoint) ModifiedBefore(timeStrings string) []SharePointScope { } } +func (s *sharePoint) ListModifiedAfter(timeStrings string) []SharePointScope { + return []SharePointScope{ + makeInfoScope[SharePointScope]( + SharePointListItem, + SharePointListInfoModifiedAfter, + []string{timeStrings}, + filters.Less), + } +} + +func (s *sharePoint) ListModifiedBefore(timeStrings string) []SharePointScope { + return []SharePointScope{ + makeInfoScope[SharePointScope]( + SharePointListItem, + SharePointListInfoModifiedBefore, + []string{timeStrings}, + filters.Greater), + } +} + +func (s *sharePoint) ListCreatedAfter(timeStrings string) []SharePointScope { + return []SharePointScope{ + makeInfoScope[SharePointScope]( + SharePointListItem, + SharePointListInfoCreatedAfter, + []string{timeStrings}, + filters.Less), + } +} + +func (s *sharePoint) ListCreatedBefore(timeStrings string) []SharePointScope { + return []SharePointScope{ + makeInfoScope[SharePointScope]( + SharePointListItem, + SharePointListInfoCreatedBefore, + []string{timeStrings}, + filters.Greater), + } +} + // --------------------------------------------------------------------------- // Categories // --------------------------------------------------------------------------- @@ -440,6 +480,11 @@ const ( SharePointInfoModifiedAfter sharePointCategory = "SharePointInfoModifiedAfter" SharePointInfoModifiedBefore sharePointCategory = "SharePointInfoModifiedBefore" + SharePointListInfoModifiedAfter sharePointCategory = "SharePointListInfoModifiedAfter" + SharePointListInfoModifiedBefore sharePointCategory = "SharePointListInfoModifiedBefore" + SharePointListInfoCreatedAfter sharePointCategory = "SharePointListInfoCreatedAfter" + SharePointListInfoCreatedBefore sharePointCategory = "SharePointListInfoCreatedBefore" + // library drive selection SharePointInfoLibraryDrive sharePointCategory = "SharePointInfoLibraryDrive" ) @@ -479,7 +524,9 @@ func (c sharePointCategory) leafCat() categorizer { SharePointInfoCreatedAfter, SharePointInfoCreatedBefore, SharePointInfoModifiedAfter, SharePointInfoModifiedBefore: return SharePointLibraryItem - case SharePointList, SharePointListItem: + case SharePointList, SharePointListItem, + SharePointListInfoModifiedAfter, SharePointListInfoModifiedBefore, + SharePointListInfoCreatedAfter, SharePointListInfoCreatedBefore: return SharePointListItem case SharePointPage, SharePointPageFolder: return SharePointPage @@ -715,9 +762,11 @@ func (s SharePointScope) matchesInfo(dii details.ItemInfo) bool { switch infoCat { case SharePointWebURL: i = info.WebURL - case SharePointInfoCreatedAfter, SharePointInfoCreatedBefore: + case SharePointInfoCreatedAfter, SharePointInfoCreatedBefore, + SharePointListInfoCreatedAfter, SharePointListInfoCreatedBefore: i = dttm.Format(info.Created) - case SharePointInfoModifiedAfter, SharePointInfoModifiedBefore: + case SharePointInfoModifiedAfter, SharePointInfoModifiedBefore, + SharePointListInfoModifiedAfter, SharePointListInfoModifiedBefore: i = dttm.Format(info.Modified) case SharePointInfoLibraryDrive: ds := []string{} diff --git a/src/pkg/selectors/sharepoint_test.go b/src/pkg/selectors/sharepoint_test.go index 30eb3abef..1fbc21e8c 100644 --- a/src/pkg/selectors/sharepoint_test.go +++ b/src/pkg/selectors/sharepoint_test.go @@ -544,6 +544,19 @@ func (suite *SharePointSelectorSuite) TestSharePointScope_MatchesInfo() { {"file modified before future", host, sel.ModifiedBefore(dttm.Format(future)), assert.True}, {"file modified before now", host, sel.ModifiedBefore(dttm.Format(now)), assert.False}, {"file modified before epoch", host, sel.ModifiedBefore(dttm.Format(now)), assert.False}, + {"list create after the epoch", host, sel.ListCreatedAfter(dttm.Format(epoch)), assert.True}, + {"list create after now", host, sel.ListCreatedAfter(dttm.Format(now)), assert.False}, + {"list create after later", url, sel.ListCreatedAfter(dttm.Format(future)), assert.False}, + {"list create before future", host, sel.ListCreatedBefore(dttm.Format(future)), assert.True}, + {"list create before now", host, sel.ListCreatedBefore(dttm.Format(now)), assert.False}, + {"list create before modification", host, sel.ListCreatedBefore(dttm.Format(modification)), assert.True}, + {"list create before epoch", host, sel.ListCreatedBefore(dttm.Format(now)), assert.False}, + {"list modified after the epoch", host, sel.ListModifiedAfter(dttm.Format(epoch)), assert.True}, + {"list modified after now", host, sel.ListModifiedAfter(dttm.Format(now)), assert.True}, + {"list modified after later", host, sel.ListModifiedAfter(dttm.Format(future)), assert.False}, + {"list modified before future", host, sel.ListModifiedBefore(dttm.Format(future)), assert.True}, + {"list modified before now", host, sel.ListModifiedBefore(dttm.Format(now)), assert.False}, + {"list modified before epoch", host, sel.ListModifiedBefore(dttm.Format(now)), assert.False}, {"in library", host, sel.Library("included-library"), assert.True}, {"not in library", host, sel.Library("not-included-library"), assert.False}, {"library id", host, sel.Library("1234"), assert.True}, diff --git a/src/pkg/services/m365/api/lists.go b/src/pkg/services/m365/api/lists.go index 4a0809152..80493f4ce 100644 --- a/src/pkg/services/m365/api/lists.go +++ b/src/pkg/services/m365/api/lists.go @@ -244,6 +244,22 @@ func (c Lists) DeleteList( return graph.Wrap(ctx, err, "deleting list").OrNil() } +func (c Lists) PatchList( + ctx context.Context, + siteID, listID string, + list models.Listable, +) (models.Listable, error) { + patchedList, err := c.Stable. + Client(). + Sites(). + BySiteId(siteID). + Lists(). + ByListId(listID). + Patch(ctx, list, nil) + + return patchedList, graph.Wrap(ctx, err, "patching list").OrNil() +} + func BytesToListable(bytes []byte) (models.Listable, error) { parsable, err := CreateFromBytes(bytes, models.CreateListFromDiscriminatorValue) if err != nil { @@ -591,13 +607,12 @@ func ListToSPInfo(lst models.Listable) *details.SharePointInfo { return &details.SharePointInfo{ ItemType: details.SharePointList, Modified: modified, + Created: created, + WebURL: webURL, List: &details.ListInfo{ Name: name, ItemCount: int64(count), Template: template, - Created: created, - Modified: modified, - WebURL: webURL, }, } } diff --git a/src/pkg/services/m365/api/lists_test.go b/src/pkg/services/m365/api/lists_test.go index ef1195b75..100cb9288 100644 --- a/src/pkg/services/m365/api/lists_test.go +++ b/src/pkg/services/m365/api/lists_test.go @@ -774,6 +774,7 @@ func (suite *ListsAPIIntgSuite) TestLists_GetListByID() { list.SetId(ptr.To(listID)) list.SetDisplayName(ptr.To(listName)) list.SetList(listInfo) + list.SetCreatedDateTime(ptr.To(time.Now())) list.SetLastModifiedDateTime(ptr.To(time.Now())) txtColumnDef := models.NewColumnDefinition() @@ -911,8 +912,8 @@ func (suite *ListsAPIIntgSuite) TestLists_GetListByID() { assert.Equal(t, listName, info.List.Name) assert.Equal(t, int64(1), info.List.ItemCount) assert.Equal(t, listTemplate, info.List.Template) - assert.NotEmpty(t, info.List.Modified) assert.NotEmpty(t, info.Modified) + assert.NotEmpty(t, info.Created) }) } } @@ -1016,6 +1017,50 @@ func (suite *ListsAPIIntgSuite) TestLists_PostList_invalidTemplate() { } } +func (suite *ListsAPIIntgSuite) TestLists_PatchList() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + var ( + acl = suite.its.ac.Lists() + siteID = suite.its.site.id + listName = "old-list-name" + newListName = "new-list-name" + ) + + fieldsData, list := getFieldsDataAndList() + + createdList, err := acl.PostList(ctx, siteID, listName, list, fault.New(true)) + require.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, listName, ptr.Val(createdList.GetDisplayName())) + + listID := ptr.Val(createdList.GetId()) + + newList := models.NewList() + newList.SetDisplayName(ptr.To(newListName)) + patchedList, err := acl.PatchList(ctx, siteID, listID, newList) + require.NoError(t, err) + assert.Equal(t, newListName, ptr.Val(patchedList.GetDisplayName())) + + patchedList, _, err = acl.GetListByID(ctx, siteID, listID) + require.NoError(t, err) + + newListItems := patchedList.GetItems() + require.Less(t, 0, len(newListItems)) + + newListItemFields := newListItems[0].GetFields() + require.NotEmpty(t, newListItemFields) + + newListItemsData := newListItemFields.GetAdditionalData() + require.NotEmpty(t, newListItemsData) + assert.Equal(t, fieldsData["itemName"], newListItemsData["itemName"]) + + err = acl.DeleteList(ctx, siteID, ptr.Val(patchedList.GetId())) + require.NoError(t, err) +} + func (suite *ListsAPIIntgSuite) TestLists_DeleteList() { t := suite.T()