Files
sharedinbox/test/unit/undo_service_test.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 69e358204d fix(undo): keep undo log entry and fix IMAP UID mismatch after sync (#81)
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>
2026-05-15 10:46:12 +02:00

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]);
});
}