diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart new file mode 100644 index 0000000..97d9348 --- /dev/null +++ b/test/unit/reliability_runner_test.dart @@ -0,0 +1,348 @@ +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 accounts; + _FakeAccounts([Account? account]) : accounts = [account ?? _account()]; + + @override + Stream> observeAccounts() => Stream.value(accounts); + @override + Future getAccount(String id) async => + accounts.cast().firstWhere( + (a) => a?.id == id, + orElse: () => null, + ); + @override + Future addAccount(Account account, String password) async {} + @override + Future updateAccount(Account account, {String? password}) async {} + @override + Future removeAccount(String id) async {} + @override + Future getPassword(String id) async => 'secret'; +} + +class _FakeMailboxes implements MailboxRepository { + final List mailboxes; + _FakeMailboxes([this.mailboxes = const []]); + @override + Stream> observeMailboxes(String? accountId) => + Stream.value(mailboxes); + @override + Future syncMailboxes(String accountId) async => 0; + @override + Future findMailboxByRole(String accountId, String role) async => + null; + @override + Future clearForResync(String accountId) async {} +} + +class _CountingEmails implements EmailRepository { + int syncCount = 0; + int wakeUpCount = 0; + final Exception? syncError; + + _CountingEmails({this.syncError}); + + @override + Future syncEmails(String accountId, String mailbox) async { + syncCount++; + if (syncError != null) throw syncError!; + return SyncEmailsResult.zero; + } + + @override + Future wakeUpEmails(String accountId) async { + wakeUpCount++; + return 0; + } + + @override + Future flushPendingChanges(String accountId, String password) async => 0; + @override + Stream> observeEmails(String a, String m) => Stream.value([]); + @override + Stream> observeThreads(String a, String m) => + Stream.value([]); + @override + Stream> observeEmailsInThread(String a, String m, String t) => + Stream.value([]); + @override + Future getEmail(String id) async => null; + @override + Future getEmailBody(String id) async => + const EmailBody(emailId: '', attachments: []); + @override + Future setFlag(String id, {bool? seen, bool? flagged}) async {} + @override + Future moveEmail(String id, String dest) async {} + @override + Future deleteEmail(String id) async => null; + @override + Future sendEmail(String accountId, EmailDraft draft) async {} + @override + Future downloadAttachment(String id, EmailAttachment att) async => ''; + @override + Future> searchEmails(String a, String m, String q) async => []; + @override + Future> searchEmailsGlobal(String? a, String q) async => []; + @override + Future> getEmailsByAddress(String? a, String addr) async => []; + @override + Stream> observeFailedMutations(String a) => + Stream.value([]); + @override + Future discardMutation(int id) async {} + @override + Future retryMutation(int id) async {} + @override + Future cancelPendingChange(String id, String type) async => false; + @override + Future snoozeEmail(String id, DateTime until) async {} + @override + Future restoreEmails(List emails) async {} + @override + Stream get onChangesQueued => const Stream.empty(); + @override + Stream watchJmapPush(String accountId, String password) => + const Stream.empty(); + @override + Future verifySyncReliability( + String accountId, + String mailboxPath, + ) async => + ReliabilityResult.healthy; + @override + Future clearForResync(String accountId) async {} +} + +class _FakeSyncLog implements SyncLogRepository { + final logs = []; + @override + Future 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 mailboxStats = const [], + String? protocolLog, + }) async { + logs.add(success); + } + + @override + Stream> observeSyncLogs(String accountId) => + Stream.value([]); + + @override + Stream 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 Function(String) onSync}) + : _onSync = onSync; + + final Future Function(String) _onSync; + + @override + Future syncEmails(String accountId, String mailbox) => + _onSync(mailbox); +}