properly handle collision replace behavior (#3636)
completes the item collision handling behavior in exchange by turning replace handling into a post-delete process. --- #### Does this PR need a docs update or release note? - [x] ⛔ No #### Type of change - [x] 🌻 Feature #### Issue(s) * #3562 #### Test Plan - [x] ⚡ Unit test - [x] 💚 E2E
This commit is contained in:
parent
81a289d8bc
commit
2132b3c789
@ -79,9 +79,14 @@ func (h contactRestoreHandler) restore(
|
|||||||
errs)
|
errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contactRestorer interface {
|
||||||
|
postItemer[models.Contactable]
|
||||||
|
deleteItemer
|
||||||
|
}
|
||||||
|
|
||||||
func restoreContact(
|
func restoreContact(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
pi postItemer[models.Contactable],
|
cr contactRestorer,
|
||||||
body []byte,
|
body []byte,
|
||||||
userID, destinationID string,
|
userID, destinationID string,
|
||||||
collisionKeyToItemID map[string]string,
|
collisionKeyToItemID map[string]string,
|
||||||
@ -94,22 +99,40 @@ func restoreContact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = clues.Add(ctx, "item_id", ptr.Val(contact.GetId()))
|
ctx = clues.Add(ctx, "item_id", ptr.Val(contact.GetId()))
|
||||||
collisionKey := api.ContactCollisionKey(contact)
|
|
||||||
|
|
||||||
if _, ok := collisionKeyToItemID[collisionKey]; ok {
|
var (
|
||||||
|
collisionKey = api.ContactCollisionKey(contact)
|
||||||
|
collisionID string
|
||||||
|
shouldDeleteOriginal bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if id, 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")
|
||||||
|
|
||||||
// TODO(rkeepers): Replace probably shouldn't no-op. Just a starting point.
|
if collisionPolicy == control.Skip {
|
||||||
if collisionPolicy == control.Skip || collisionPolicy == control.Replace {
|
|
||||||
log.Debug("skipping item with collision")
|
log.Debug("skipping item with collision")
|
||||||
return nil, graph.ErrItemAlreadyExistsConflict
|
return nil, graph.ErrItemAlreadyExistsConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collisionID = id
|
||||||
|
shouldDeleteOriginal = collisionPolicy == control.Replace
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := pi.PostItem(ctx, userID, destinationID, contact)
|
item, err := cr.PostItem(ctx, userID, destinationID, contact)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, graph.Wrap(ctx, err, "restoring mail message")
|
return nil, graph.Wrap(ctx, err, "restoring contact")
|
||||||
|
}
|
||||||
|
|
||||||
|
// contacts have no PUT request, and PATCH could retain data that's not
|
||||||
|
// associated with the backup item state. Instead of updating, we
|
||||||
|
// post first, then delete. In case of failure between the two calls,
|
||||||
|
// at least we'll have accidentally over-produced data instead of deleting
|
||||||
|
// the user's data.
|
||||||
|
if shouldDeleteOriginal {
|
||||||
|
if err := cr.DeleteItem(ctx, userID, collisionID); err != nil {
|
||||||
|
return nil, graph.Wrap(ctx, err, "deleting colliding contact")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info := api.ContactInfo(item)
|
info := api.ContactInfo(item)
|
||||||
|
|||||||
@ -20,20 +20,32 @@ import (
|
|||||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ postItemer[models.Contactable] = &mockContactRestorer{}
|
var _ contactRestorer = &contactRestoreMock{}
|
||||||
|
|
||||||
type mockContactRestorer struct {
|
type contactRestoreMock struct {
|
||||||
postItemErr error
|
postItemErr error
|
||||||
|
calledPost bool
|
||||||
|
deleteItemErr error
|
||||||
|
calledDelete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockContactRestorer) PostItem(
|
func (m *contactRestoreMock) PostItem(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
userID, containerID string,
|
_, _ string,
|
||||||
body models.Contactable,
|
_ models.Contactable,
|
||||||
) (models.Contactable, error) {
|
) (models.Contactable, error) {
|
||||||
|
m.calledPost = true
|
||||||
return models.NewContact(), m.postItemErr
|
return models.NewContact(), m.postItemErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *contactRestoreMock) DeleteItem(
|
||||||
|
_ context.Context,
|
||||||
|
_, _ string,
|
||||||
|
) error {
|
||||||
|
m.calledDelete = true
|
||||||
|
return m.deleteItemErr
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tests
|
// tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -78,63 +90,88 @@ func (suite *ContactsRestoreIntgSuite) TestRestoreContact() {
|
|||||||
|
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
apiMock postItemer[models.Contactable]
|
apiMock *contactRestoreMock
|
||||||
collisionMap map[string]string
|
collisionMap map[string]string
|
||||||
onCollision control.CollisionPolicy
|
onCollision control.CollisionPolicy
|
||||||
expectErr func(*testing.T, error)
|
expectErr func(*testing.T, error)
|
||||||
|
expectMock func(*testing.T, *contactRestoreMock)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no collision: skip",
|
name: "no collision: skip",
|
||||||
apiMock: mockContactRestorer{},
|
apiMock: &contactRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Copy,
|
onCollision: control.Copy,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *contactRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no collision: copy",
|
name: "no collision: copy",
|
||||||
apiMock: mockContactRestorer{},
|
apiMock: &contactRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Skip,
|
onCollision: control.Skip,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *contactRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no collision: replace",
|
name: "no collision: replace",
|
||||||
apiMock: mockContactRestorer{},
|
apiMock: &contactRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Replace,
|
onCollision: control.Replace,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *contactRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: skip",
|
name: "collision: skip",
|
||||||
apiMock: mockContactRestorer{},
|
apiMock: &contactRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Skip,
|
onCollision: control.Skip,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *contactRestoreMock) {
|
||||||
|
assert.False(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: copy",
|
name: "collision: copy",
|
||||||
apiMock: mockContactRestorer{},
|
apiMock: &contactRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Copy,
|
onCollision: control.Copy,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *contactRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: replace",
|
name: "collision: replace",
|
||||||
apiMock: mockContactRestorer{},
|
apiMock: &contactRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Replace,
|
onCollision: control.Replace,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
|
},
|
||||||
|
expectMock: func(t *testing.T, m *contactRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.True(t, m.calledDelete, "old item deleted")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -156,6 +193,7 @@ func (suite *ContactsRestoreIntgSuite) TestRestoreContact() {
|
|||||||
fault.New(true))
|
fault.New(true))
|
||||||
|
|
||||||
test.expectErr(t, err)
|
test.expectErr(t, err)
|
||||||
|
test.expectMock(t, test.apiMock)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,17 +105,24 @@ func restoreEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = clues.Add(ctx, "item_id", ptr.Val(event.GetId()))
|
ctx = clues.Add(ctx, "item_id", ptr.Val(event.GetId()))
|
||||||
collisionKey := api.EventCollisionKey(event)
|
|
||||||
|
|
||||||
if _, ok := collisionKeyToItemID[collisionKey]; ok {
|
var (
|
||||||
|
collisionKey = api.EventCollisionKey(event)
|
||||||
|
collisionID string
|
||||||
|
shouldDeleteOriginal bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if id, 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")
|
||||||
|
|
||||||
// TODO(rkeepers): Replace probably shouldn't no-op. Just a starting point.
|
if collisionPolicy == control.Skip {
|
||||||
if collisionPolicy == control.Skip || collisionPolicy == control.Replace {
|
|
||||||
log.Debug("skipping item with collision")
|
log.Debug("skipping item with collision")
|
||||||
return nil, graph.ErrItemAlreadyExistsConflict
|
return nil, graph.ErrItemAlreadyExistsConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collisionID = id
|
||||||
|
shouldDeleteOriginal = collisionPolicy == control.Replace
|
||||||
}
|
}
|
||||||
|
|
||||||
event = toEventSimplified(event)
|
event = toEventSimplified(event)
|
||||||
@ -131,7 +138,18 @@ func restoreEvent(
|
|||||||
|
|
||||||
item, err := er.PostItem(ctx, userID, destinationID, event)
|
item, err := er.PostItem(ctx, userID, destinationID, event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, graph.Wrap(ctx, err, "restoring calendar item")
|
return nil, graph.Wrap(ctx, err, "restoring event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// events have no PUT request, and PATCH could retain data that's not
|
||||||
|
// associated with the backup item state. Instead of updating, we
|
||||||
|
// post first, then delete. In case of failure between the two calls,
|
||||||
|
// at least we'll have accidentally over-produced data instead of deleting
|
||||||
|
// the user's data.
|
||||||
|
if shouldDeleteOriginal {
|
||||||
|
if err := er.DeleteItem(ctx, userID, collisionID); err != nil {
|
||||||
|
return nil, graph.Wrap(ctx, err, "deleting colliding event")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = uploadAttachments(
|
err = uploadAttachments(
|
||||||
|
|||||||
@ -21,22 +21,34 @@ import (
|
|||||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ eventRestorer = &mockEventRestorer{}
|
var _ eventRestorer = &eventRestoreMock{}
|
||||||
|
|
||||||
type mockEventRestorer struct {
|
type eventRestoreMock struct {
|
||||||
postItemErr error
|
postItemErr error
|
||||||
|
calledPost bool
|
||||||
|
deleteItemErr error
|
||||||
|
calledDelete bool
|
||||||
postAttachmentErr error
|
postAttachmentErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) PostItem(
|
func (m *eventRestoreMock) PostItem(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
userID, containerID string,
|
_, _ string,
|
||||||
body models.Eventable,
|
_ models.Eventable,
|
||||||
) (models.Eventable, error) {
|
) (models.Eventable, error) {
|
||||||
|
m.calledPost = true
|
||||||
return models.NewEvent(), m.postItemErr
|
return models.NewEvent(), m.postItemErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) PostSmallAttachment(
|
func (m *eventRestoreMock) DeleteItem(
|
||||||
|
_ context.Context,
|
||||||
|
_, _ string,
|
||||||
|
) error {
|
||||||
|
m.calledDelete = true
|
||||||
|
return m.deleteItemErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *eventRestoreMock) PostSmallAttachment(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_, _, _ string,
|
_, _, _ string,
|
||||||
_ models.Attachmentable,
|
_ models.Attachmentable,
|
||||||
@ -44,7 +56,7 @@ func (m mockEventRestorer) PostSmallAttachment(
|
|||||||
return m.postAttachmentErr
|
return m.postAttachmentErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) PostLargeAttachment(
|
func (m *eventRestoreMock) PostLargeAttachment(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_, _, _, _ string,
|
_, _, _, _ string,
|
||||||
_ []byte,
|
_ []byte,
|
||||||
@ -52,21 +64,14 @@ func (m mockEventRestorer) PostLargeAttachment(
|
|||||||
return uuid.NewString(), m.postAttachmentErr
|
return uuid.NewString(), m.postAttachmentErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) DeleteAttachment(
|
func (m *eventRestoreMock) DeleteAttachment(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, calendarID, eventID, attachmentID string,
|
userID, calendarID, eventID, attachmentID string,
|
||||||
) error {
|
) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) DeleteItem(
|
func (m *eventRestoreMock) GetAttachments(
|
||||||
ctx context.Context,
|
|
||||||
userID, itemID string,
|
|
||||||
) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m mockEventRestorer) GetAttachments(
|
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_ bool,
|
_ bool,
|
||||||
_, _ string,
|
_, _ string,
|
||||||
@ -74,14 +79,14 @@ func (m mockEventRestorer) GetAttachments(
|
|||||||
return []models.Attachmentable{}, nil
|
return []models.Attachmentable{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) GetItemInstances(
|
func (m *eventRestoreMock) GetItemInstances(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_, _, _, _ string,
|
_, _, _, _ string,
|
||||||
) ([]models.Eventable, error) {
|
) ([]models.Eventable, error) {
|
||||||
return []models.Eventable{}, nil
|
return []models.Eventable{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockEventRestorer) PatchItem(
|
func (m *eventRestoreMock) PatchItem(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_, _ string,
|
_, _ string,
|
||||||
_ models.Eventable,
|
_ models.Eventable,
|
||||||
@ -133,63 +138,88 @@ func (suite *EventsRestoreIntgSuite) TestRestoreEvent() {
|
|||||||
|
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
apiMock eventRestorer
|
apiMock *eventRestoreMock
|
||||||
collisionMap map[string]string
|
collisionMap map[string]string
|
||||||
onCollision control.CollisionPolicy
|
onCollision control.CollisionPolicy
|
||||||
expectErr func(*testing.T, error)
|
expectErr func(*testing.T, error)
|
||||||
|
expectMock func(*testing.T, *eventRestoreMock)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no collision: skip",
|
name: "no collision: skip",
|
||||||
apiMock: mockEventRestorer{},
|
apiMock: &eventRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Copy,
|
onCollision: control.Copy,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *eventRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no collision: copy",
|
name: "no collision: copy",
|
||||||
apiMock: mockEventRestorer{},
|
apiMock: &eventRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Skip,
|
onCollision: control.Skip,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *eventRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no collision: replace",
|
name: "no collision: replace",
|
||||||
apiMock: mockEventRestorer{},
|
apiMock: &eventRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Replace,
|
onCollision: control.Replace,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *eventRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: skip",
|
name: "collision: skip",
|
||||||
apiMock: mockEventRestorer{},
|
apiMock: &eventRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Skip,
|
onCollision: control.Skip,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *eventRestoreMock) {
|
||||||
|
assert.False(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: copy",
|
name: "collision: copy",
|
||||||
apiMock: mockEventRestorer{},
|
apiMock: &eventRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Copy,
|
onCollision: control.Copy,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *eventRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: replace",
|
name: "collision: replace",
|
||||||
apiMock: mockEventRestorer{},
|
apiMock: &eventRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Replace,
|
onCollision: control.Replace,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
|
},
|
||||||
|
expectMock: func(t *testing.T, m *eventRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.True(t, m.calledDelete, "old item deleted")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -211,6 +241,7 @@ func (suite *EventsRestoreIntgSuite) TestRestoreEvent() {
|
|||||||
fault.New(true))
|
fault.New(true))
|
||||||
|
|
||||||
test.expectErr(t, err)
|
test.expectErr(t, err)
|
||||||
|
test.expectMock(t, test.apiMock)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,6 +138,10 @@ type getItemsByCollisionKeyser interface {
|
|||||||
) (map[string]string, error)
|
) (map[string]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// other interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type postItemer[T any] interface {
|
type postItemer[T any] interface {
|
||||||
PostItem(
|
PostItem(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@ -145,3 +149,10 @@ type postItemer[T any] interface {
|
|||||||
body T,
|
body T,
|
||||||
) (T, error)
|
) (T, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deleteItemer interface {
|
||||||
|
DeleteItem(
|
||||||
|
ctx context.Context,
|
||||||
|
userID, itemID string,
|
||||||
|
) error
|
||||||
|
}
|
||||||
|
|||||||
@ -86,6 +86,7 @@ func (h mailRestoreHandler) restore(
|
|||||||
|
|
||||||
type mailRestorer interface {
|
type mailRestorer interface {
|
||||||
postItemer[models.Messageable]
|
postItemer[models.Messageable]
|
||||||
|
deleteItemer
|
||||||
attachmentPoster
|
attachmentPoster
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,17 +105,24 @@ func restoreMail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = clues.Add(ctx, "item_id", ptr.Val(msg.GetId()))
|
ctx = clues.Add(ctx, "item_id", ptr.Val(msg.GetId()))
|
||||||
collisionKey := api.MailCollisionKey(msg)
|
|
||||||
|
|
||||||
if _, ok := collisionKeyToItemID[collisionKey]; ok {
|
var (
|
||||||
|
collisionKey = api.MailCollisionKey(msg)
|
||||||
|
collisionID string
|
||||||
|
shouldDeleteOriginal bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if id, 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")
|
||||||
|
|
||||||
// TODO(rkeepers): Replace probably shouldn't no-op. Just a starting point.
|
if collisionPolicy == control.Skip {
|
||||||
if collisionPolicy == control.Skip || collisionPolicy == control.Replace {
|
|
||||||
log.Debug("skipping item with collision")
|
log.Debug("skipping item with collision")
|
||||||
return nil, graph.ErrItemAlreadyExistsConflict
|
return nil, graph.ErrItemAlreadyExistsConflict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collisionID = id
|
||||||
|
shouldDeleteOriginal = collisionPolicy == control.Replace
|
||||||
}
|
}
|
||||||
|
|
||||||
msg = setMessageSVEPs(toMessage(msg))
|
msg = setMessageSVEPs(toMessage(msg))
|
||||||
@ -128,6 +136,17 @@ func restoreMail(
|
|||||||
return nil, graph.Wrap(ctx, err, "restoring mail message")
|
return nil, graph.Wrap(ctx, err, "restoring mail message")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mails have no PUT request, and PATCH could retain data that's not
|
||||||
|
// associated with the backup item state. Instead of updating, we
|
||||||
|
// post first, then delete. In case of failure between the two calls,
|
||||||
|
// at least we'll have accidentally over-produced data instead of deleting
|
||||||
|
// the user's data.
|
||||||
|
if shouldDeleteOriginal {
|
||||||
|
if err := mr.DeleteItem(ctx, userID, collisionID); err != nil {
|
||||||
|
return nil, graph.Wrap(ctx, err, "deleting colliding mail message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = uploadAttachments(
|
err = uploadAttachments(
|
||||||
ctx,
|
ctx,
|
||||||
mr,
|
mr,
|
||||||
|
|||||||
@ -21,22 +21,34 @@ import (
|
|||||||
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
"github.com/alcionai/corso/src/pkg/services/m365/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ mailRestorer = &mockMailRestorer{}
|
var _ mailRestorer = &mailRestoreMock{}
|
||||||
|
|
||||||
type mockMailRestorer struct {
|
type mailRestoreMock struct {
|
||||||
postItemErr error
|
postItemErr error
|
||||||
|
calledPost bool
|
||||||
|
deleteItemErr error
|
||||||
|
calledDelete bool
|
||||||
postAttachmentErr error
|
postAttachmentErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockMailRestorer) PostItem(
|
func (m *mailRestoreMock) PostItem(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
userID, containerID string,
|
_, _ string,
|
||||||
body models.Messageable,
|
_ models.Messageable,
|
||||||
) (models.Messageable, error) {
|
) (models.Messageable, error) {
|
||||||
|
m.calledPost = true
|
||||||
return models.NewMessage(), m.postItemErr
|
return models.NewMessage(), m.postItemErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockMailRestorer) PostSmallAttachment(
|
func (m *mailRestoreMock) DeleteItem(
|
||||||
|
_ context.Context,
|
||||||
|
_, _ string,
|
||||||
|
) error {
|
||||||
|
m.calledDelete = true
|
||||||
|
return m.deleteItemErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mailRestoreMock) PostSmallAttachment(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_, _, _ string,
|
_, _, _ string,
|
||||||
_ models.Attachmentable,
|
_ models.Attachmentable,
|
||||||
@ -44,7 +56,7 @@ func (m mockMailRestorer) PostSmallAttachment(
|
|||||||
return m.postAttachmentErr
|
return m.postAttachmentErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mockMailRestorer) PostLargeAttachment(
|
func (m *mailRestoreMock) PostLargeAttachment(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_, _, _, _ string,
|
_, _, _, _ string,
|
||||||
_ []byte,
|
_ []byte,
|
||||||
@ -95,63 +107,88 @@ func (suite *MailRestoreIntgSuite) TestRestoreMail() {
|
|||||||
|
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
apiMock mailRestorer
|
apiMock *mailRestoreMock
|
||||||
collisionMap map[string]string
|
collisionMap map[string]string
|
||||||
onCollision control.CollisionPolicy
|
onCollision control.CollisionPolicy
|
||||||
expectErr func(*testing.T, error)
|
expectErr func(*testing.T, error)
|
||||||
|
expectMock func(*testing.T, *mailRestoreMock)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no collision: skip",
|
name: "no collision: skip",
|
||||||
apiMock: mockMailRestorer{},
|
apiMock: &mailRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Copy,
|
onCollision: control.Copy,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *mailRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no collision: copy",
|
name: "no collision: copy",
|
||||||
apiMock: mockMailRestorer{},
|
apiMock: &mailRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Skip,
|
onCollision: control.Skip,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *mailRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no collision: replace",
|
name: "no collision: replace",
|
||||||
apiMock: mockMailRestorer{},
|
apiMock: &mailRestoreMock{},
|
||||||
collisionMap: map[string]string{},
|
collisionMap: map[string]string{},
|
||||||
onCollision: control.Replace,
|
onCollision: control.Replace,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *mailRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: skip",
|
name: "collision: skip",
|
||||||
apiMock: mockMailRestorer{},
|
apiMock: &mailRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Skip,
|
onCollision: control.Skip,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *mailRestoreMock) {
|
||||||
|
assert.False(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: copy",
|
name: "collision: copy",
|
||||||
apiMock: mockMailRestorer{},
|
apiMock: &mailRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Copy,
|
onCollision: control.Copy,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.NoError(t, err, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
},
|
},
|
||||||
|
expectMock: func(t *testing.T, m *mailRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.False(t, m.calledDelete, "old item deleted")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "collision: replace",
|
name: "collision: replace",
|
||||||
apiMock: mockMailRestorer{},
|
apiMock: &mailRestoreMock{},
|
||||||
collisionMap: map[string]string{collisionKey: "smarf"},
|
collisionMap: map[string]string{collisionKey: "smarf"},
|
||||||
onCollision: control.Replace,
|
onCollision: control.Replace,
|
||||||
expectErr: func(t *testing.T, err error) {
|
expectErr: func(t *testing.T, err error) {
|
||||||
assert.ErrorIs(t, err, graph.ErrItemAlreadyExistsConflict, clues.ToCore(err))
|
assert.NoError(t, err, clues.ToCore(err))
|
||||||
|
},
|
||||||
|
expectMock: func(t *testing.T, m *mailRestoreMock) {
|
||||||
|
assert.True(t, m.calledPost, "new item posted")
|
||||||
|
assert.True(t, m.calledDelete, "old item deleted")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -173,6 +210,7 @@ func (suite *MailRestoreIntgSuite) TestRestoreMail() {
|
|||||||
fault.New(true))
|
fault.New(true))
|
||||||
|
|
||||||
test.expectErr(t, err)
|
test.expectErr(t, err)
|
||||||
|
test.expectMock(t, test.apiMock)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user