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>
375 lines
12 KiB
Dart
375 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
|
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
Account _account({String id = 'a1'}) => Account(
|
|
id: id,
|
|
displayName: 'Test',
|
|
email: 'test@example.com',
|
|
imapHost: 'localhost',
|
|
);
|
|
|
|
class _FakeAccounts implements AccountRepository {
|
|
final List<Account> accounts;
|
|
_FakeAccounts([Account? account]) : accounts = [account ?? _account()];
|
|
|
|
@override
|
|
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
|
@override
|
|
Future<Account?> getAccount(String id) async =>
|
|
accounts.cast<Account?>().firstWhere(
|
|
(a) => a?.id == id,
|
|
orElse: () => null,
|
|
);
|
|
@override
|
|
Future<void> addAccount(Account account, String password) async {}
|
|
@override
|
|
Future<void> updateAccount(Account account, {String? password}) async {}
|
|
@override
|
|
Future<void> removeAccount(String id) async {}
|
|
@override
|
|
Future<String> getPassword(String id) async => 'secret';
|
|
}
|
|
|
|
class _FakeMailboxes implements MailboxRepository {
|
|
final List<Mailbox> mailboxes;
|
|
_FakeMailboxes([this.mailboxes = const []]);
|
|
@override
|
|
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
|
|
Stream.value(mailboxes);
|
|
@override
|
|
Future<int> syncMailboxes(String accountId) async => 0;
|
|
@override
|
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
|
null;
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
}
|
|
|
|
class _CountingEmails implements EmailRepository {
|
|
int syncCount = 0;
|
|
int wakeUpCount = 0;
|
|
final Exception? syncError;
|
|
|
|
_CountingEmails({this.syncError});
|
|
|
|
@override
|
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailbox) async {
|
|
syncCount++;
|
|
if (syncError != null) throw syncError!;
|
|
return SyncEmailsResult.zero;
|
|
}
|
|
|
|
@override
|
|
Future<int> wakeUpEmails(String accountId) async {
|
|
wakeUpCount++;
|
|
return 0;
|
|
}
|
|
|
|
@override
|
|
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
|
@override
|
|
Stream<List<Email>> observeEmails(
|
|
String a,
|
|
String m, {
|
|
int limit = 50,
|
|
}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<EmailThread>> observeThreads(
|
|
String a,
|
|
String m, {
|
|
int limit = 50,
|
|
}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
|
Stream.value([]);
|
|
@override
|
|
Future<Email?> getEmail(String id) async => null;
|
|
@override
|
|
Future<EmailBody> getEmailBody(String id) async =>
|
|
const EmailBody(emailId: '', attachments: []);
|
|
@override
|
|
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
|
@override
|
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
|
@override
|
|
Future<void> moveEmail(String id, String dest) async {}
|
|
@override
|
|
Future<String?> deleteEmail(String id) async => null;
|
|
@override
|
|
Future<void> sendEmail(String accountId, EmailDraft draft) async {}
|
|
@override
|
|
Future<String> downloadAttachment(String id, EmailAttachment att) async => '';
|
|
@override
|
|
Future<String> fetchRawRfc822(String emailId) async => '';
|
|
@override
|
|
Future<List<Email>> searchEmails(String a, String m, String q) async => [];
|
|
@override
|
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
|
@override
|
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
|
@override
|
|
Future<List<EmailAddress>> searchAddresses(
|
|
String? a,
|
|
String q, {
|
|
int limit = 10,
|
|
}) async =>
|
|
[];
|
|
@override
|
|
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
|
Stream.value([]);
|
|
@override
|
|
Future<void> discardMutation(int id) async {}
|
|
@override
|
|
Future<void> retryMutation(int id) async {}
|
|
@override
|
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
|
@override
|
|
Future<void> snoozeEmail(String id, DateTime until) async {}
|
|
@override
|
|
Future<void> restoreEmails(List<Email> emails) async {}
|
|
@override
|
|
Future<Email?> findEmailByMessageId(
|
|
String accountId,
|
|
String messageId,
|
|
) async =>
|
|
null;
|
|
@override
|
|
Stream<String> get onChangesQueued => const Stream.empty();
|
|
@override
|
|
Stream<void> watchJmapPush(String accountId, String password) =>
|
|
const Stream.empty();
|
|
@override
|
|
Future<ReliabilityResult> verifySyncReliability(
|
|
String accountId,
|
|
String mailboxPath,
|
|
) async =>
|
|
ReliabilityResult.healthy;
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
}
|
|
|
|
class _FakeSyncLog implements SyncLogRepository {
|
|
final logs = <bool>[];
|
|
@override
|
|
Future<void> log({
|
|
required String accountId,
|
|
required bool success,
|
|
String? errorMessage,
|
|
required String protocol,
|
|
required int emailsFetched,
|
|
required int emailsSkipped,
|
|
required int mailboxesSynced,
|
|
required int pendingFlushed,
|
|
required int bytesTransferred,
|
|
required DateTime startedAt,
|
|
required DateTime finishedAt,
|
|
List<MailboxSyncStats> mailboxStats = const [],
|
|
String? protocolLog,
|
|
}) async {
|
|
logs.add(success);
|
|
}
|
|
|
|
@override
|
|
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
|
|
Stream.value([]);
|
|
|
|
@override
|
|
Stream<String?> observeLastError(String accountId) => Stream.value(null);
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
void main() {
|
|
group('AccountSyncManager backoff', () {
|
|
test('backoff is capped at 900 s after repeated failures', () {
|
|
fakeAsync((async) {
|
|
final emails = _CountingEmails(
|
|
syncError: Exception('connection refused'),
|
|
);
|
|
final syncLog = _FakeSyncLog();
|
|
final manager = AccountSyncManager(
|
|
_FakeAccounts(),
|
|
_FakeMailboxes([
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
]),
|
|
emails,
|
|
syncLog: syncLog,
|
|
imapConnect: (_, __, ___) async =>
|
|
throw Exception('connection refused'),
|
|
);
|
|
|
|
manager.start();
|
|
|
|
// Advance 3 hours — long enough to observe many retries.
|
|
// With max backoff 900 s, we expect at least floor(3*3600/900) = 12
|
|
// attempts, and at most 3*3600/5 = 2160 (if backoff never grew).
|
|
async.elapse(const Duration(hours: 3));
|
|
|
|
final failCount = syncLog.logs.where((ok) => !ok).length;
|
|
expect(
|
|
failCount,
|
|
greaterThan(10),
|
|
reason: 'should have retried many times within 3 h',
|
|
);
|
|
expect(
|
|
failCount,
|
|
lessThan(2200),
|
|
reason: 'backoff must have kicked in — not every 5 s for 3 h',
|
|
);
|
|
|
|
manager.dispose();
|
|
async.elapse(const Duration(seconds: 1));
|
|
});
|
|
});
|
|
|
|
test('backoff resets to 5 s after a successful sync', () {
|
|
fakeAsync((async) {
|
|
int callCount = 0;
|
|
final syncLog = _FakeSyncLog();
|
|
|
|
var failsLeft = 5;
|
|
final customEmails = _OverrideEmails(
|
|
onSync: (_) async {
|
|
callCount++;
|
|
if (failsLeft > 0) {
|
|
failsLeft--;
|
|
throw Exception('transient error');
|
|
}
|
|
return SyncEmailsResult.zero;
|
|
},
|
|
);
|
|
|
|
final manager = AccountSyncManager(
|
|
_FakeAccounts(),
|
|
_FakeMailboxes([
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
]),
|
|
customEmails,
|
|
syncLog: syncLog,
|
|
imapConnect: (_, __, ___) async =>
|
|
throw Exception('skip idle — force immediate loop'),
|
|
);
|
|
|
|
manager.start();
|
|
|
|
// Allow errors + backoff to build up, then a success, then more loops.
|
|
async.elapse(const Duration(seconds: 3600));
|
|
|
|
// After success, backoff should reset; failures before success should
|
|
// be exactly 5, and subsequent loops should fire frequently.
|
|
final successCount = syncLog.logs.where((ok) => ok).length;
|
|
expect(
|
|
successCount,
|
|
greaterThan(0),
|
|
reason: 'should have at least one success',
|
|
);
|
|
expect(
|
|
callCount,
|
|
greaterThan(5),
|
|
reason: 'should retry after failures and continue after success',
|
|
);
|
|
|
|
manager.dispose();
|
|
async.elapse(const Duration(seconds: 1));
|
|
});
|
|
});
|
|
|
|
test('concurrent sync errors from multiple accounts stay bounded', () {
|
|
fakeAsync((async) {
|
|
final accounts = _FakeAccounts()
|
|
..accounts.add(_account(id: 'a2'))
|
|
..accounts.add(_account(id: 'a3'));
|
|
final syncLog = _FakeSyncLog();
|
|
final manager = AccountSyncManager(
|
|
accounts,
|
|
_FakeMailboxes([
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a2',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a3',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
]),
|
|
_CountingEmails(syncError: Exception('network error')),
|
|
syncLog: syncLog,
|
|
imapConnect: (_, __, ___) async =>
|
|
throw Exception('connection refused'),
|
|
);
|
|
|
|
manager.start();
|
|
async.elapse(const Duration(hours: 2));
|
|
|
|
// All 3 accounts retry, each bounded by the 900 s cap.
|
|
final failCount = syncLog.logs.where((ok) => !ok).length;
|
|
expect(failCount, greaterThan(5));
|
|
expect(
|
|
failCount,
|
|
lessThan(5000),
|
|
reason: 'backoff must be in effect across all accounts',
|
|
);
|
|
|
|
manager.dispose();
|
|
async.elapse(const Duration(seconds: 1));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── _OverrideEmails ───────────────────────────────────────────────────────────
|
|
|
|
class _OverrideEmails extends _CountingEmails {
|
|
_OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync})
|
|
: _onSync = onSync;
|
|
|
|
final Future<SyncEmailsResult> Function(String) _onSync;
|
|
|
|
@override
|
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailbox) =>
|
|
_onSync(mailbox);
|
|
}
|