handle file-folder collision cases (#3688)

handle onedrive item collision cases where a file to be restored is in conflict with an existing folder. Skip and Copy will function normally.  For Replace behavior, we'll defer to Copy in this situation, so that the file gets restored without changing or deleting the folder.  If a folder creation attempts to replace an item, we do a similar action and make a renamed copy of the folder instead.

---

#### Does this PR need a docs update or release note?

- [x]  No

#### Type of change

- [x] 🧹 Tech Debt/Cleanup

#### Issue(s)

* #3562

#### Test Plan

- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Keepers 2023-07-03 20:34:53 -06:00 committed by GitHub
parent 411be17b17
commit 662d626809
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 325 additions and 63 deletions

View File

@ -117,7 +117,7 @@ type GetItemsByCollisionKeyser interface {
GetItemsInContainerByCollisionKey( GetItemsInContainerByCollisionKey(
ctx context.Context, ctx context.Context,
driveID, containerID string, driveID, containerID string,
) (map[string]string, error) ) (map[string]api.DriveCollisionItem, error)
} }
type NewItemContentUploader interface { type NewItemContentUploader interface {

View File

@ -164,7 +164,7 @@ func (h itemRestoreHandler) DeleteItemPermission(
func (h itemRestoreHandler) GetItemsInContainerByCollisionKey( func (h itemRestoreHandler) GetItemsInContainerByCollisionKey(
ctx context.Context, ctx context.Context,
driveID, containerID string, driveID, containerID string,
) (map[string]string, error) { ) (map[string]api.DriveCollisionItem, error) {
m, err := h.ac.GetItemsInContainerByCollisionKey(ctx, driveID, containerID) m, err := h.ac.GetItemsInContainerByCollisionKey(ctx, driveID, containerID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -233,7 +233,7 @@ func (m GetsItemPermission) GetItemPermission(
type RestoreHandler struct { type RestoreHandler struct {
ItemInfo details.ItemInfo ItemInfo details.ItemInfo
CollisionKeyMap map[string]string CollisionKeyMap map[string]api.DriveCollisionItem
CalledDeleteItem bool CalledDeleteItem bool
CalledDeleteItemOn string CalledDeleteItemOn string
@ -258,7 +258,7 @@ func (h *RestoreHandler) AugmentItemInfo(
func (h *RestoreHandler) GetItemsInContainerByCollisionKey( func (h *RestoreHandler) GetItemsInContainerByCollisionKey(
context.Context, context.Context,
string, string, string, string,
) (map[string]string, error) { ) (map[string]api.DriveCollisionItem, error) {
return h.CollisionKeyMap, nil return h.CollisionKeyMap, nil
} }

View File

@ -12,6 +12,7 @@ import (
"sync/atomic" "sync/atomic"
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
@ -36,7 +37,7 @@ const (
) )
type restoreCaches struct { type restoreCaches struct {
collisionKeyToItemID map[string]string collisionKeyToItemID map[string]api.DriveCollisionItem
DriveIDToRootFolderID map[string]string DriveIDToRootFolderID map[string]string
Folders *folderCache Folders *folderCache
OldLinkShareIDToNewID map[string]string OldLinkShareIDToNewID map[string]string
@ -48,7 +49,7 @@ type restoreCaches struct {
func NewRestoreCaches() *restoreCaches { func NewRestoreCaches() *restoreCaches {
return &restoreCaches{ return &restoreCaches{
collisionKeyToItemID: map[string]string{}, collisionKeyToItemID: map[string]api.DriveCollisionItem{},
DriveIDToRootFolderID: map[string]string{}, DriveIDToRootFolderID: map[string]string{},
Folders: NewFolderCache(), Folders: NewFolderCache(),
OldLinkShareIDToNewID: map[string]string{}, OldLinkShareIDToNewID: map[string]string{},
@ -468,10 +469,10 @@ func restoreV0File(
fibn data.FetchItemByNamer, fibn data.FetchItemByNamer,
restoreFolderID string, restoreFolderID string,
copyBuffer []byte, copyBuffer []byte,
collisionKeyToItemID map[string]string, collisionKeyToItemID map[string]api.DriveCollisionItem,
itemData data.Stream, itemData data.Stream,
) (details.ItemInfo, error) { ) (details.ItemInfo, error) {
_, itemInfo, err := restoreData( _, itemInfo, err := restoreFile(
ctx, ctx,
restoreCfg, restoreCfg,
rh, rh,
@ -504,7 +505,7 @@ func restoreV1File(
) (details.ItemInfo, error) { ) (details.ItemInfo, error) {
trimmedName := strings.TrimSuffix(itemData.UUID(), metadata.DataFileSuffix) trimmedName := strings.TrimSuffix(itemData.UUID(), metadata.DataFileSuffix)
itemID, itemInfo, err := restoreData( itemID, itemInfo, err := restoreFile(
ctx, ctx,
restoreCfg, restoreCfg,
rh, rh,
@ -587,7 +588,7 @@ func restoreV6File(
return details.ItemInfo{}, clues.New("item with empty name") return details.ItemInfo{}, clues.New("item with empty name")
} }
itemID, itemInfo, err := restoreData( itemID, itemInfo, err := restoreFile(
ctx, ctx,
restoreCfg, restoreCfg,
rh, rh,
@ -696,11 +697,11 @@ func createRestoreFolders(
"drive_id", drivePath.DriveID, "drive_id", drivePath.DriveID,
"root_folder_id", parentFolderID) "root_folder_id", parentFolderID)
for _, folder := range folders { for _, folderName := range folders {
location = location.Append(folder) location = location.Append(folderName)
ictx := clues.Add( ictx := clues.Add(
ctx, ctx,
"creating_restore_folder", folder, "creating_restore_folder", folderName,
"restore_folder_location", location, "restore_folder_location", location,
"parent_of_restore_folder", parentFolderID) "parent_of_restore_folder", parentFolderID)
@ -710,30 +711,15 @@ func createRestoreFolders(
continue continue
} }
folderItem, err := fr.GetFolderByName(ictx, driveID, parentFolderID, folder) // we assume this folder creation always uses the Replace
if err != nil && !errors.Is(err, api.ErrFolderNotFound) { // conflict policy, which means it will act as a GET if the
return "", clues.Wrap(err, "getting folder by display name") // folder already exists.
} folderItem, err := createFolder(
// folder found, moving to next child
if err == nil {
parentFolderID = ptr.Val(folderItem.GetId())
caches.Folders.set(location, folderItem)
continue
}
// create the folder if not found
// the Replace collision policy is used since collisions on that
// policy will no-op and return the existing folder. This has two
// benefits: first, we get to treat the post as idempotent; and
// second, we don't have to worry about race conditions.
folderItem, err = fr.PostItemInContainer(
ictx, ictx,
fr,
driveID, driveID,
parentFolderID, parentFolderID,
newItem(folder, true), folderName)
control.Replace)
if err != nil { if err != nil {
return "", clues.Wrap(err, "creating folder") return "", clues.Wrap(err, "creating folder")
} }
@ -747,6 +733,50 @@ func createRestoreFolders(
return parentFolderID, nil return parentFolderID, nil
} }
func createFolder(
ctx context.Context,
piic PostItemInContainerer,
driveID, parentFolderID, folderName string,
) (models.DriveItemable, error) {
// create the folder if not found
// the Replace collision policy is used since collisions on that
// policy will no-op and return the existing folder. This has two
// benefits: first, we get to treat the post as idempotent; and
// second, we don't have to worry about race conditions.
item, err := piic.PostItemInContainer(
ctx,
driveID,
parentFolderID,
newItem(folderName, true),
control.Replace)
// ErrItemAlreadyExistsConflict can only occur for folders if the
// item being replaced is a file, not another folder.
if err != nil && !errors.Is(err, graph.ErrItemAlreadyExistsConflict) {
return nil, clues.Wrap(err, "creating folder")
}
if err == nil {
return item, err
}
// if we made it here, then we tried to replace a file with a folder and
// hit a conflict. An unlikely occurrence, and we can try again with a copy
// conflict behavior setting and probably succeed, though that will change
// the location name of the restore.
item, err = piic.PostItemInContainer(
ctx,
driveID,
parentFolderID,
newItem(folderName, true),
control.Copy)
if err != nil {
return nil, clues.Wrap(err, "creating folder")
}
return item, err
}
type itemRestorer interface { type itemRestorer interface {
DeleteItemer DeleteItemer
ItemInfoAugmenter ItemInfoAugmenter
@ -754,8 +784,8 @@ type itemRestorer interface {
PostItemInContainerer PostItemInContainerer
} }
// restoreData will create a new item in the specified `parentFolderID` and upload the data.Stream // restoreFile will create a new item in the specified `parentFolderID` and upload the data.Stream
func restoreData( func restoreFile(
ctx context.Context, ctx context.Context,
restoreCfg control.RestoreConfig, restoreCfg control.RestoreConfig,
ir itemRestorer, ir itemRestorer,
@ -763,7 +793,7 @@ func restoreData(
name string, name string,
itemData data.Stream, itemData data.Stream,
driveID, parentFolderID string, driveID, parentFolderID string,
collisionKeyToItemID map[string]string, collisionKeyToItemID map[string]api.DriveCollisionItem,
copyBuffer []byte, copyBuffer []byte,
) (string, details.ItemInfo, error) { ) (string, details.ItemInfo, error) {
ctx, end := diagnostics.Span(ctx, "gc:oneDrive:restoreItem", diagnostics.Label("item_uuid", itemData.UUID())) ctx, end := diagnostics.Span(ctx, "gc:oneDrive:restoreItem", diagnostics.Label("item_uuid", itemData.UUID()))
@ -780,11 +810,11 @@ func restoreData(
var ( var (
item = newItem(name, false) item = newItem(name, false)
collisionKey = api.DriveItemCollisionKey(item) collisionKey = api.DriveItemCollisionKey(item)
collisionID string collision api.DriveCollisionItem
replace bool replace bool
) )
if id, ok := collisionKeyToItemID[collisionKey]; ok { if dci, ok := collisionKeyToItemID[collisionKey]; ok {
log := logger.Ctx(ctx).With("collision_key", clues.Hide(collisionKey)) log := logger.Ctx(ctx).With("collision_key", clues.Hide(collisionKey))
log.Debug("item collision") log.Debug("item collision")
@ -793,8 +823,8 @@ func restoreData(
return "", details.ItemInfo{}, graph.ErrItemAlreadyExistsConflict return "", details.ItemInfo{}, graph.ErrItemAlreadyExistsConflict
} }
collisionID = id collision = dci
replace = restoreCfg.OnCollision == control.Replace replace = restoreCfg.OnCollision == control.Replace && !dci.IsFolder
} }
// drive items do not support PUT requests on the drive item data, so // drive items do not support PUT requests on the drive item data, so
@ -805,7 +835,7 @@ func restoreData(
// risk failures in the middle, or we post w/ copy, then delete, then patch // risk failures in the middle, or we post w/ copy, then delete, then patch
// the name, which could triple our graph calls in the worst case. // the name, which could triple our graph calls in the worst case.
if replace { if replace {
if err := ir.DeleteItem(ctx, driveID, collisionID); err != nil { if err := ir.DeleteItem(ctx, driveID, collision.ItemID); err != nil {
return "", details.ItemInfo{}, clues.New("deleting colliding item") return "", details.ItemInfo{}, clues.New("deleting colliding item")
} }
} }
@ -820,6 +850,12 @@ func restoreData(
driveID, driveID,
parentFolderID, parentFolderID,
item, item,
// notes on forced copy:
// 1. happy path: any non-colliding item will restore as if no collision had occurred
// 2. if a file-container collision is present, we assume the item being restored
// will get generated according to server-side copy rules.
// 3. if restoreCfg specifies replace and a file-container collision is present, we
// make no changes to the original file, and do not delete it.
control.Copy) control.Copy)
if err != nil { if err != nil {
return "", details.ItemInfo{}, err return "", details.ItemInfo{}, err

View File

@ -1,6 +1,7 @@
package onedrive package onedrive
import ( import (
"context"
"testing" "testing"
"github.com/alcionai/clues" "github.com/alcionai/clues"
@ -18,6 +19,7 @@ import (
"github.com/alcionai/corso/src/internal/version" "github.com/alcionai/corso/src/internal/version"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
"github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/path"
"github.com/alcionai/corso/src/pkg/services/m365/api"
) )
type RestoreUnitSuite struct { type RestoreUnitSuite struct {
@ -328,14 +330,14 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
table := []struct { table := []struct {
name string name string
collisionKeys map[string]string collisionKeys map[string]api.DriveCollisionItem
onCollision control.CollisionPolicy onCollision control.CollisionPolicy
expectSkipped assert.BoolAssertionFunc expectSkipped assert.BoolAssertionFunc
expectMock func(*testing.T, *mock.RestoreHandler) expectMock func(*testing.T, *mock.RestoreHandler)
}{ }{
{ {
name: "no collision, copy", name: "no collision, copy",
collisionKeys: map[string]string{}, collisionKeys: map[string]api.DriveCollisionItem{},
onCollision: control.Copy, onCollision: control.Copy,
expectSkipped: assert.False, expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) { expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
@ -345,7 +347,7 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
}, },
{ {
name: "no collision, replace", name: "no collision, replace",
collisionKeys: map[string]string{}, collisionKeys: map[string]api.DriveCollisionItem{},
onCollision: control.Replace, onCollision: control.Replace,
expectSkipped: assert.False, expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) { expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
@ -355,7 +357,7 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
}, },
{ {
name: "no collision, skip", name: "no collision, skip",
collisionKeys: map[string]string{}, collisionKeys: map[string]api.DriveCollisionItem{},
onCollision: control.Skip, onCollision: control.Skip,
expectSkipped: assert.False, expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) { expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
@ -364,8 +366,10 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
}, },
}, },
{ {
name: "collision, copy", name: "collision, copy",
collisionKeys: map[string]string{mock.DriveItemFileName: mndiID}, collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {ItemID: mndiID},
},
onCollision: control.Copy, onCollision: control.Copy,
expectSkipped: assert.False, expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) { expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
@ -374,8 +378,10 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
}, },
}, },
{ {
name: "collision, replace", name: "collision, replace",
collisionKeys: map[string]string{mock.DriveItemFileName: mndiID}, collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {ItemID: mndiID},
},
onCollision: control.Replace, onCollision: control.Replace,
expectSkipped: assert.False, expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) { expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
@ -385,8 +391,55 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
}, },
}, },
{ {
name: "collision, skip", name: "collision, skip",
collisionKeys: map[string]string{mock.DriveItemFileName: mndiID}, collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {ItemID: mndiID},
},
onCollision: control.Skip,
expectSkipped: assert.True,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
assert.False(t, rh.CalledPostItem, "new item posted")
assert.False(t, rh.CalledDeleteItem, "new item deleted")
},
},
{
name: "file-folder collision, copy",
collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {
ItemID: mndiID,
IsFolder: true,
},
},
onCollision: control.Copy,
expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
assert.True(t, rh.CalledPostItem, "new item posted")
assert.False(t, rh.CalledDeleteItem, "new item deleted")
},
},
{
name: "file-folder collision, replace",
collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {
ItemID: mndiID,
IsFolder: true,
},
},
onCollision: control.Replace,
expectSkipped: assert.False,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
assert.True(t, rh.CalledPostItem, "new item posted")
assert.False(t, rh.CalledDeleteItem, "new item deleted")
},
},
{
name: "file-folder collision, skip",
collisionKeys: map[string]api.DriveCollisionItem{
mock.DriveItemFileName: {
ItemID: mndiID,
IsFolder: true,
},
},
onCollision: control.Skip, onCollision: control.Skip,
expectSkipped: assert.True, expectSkipped: assert.True,
expectMock: func(t *testing.T, rh *mock.RestoreHandler) { expectMock: func(t *testing.T, rh *mock.RestoreHandler) {
@ -446,3 +499,79 @@ func (suite *RestoreUnitSuite) TestRestoreItem_collisionHandling() {
}) })
} }
} }
type mockPIIC struct {
i int
errs []error
items []models.DriveItemable
}
func (m *mockPIIC) PostItemInContainer(
context.Context,
string, string,
models.DriveItemable,
control.CollisionPolicy,
) (models.DriveItemable, error) {
j := m.i
m.i++
return m.items[j], m.errs[j]
}
func (suite *RestoreUnitSuite) TestCreateFolder() {
table := []struct {
name string
mock *mockPIIC
expectErr assert.ErrorAssertionFunc
expectItem assert.ValueAssertionFunc
}{
{
name: "good",
mock: &mockPIIC{
errs: []error{nil},
items: []models.DriveItemable{models.NewDriveItem()},
},
expectErr: assert.NoError,
expectItem: assert.NotNil,
},
{
name: "good with copy",
mock: &mockPIIC{
errs: []error{graph.ErrItemAlreadyExistsConflict, nil},
items: []models.DriveItemable{nil, models.NewDriveItem()},
},
expectErr: assert.NoError,
expectItem: assert.NotNil,
},
{
name: "bad",
mock: &mockPIIC{
errs: []error{assert.AnError},
items: []models.DriveItemable{nil},
},
expectErr: assert.Error,
expectItem: assert.Nil,
},
{
name: "bad with copy",
mock: &mockPIIC{
errs: []error{graph.ErrItemAlreadyExistsConflict, assert.AnError},
items: []models.DriveItemable{nil, nil},
},
expectErr: assert.Error,
expectItem: assert.Nil,
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
result, err := createFolder(ctx, test.mock, "d", "pf", "fn")
test.expectErr(t, err, clues.ToCore(err))
test.expectItem(t, result)
})
}
}

View File

@ -190,7 +190,7 @@ func (h libraryRestoreHandler) DeleteItemPermission(
func (h libraryRestoreHandler) GetItemsInContainerByCollisionKey( func (h libraryRestoreHandler) GetItemsInContainerByCollisionKey(
ctx context.Context, ctx context.Context,
driveID, containerID string, driveID, containerID string,
) (map[string]string, error) { ) (map[string]api.DriveCollisionItem, error) {
m, err := h.ac.GetItemsInContainerByCollisionKey(ctx, driveID, containerID) m, err := h.ac.GetItemsInContainerByCollisionKey(ctx, driveID, containerID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -67,10 +67,15 @@ func (p *driveItemPageCtrl) setNext(nextLink string) {
p.builder = drives.NewItemItemsItemChildrenRequestBuilder(nextLink, p.gs.Adapter()) p.builder = drives.NewItemItemsItemChildrenRequestBuilder(nextLink, p.gs.Adapter())
} }
type DriveCollisionItem struct {
ItemID string
IsFolder bool
}
func (c Drives) GetItemsInContainerByCollisionKey( func (c Drives) GetItemsInContainerByCollisionKey(
ctx context.Context, ctx context.Context,
driveID, containerID string, driveID, containerID string,
) (map[string]string, error) { ) (map[string]DriveCollisionItem, error) {
ctx = clues.Add(ctx, "container_id", containerID) ctx = clues.Add(ctx, "container_id", containerID)
pager := c.NewDriveItemPager(driveID, containerID, idAnd("name")...) pager := c.NewDriveItemPager(driveID, containerID, idAnd("name")...)
@ -79,10 +84,13 @@ func (c Drives) GetItemsInContainerByCollisionKey(
return nil, graph.Wrap(ctx, err, "enumerating drive items") return nil, graph.Wrap(ctx, err, "enumerating drive items")
} }
m := map[string]string{} m := map[string]DriveCollisionItem{}
for _, item := range items { for _, item := range items {
m[DriveItemCollisionKey(item)] = ptr.Val(item.GetId()) m[DriveItemCollisionKey(item)] = DriveCollisionItem{
ItemID: ptr.Val(item.GetId()),
IsFolder: item.GetFolder() != nil,
}
} }
return m, nil return m, nil

View File

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester"
"github.com/alcionai/corso/src/pkg/services/m365/api"
) )
type DrivePagerIntgSuite struct { type DrivePagerIntgSuite struct {
@ -63,7 +64,7 @@ func (suite *DrivePagerIntgSuite) TestDrives_GetItemsInContainerByCollisionKey()
require.NoError(t, err, clues.ToCore(err)) require.NoError(t, err, clues.ToCore(err))
ims := items.GetValue() ims := items.GetValue()
expect := make([]string, 0, len(ims)) expect := make([]api.DriveCollisionItem, 0, len(ims))
assert.NotEmptyf( assert.NotEmptyf(
t, t,
@ -81,8 +82,9 @@ func (suite *DrivePagerIntgSuite) TestDrives_GetItemsInContainerByCollisionKey()
} }
for _, e := range expect { for _, e := range expect {
_, ok := results[e] r, ok := results[e.ItemID]
assert.Truef(t, ok, "expected results to contain collision key: %s", e) assert.Truef(t, ok, "expected results to contain collision key: %s", e)
assert.Equal(t, e, r)
} }
}) })
} }

View File

@ -1,6 +1,7 @@
package api_test package api_test
import ( import (
"fmt"
"testing" "testing"
"github.com/alcionai/clues" "github.com/alcionai/clues"
@ -8,6 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"golang.org/x/exp/slices"
"github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/m365/graph" "github.com/alcionai/corso/src/internal/m365/graph"
@ -16,24 +18,24 @@ import (
"github.com/alcionai/corso/src/pkg/control/testdata" "github.com/alcionai/corso/src/pkg/control/testdata"
) )
type DriveAPISuite struct { type DriveAPIIntgSuite struct {
tester.Suite tester.Suite
its intgTesterSetup its intgTesterSetup
} }
func (suite *DriveAPISuite) SetupSuite() { func (suite *DriveAPIIntgSuite) SetupSuite() {
suite.its = newIntegrationTesterSetup(suite.T()) suite.its = newIntegrationTesterSetup(suite.T())
} }
func TestDriveAPIs(t *testing.T) { func TestDriveAPIs(t *testing.T) {
suite.Run(t, &DriveAPISuite{ suite.Run(t, &DriveAPIIntgSuite{
Suite: tester.NewIntegrationSuite( Suite: tester.NewIntegrationSuite(
t, t,
[][]string{tester.M365AcctCredEnvs}), [][]string{tester.M365AcctCredEnvs}),
}) })
} }
func (suite *DriveAPISuite) TestDrives_CreatePagerAndGetPage() { func (suite *DriveAPIIntgSuite) TestDrives_CreatePagerAndGetPage() {
t := suite.T() t := suite.T()
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
@ -61,7 +63,7 @@ func newItem(name string, folder bool) *models.DriveItem {
return itemToCreate return itemToCreate
} }
func (suite *DriveAPISuite) TestDrives_PostItemInContainer() { func (suite *DriveAPIIntgSuite) TestDrives_PostItemInContainer() {
t := suite.T() t := suite.T()
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
@ -218,3 +220,88 @@ func (suite *DriveAPISuite) TestDrives_PostItemInContainer() {
}) })
} }
} }
// purpose: ensure that creating a new folder with "replace" conflict behavior
// makes no changes to the items which exist in that folder.
func (suite *DriveAPIIntgSuite) TestDrives_PostItemInContainer_replaceFolderRegression() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
var (
rc = testdata.DefaultRestoreConfig("drive_folder_replace_regression")
acd = suite.its.ac.Drives()
files = make([]models.DriveItemable, 0, 5)
)
// generate a folder for the test data
folder, err := acd.PostItemInContainer(
ctx,
suite.its.userDriveID,
suite.its.userDriveRootFolderID,
newItem(rc.Location, true),
// skip instead of replace here to get
// an ErrItemAlreadyExistsConflict, just in case.
control.Skip)
require.NoError(t, err, clues.ToCore(err))
// generate items within that folder
for i := 0; i < 5; i++ {
file := newItem(fmt.Sprintf("collision_%d.txt", i), false)
f, err := acd.PostItemInContainer(
ctx,
suite.its.userDriveID,
ptr.Val(folder.GetId()),
file,
control.Copy)
require.NoError(t, err, clues.ToCore(err))
files = append(files, f)
}
resultFolder, err := acd.PostItemInContainer(
ctx,
suite.its.userDriveID,
ptr.Val(folder.GetParentReference().GetId()),
newItem(rc.Location, true),
control.Replace)
require.NoError(t, err, clues.ToCore(err))
require.NotEmpty(t, ptr.Val(resultFolder.GetId()))
require.Equal(t, ptr.Val(folder.GetId()), ptr.Val(resultFolder.GetId()))
resultFileColl, err := acd.Stable.
Client().
Drives().
ByDriveId(suite.its.userDriveID).
Items().
ByDriveItemId(ptr.Val(resultFolder.GetId())).
Children().
Get(ctx, nil)
err = graph.Stack(ctx, err).OrNil()
require.NoError(t, err, clues.ToCore(err))
resultFiles := resultFileColl.GetValue()
// asserting that no file changes have occurred as a result of the
// "replacement" of the owning folder.
for _, rf := range resultFiles {
var (
rID = ptr.Val(rf.GetId())
rName = ptr.Val(rf.GetName())
rMod = ptr.Val(rf.GetLastModifiedDateTime())
)
check := func(expect models.DriveItemable) bool {
var (
eID = ptr.Val(expect.GetId())
eName = ptr.Val(expect.GetName())
eMod = ptr.Val(expect.GetLastModifiedDateTime())
)
return eID == rID && eName == rName && eMod.Equal(rMod)
}
assert.True(t, slices.ContainsFunc(files, check))
}
}