Two fixes for the UndoLog: 1. Don't delete the original undo log entry when undo is performed. The entry stays in the log alongside the new inverse action, so the user can retry the undo if it was silently reverted by an IMAP sync. 2. Fix IMAP UID mismatch: after an IMAP move is applied on the server the email gets a new UID in the destination folder. The undo service now looks up the email by its RFC 2822 Message-ID when the original row is gone, so the reverse-move pending change carries the correct UID and actually succeeds on the server. Add findEmailByMessageId to EmailRepository interface and impl. Add a regression test that simulates the UID change scenario. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
372 lines
11 KiB
Dart
372 lines
11 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mockito/annotations.dart';
|
|
import 'package:mockito/mockito.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
|
import 'package:sharedinbox/di.dart';
|
|
|
|
import 'undo_service_test.mocks.dart';
|
|
|
|
@GenerateMocks([EmailRepository, UndoRepository])
|
|
void main() {
|
|
late ProviderContainer container;
|
|
late MockEmailRepository mockEmailRepo;
|
|
late MockUndoRepository mockUndoRepo;
|
|
|
|
setUp(() {
|
|
mockEmailRepo = MockEmailRepository();
|
|
mockUndoRepo = MockUndoRepository();
|
|
|
|
when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {});
|
|
when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {});
|
|
when(
|
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
|
).thenAnswer((_) async => []);
|
|
when(mockEmailRepo.getEmail(any)).thenAnswer((_) async => null);
|
|
when(
|
|
mockEmailRepo.findEmailByMessageId(any, any),
|
|
).thenAnswer((_) async => null);
|
|
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
emailRepositoryProvider.overrideWithValue(mockEmailRepo),
|
|
undoRepositoryProvider.overrideWithValue(mockUndoRepo),
|
|
],
|
|
);
|
|
});
|
|
|
|
tearDown(() {
|
|
container.dispose();
|
|
});
|
|
|
|
test('UndoService initial state is empty list', () {
|
|
expect(container.read(undoServiceProvider), isEmpty);
|
|
});
|
|
|
|
test('pushAction maintains history and updates state', () async {
|
|
final action1 = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
final action2 = UndoAction(
|
|
id: '2',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e2'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init(); // Wait for persistent load
|
|
|
|
await notifier.pushAction(action1);
|
|
expect(container.read(undoServiceProvider), [action1]);
|
|
|
|
await notifier.pushAction(action2);
|
|
expect(container.read(undoServiceProvider), [action1, action2]);
|
|
});
|
|
|
|
test('undo pops history and updates state', () async {
|
|
// action1 has no destinationMailboxPath → no inverse action pushed.
|
|
final action1 = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
// action2 has a destinationMailboxPath → inverse action IS pushed after undo.
|
|
final action2 = UndoAction(
|
|
id: '2',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e2'],
|
|
sourceMailboxPath: 'INBOX',
|
|
destinationMailboxPath: 'Trash',
|
|
);
|
|
|
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
|
when(
|
|
mockEmailRepo.cancelPendingChange(any, any),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(action1);
|
|
await notifier.pushAction(action2);
|
|
|
|
// Undo action2 (delete); the original entry is kept and a reverse action is added.
|
|
await notifier.undo();
|
|
final stateAfterUndo2 = container.read(undoServiceProvider);
|
|
expect(stateAfterUndo2.length, 3);
|
|
expect(stateAfterUndo2[0].id, '1');
|
|
expect(stateAfterUndo2[1].id, '2');
|
|
expect(stateAfterUndo2[2].id, '2-inv');
|
|
expect(stateAfterUndo2[2].sourceMailboxPath, 'Trash');
|
|
expect(stateAfterUndo2[2].destinationMailboxPath, 'INBOX');
|
|
verify(mockEmailRepo.moveEmail('e2', 'INBOX')).called(1);
|
|
|
|
// Undo action1 (no dest → no inverse); original stays, log is unchanged.
|
|
await notifier.undo(actionId: '1');
|
|
final stateAfterUndo1 = container.read(undoServiceProvider);
|
|
expect(stateAfterUndo1.length, 3);
|
|
expect(stateAfterUndo1[0].id, '1');
|
|
expect(stateAfterUndo1[1].id, '2');
|
|
expect(stateAfterUndo1[2].id, '2-inv');
|
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
|
});
|
|
|
|
test('undo pushes inverse action into log when destinationMailboxPath is set',
|
|
() async {
|
|
final action = UndoAction(
|
|
id: 'del1',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
destinationMailboxPath: 'Trash',
|
|
);
|
|
|
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
|
when(
|
|
mockEmailRepo.cancelPendingChange(any, any),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(action);
|
|
await notifier.undo(actionId: 'del1');
|
|
|
|
// Original entry stays; inverse is added.
|
|
final log = container.read(undoServiceProvider);
|
|
expect(log.length, 2);
|
|
expect(log[0].id, 'del1');
|
|
final inv = log[1];
|
|
expect(inv.id, 'del1-inv');
|
|
expect(inv.type, UndoType.move);
|
|
expect(inv.emailIds, ['e1']);
|
|
expect(inv.sourceMailboxPath, 'Trash');
|
|
expect(inv.destinationMailboxPath, 'INBOX');
|
|
verify(
|
|
mockUndoRepo.saveAction(
|
|
argThat(predicate<UndoAction>((a) => a.id == 'del1-inv')),
|
|
),
|
|
).called(1);
|
|
});
|
|
|
|
test('undo without destinationMailboxPath does not push inverse action',
|
|
() async {
|
|
final action = UndoAction(
|
|
id: 'mv1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
// no destinationMailboxPath
|
|
);
|
|
|
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
|
when(
|
|
mockEmailRepo.cancelPendingChange(any, any),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(action);
|
|
await notifier.undo(actionId: 'mv1');
|
|
|
|
// Original entry stays; no inverse since no destinationMailboxPath.
|
|
final log = container.read(undoServiceProvider);
|
|
expect(log.length, 1);
|
|
expect(log.first.id, 'mv1');
|
|
});
|
|
|
|
test('undo with actionId removes and undos specific action', () async {
|
|
// action1 has no destination → no inverse action
|
|
final action1 = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
final action2 = UndoAction(
|
|
id: '2',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e2'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
|
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
|
when(
|
|
mockEmailRepo.cancelPendingChange(any, any),
|
|
).thenAnswer((_) async => false);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(action1);
|
|
await notifier.pushAction(action2);
|
|
|
|
// action1 has no destinationMailboxPath → no inverse pushed, original stays.
|
|
await notifier.undo(actionId: '1');
|
|
expect(container.read(undoServiceProvider), [action1, action2]);
|
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
|
verifyNever(mockEmailRepo.moveEmail('e2', 'INBOX'));
|
|
});
|
|
|
|
test('undo performs cancelPendingChange optimization', () async {
|
|
final action = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
|
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
|
when(
|
|
mockEmailRepo.cancelPendingChange('e1', 'delete'),
|
|
).thenAnswer((_) async => false);
|
|
when(
|
|
mockEmailRepo.cancelPendingChange('e1', 'move'),
|
|
).thenAnswer((_) async => true);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(action);
|
|
|
|
await notifier.undo();
|
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
|
verify(mockEmailRepo.cancelPendingChange('e1', 'move')).called(2);
|
|
});
|
|
|
|
test('undo restores hard-deleted emails if provided', () async {
|
|
final email = Email(
|
|
id: 'e1',
|
|
accountId: 'acc1',
|
|
mailboxPath: 'Trash',
|
|
uid: 10,
|
|
from: [const EmailAddress(email: 'me@me.com')],
|
|
to: [],
|
|
cc: [],
|
|
subject: 'hi',
|
|
receivedAt: DateTime.now(),
|
|
isSeen: true,
|
|
isFlagged: false,
|
|
hasAttachment: false,
|
|
);
|
|
final action = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
originalEmails: [email],
|
|
);
|
|
|
|
when(
|
|
mockEmailRepo.cancelPendingChange(any, any),
|
|
).thenAnswer((_) async => false);
|
|
when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {});
|
|
when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {});
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(action);
|
|
|
|
await notifier.undo();
|
|
|
|
verify(mockEmailRepo.restoreEmails(any)).called(1);
|
|
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
|
|
});
|
|
|
|
test('init loads persisted history from repository', () async {
|
|
final persisted = UndoAction(
|
|
id: '99',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e99'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
|
|
when(
|
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
|
).thenAnswer((_) async => [persisted]);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
|
|
expect(container.read(undoServiceProvider), [persisted]);
|
|
});
|
|
|
|
test('pushAction after restart appends to persisted history', () async {
|
|
final persisted = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
final newAction = UndoAction(
|
|
id: '2',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e2'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
|
|
when(
|
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
|
).thenAnswer((_) async => [persisted]);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
await notifier.init();
|
|
await notifier.pushAction(newAction);
|
|
|
|
expect(container.read(undoServiceProvider), [persisted, newAction]);
|
|
});
|
|
|
|
test('pushAction concurrent with init waits for init to complete', () async {
|
|
final persisted = UndoAction(
|
|
id: '1',
|
|
accountId: 'acc1',
|
|
type: UndoType.move,
|
|
emailIds: ['e1'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
final raced = UndoAction(
|
|
id: '2',
|
|
accountId: 'acc1',
|
|
type: UndoType.delete,
|
|
emailIds: ['e2'],
|
|
sourceMailboxPath: 'INBOX',
|
|
);
|
|
|
|
// Simulate slow DB load
|
|
when(
|
|
mockUndoRepo.getHistory(limit: anyNamed('limit')),
|
|
).thenAnswer(
|
|
(_) => Future.delayed(
|
|
const Duration(milliseconds: 10),
|
|
() => [persisted],
|
|
),
|
|
);
|
|
|
|
final notifier = container.read(undoServiceProvider.notifier);
|
|
final initFuture = notifier.init();
|
|
// pushAction issued before init completes — it must still see persisted history
|
|
final pushFuture = notifier.pushAction(raced);
|
|
|
|
await Future.wait([initFuture, pushFuture]);
|
|
|
|
expect(container.read(undoServiceProvider), [persisted, raced]);
|
|
});
|
|
}
|