import 'dart:async'; import 'package:flutter/services.dart' show MissingPluginException; import 'package:mockito/annotations.dart'; import 'package:sharedinbox/core/filter/filter_expression.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'; import 'package:test/test.dart'; @GenerateMocks([AccountRepository, MailboxRepository, EmailRepository]) import 'account_sync_manager_test.mocks.dart'; void main() { late MockAccountRepository accounts; late MockMailboxRepository mailboxes; late MockEmailRepository emails; late AccountSyncManager manager; setUp(() { accounts = MockAccountRepository(); mailboxes = MockMailboxRepository(); emails = MockEmailRepository(); manager = AccountSyncManager(accounts, mailboxes, emails); }); test('syncNow kicks the active sync loop', () async { // This is hard to test without real loops, but we can verify it doesn't crash. manager.syncNow('unknown'); }); // Regression test for issue #200: when flutter_secure_storage throws // MissingPluginException (channel unavailable on the device), the IMAP sync // loop must stop permanently instead of retrying indefinitely with backoff. test( 'MissingPluginException from secure storage stops IMAP sync loop permanently', () async { final syncLog = FakeSyncLogRepository(); final m = AccountSyncManager( _AccountRepositoryWithMissingPlugin(), FakeMailboxRepositoryWithInbox(), FakeEmailRepository(), syncLog: syncLog, ); m.start(); // Allow the first sync cycle to run and fail. await Future.delayed(const Duration(milliseconds: 100)); expect(syncLog.logs, hasLength(1)); expect(syncLog.logs.first.success, isFalse); // Kicking the loop should have no effect once it has stopped permanently. m.syncNow('1'); await Future.delayed(const Duration(milliseconds: 100)); // Before the fix: kick triggers a retry → 2 log entries. // After the fix: loop is permanently stopped → still exactly 1 entry. expect(syncLog.logs, hasLength(1)); m.dispose(); }, ); } class FakeEmailRepository implements EmailRepository { @override Stream> observeEmails(String a, String m, {int limit = 50}) => Stream.value([]); @override Stream> observeThreads( String a, String m, { int limit = 50, }) => Stream.value([]); @override Stream> observeAllInboxThreads({int limit = 50}) => 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 syncEmails(String a, String m) async => SyncEmailsResult.zero; @override Future setFlag(String id, {bool? seen, bool? flagged}) async {} @override Future markAllAsRead(String accountId, String mailboxPath) async {} @override Future moveEmail(String id, String dest) async {} @override Future cancelPendingChange(String id, String type) async => false; @override Future snoozeEmail(String emailId, DateTime until) async {} @override Future wakeUpEmails(String accountId) async => 0; @override Future restoreEmails(List emails) async {} @override Future findEmailByMessageId( String accountId, String messageId, ) async => null; @override Future deleteEmail(String id) async => null; @override Stream get onChangesQueued => const Stream.empty(); @override Future flushPendingChanges(String a, String p) async => 0; @override Future sendEmail(String a, EmailDraft d) async {} @override Future downloadAttachment(String id, EmailAttachment a) async => ''; @override Future fetchRawRfc822(String emailId) async => ''; @override Future> searchEmails(String a, String m, String q) async => []; @override Future> searchEmailsGlobal(String? a, String q) async => []; @override Future> searchEmailsStructured( String? a, FilterGroup f, ) async => []; @override Future> getEmailsByAddress(String? a, String address) async => []; @override Future> searchAddresses( String? a, String q, { int limit = 10, }) async => []; @override Stream watchJmapPush(String a, String p) => const Stream.empty(); @override Stream> observeFailedMutations(String a) => Stream.value([]); @override Future discardMutation(int id) async {} @override Future retryMutation(int id) async {} @override Future verifySyncReliability( String accountId, String mailboxPath, ) async => ReliabilityResult.healthy; @override Future clearForResync(String accountId) async {} @override Future applySieveRules(String accountId) async => 0; } class _Log { _Log({required this.success}); final bool success; } class FakeSyncLogRepository implements SyncLogRepository { final logs = <_Log>[]; @override Future log({ required String accountId, required bool success, String? errorMessage, String? stackTrace, bool isPermanent = false, 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(_Log(success: success)); } @override Stream> observeSyncLogs(String accountId) => Stream.value([]); @override Stream observeLastError(String accountId) => Stream.value(null); } class FakeMailboxRepositoryWithInbox implements MailboxRepository { @override Stream> observeMailboxes(String? accountId) => Stream.value([ const Mailbox( id: '1:INBOX', accountId: '1', path: 'INBOX', name: 'INBOX', unreadCount: 0, totalCount: 0, role: 'inbox', ), ]); @override Future syncMailboxes(String id) async => 1; @override Future findMailboxByRole(String id, String role) async => null; @override Future clearForResync(String accountId) async {} @override Future createMailboxWithRole( String accountId, String name, String role, ) async => Mailbox( id: '$accountId:$name', accountId: accountId, path: name, name: name, role: role, unreadCount: 0, totalCount: 0, ); @override Future createMailbox(String accountId, String name) async => Mailbox( id: '$accountId:$name', accountId: accountId, path: name, name: name, unreadCount: 0, totalCount: 0, ); } class _AccountRepositoryWithMissingPlugin implements AccountRepository { static const _account = Account( id: '1', displayName: 'Test', email: 'test@example.com', ); @override Stream> observeAccounts() => Stream.value([_account]); @override Future getAccount(String id) async => _account; @override Future getPassword(String accountId) => Future.error( MissingPluginException( 'No implementation found for method read on channel ' 'plugins.it.nomads.com/flutter_secure_storage', ), ); @override Future addAccount(Account account, String password) async {} @override Future updateAccount(Account account, {String? password}) async {} @override Future removeAccount(String id) async {} }