// Shared helpers for widget tests. // // Each test pumps [buildApp] which wires up a fresh GoRouter (same route tree // as the real app) inside a ProviderScope whose repository providers are // replaced with lightweight in-memory fakes. No database or network is used. import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show Override; import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/discovery_result.dart'; import 'package:sharedinbox/core/models/draft.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/user_preferences_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/share_encryption_service.dart'; import 'package:sharedinbox/data/db/database.dart' show AppDatabase, SyncHealthRow; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; import 'package:sharedinbox/ui/screens/account_send_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; import 'package:sharedinbox/ui/screens/compose_screen.dart'; import 'package:sharedinbox/ui/screens/edit_account_screen.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; // --------------------------------------------------------------------------- // Fake repositories // --------------------------------------------------------------------------- class FakeAccountRepository implements AccountRepository { FakeAccountRepository([List? accounts]) : _accounts = List.of(accounts ?? []); final List _accounts; bool hasPassword = true; @override Stream> observeAccounts() => Stream.value(List.of(_accounts)); @override Future getAccount(String id) async { for (final a in _accounts) { if (a.id == id) return a; } return null; } @override Future addAccount(Account account, String password) async => _accounts.add(account); @override Future updateAccount(Account account, {String? password}) async { final idx = _accounts.indexWhere((a) => a.id == account.id); if (idx >= 0) _accounts[idx] = account; } @override Future removeAccount(String id) async => _accounts.removeWhere((a) => a.id == id); @override Future getPassword(String accountId) async { if (!hasPassword) { throw StateError('No password stored for account $accountId'); } return 'test-password'; } } class FakeShareKeyRepository implements ShareKeyRepository { FakeShareKeyRepository({ShareKeyMaterial? material}) : _material = material; ShareKeyMaterial? _material; @override Future createKeyPair() async { _material ??= await ShareEncryptionService.generateKeyPair(); return _material!; } @override Future findByKeyId(dynamic keyId) async => _material; } class FakeDraftRepository implements DraftRepository { int _nextId = 1; final Map _drafts = {}; @override Future saveDraft({ int? id, String? accountId, String? replyToEmailId, required String toText, required String ccText, required String subjectText, required String bodyText, }) async { final draftId = id ?? _nextId++; final draft = SavedDraft( id: draftId, accountId: accountId, replyToEmailId: replyToEmailId, toText: toText, ccText: ccText, subjectText: subjectText, bodyText: bodyText, updatedAt: DateTime.now(), ); _drafts[draftId] = draft; return draft; } @override Future findDraft({String? replyToEmailId}) async { final matches = _drafts.values.where((d) { if (replyToEmailId == null) return d.replyToEmailId == null; return d.replyToEmailId == replyToEmailId; }).toList() ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); return matches.isEmpty ? null : matches.first; } @override Future getDraft(int id) async => _drafts[id]; @override Future deleteDraft(int id) async => _drafts.remove(id); @override Future syncDrafts(String accountId, String password) async {} } class FakeMailboxRepository implements MailboxRepository { final List _mailboxes; FakeMailboxRepository([List? mailboxes]) : _mailboxes = mailboxes ?? []; @override Stream> observeMailboxes(String? accountId) => Stream.value(List.of(_mailboxes)); @override Future syncMailboxes(String accountId) async => 0; @override Future findMailboxByRole(String accountId, String role) async => _mailboxes.where((m) => m.role == role).firstOrNull; @override Future clearForResync(String accountId) async {} @override Future createMailboxWithRole( String accountId, String name, String role, ) async { final mailbox = Mailbox( id: '$accountId:$name', accountId: accountId, path: name, name: name, role: role, unreadCount: 0, totalCount: 0, ); _mailboxes.add(mailbox); return mailbox; } } class FakeEmailRepository implements EmailRepository { final List _emails; final Email? _emailDetail; final EmailBody _emailBody; final String _rawRfc822; final List _searchResults; FakeEmailRepository({ List? emails, Email? emailDetail, EmailBody? emailBody, List? searchResults, String rawRfc822 = '', }) : _emails = emails ?? [], _emailDetail = emailDetail, _searchResults = searchResults ?? [], _rawRfc822 = rawRfc822, _emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []); @override Stream> observeEmails( String accountId, String mailboxPath, { int limit = 50, }) => Stream.value(List.of(_emails)); @override Stream> observeThreads( String accountId, String mailboxPath, { int limit = 50, }) => observeEmails(accountId, mailboxPath).map((emails) { return emails.map((e) { return EmailThread( threadId: e.threadId ?? e.id, subject: e.subject, preview: e.preview, participants: e.from, latestDate: e.sentAt ?? e.receivedAt, messageCount: 1, hasUnread: !e.isSeen, isFlagged: e.isFlagged, latestEmailId: e.id, emailIds: [e.id], accountId: e.accountId, mailboxPath: e.mailboxPath, ); }).toList(); }); @override Stream> observeAllInboxThreads({int limit = 50}) => Stream.value([]); @override Stream> observeEmailsInThread( String accountId, String mailboxPath, String threadId, ) => Stream.value(_emails.where((e) => e.threadId == threadId).toList()); @override Future getEmail(String emailId) async => _emailDetail; @override Future getEmailBody(String emailId) async => _emailBody; @override Future syncEmails( String accountId, String mailboxPath, ) async => SyncEmailsResult.zero; @override Future setFlag(String emailId, {bool? seen, bool? flagged}) async {} @override Future markAllAsRead(String accountId, String mailboxPath) async {} @override Future moveEmail(String emailId, String destMailboxPath) 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 emailId) async => null; @override Stream get onChangesQueued => const Stream.empty(); @override Future flushPendingChanges(String accountId, String password) async => 0; @override Future sendEmail(String accountId, EmailDraft draft) async {} @override Future downloadAttachment( String emailId, EmailAttachment attachment, ) async => '/tmp/${attachment.filename}'; @override Future fetchRawRfc822(String emailId) async => _rawRfc822; @override Future> searchEmails( String accountId, String mailboxPath, String query, ) async => _searchResults; @override Future> searchEmailsGlobal( String? accountId, String query, ) async => _searchResults; @override Future> getEmailsByAddress( String? accountId, String address, ) async => []; @override Future> searchAddresses( String? accountId, String query, { int limit = 10, }) async => []; @override Stream watchJmapPush(String accountId, String password) => const Stream.empty(); @override Future verifySyncReliability( String accountId, String mailboxPath, ) async => ReliabilityResult.healthy; @override Stream> observeFailedMutations(String accountId) => Stream.value([]); @override Future discardMutation(int id) async {} @override Future retryMutation(int id) async {} @override Future clearForResync(String accountId) async {} @override Future applySieveRules(String accountId) async => 0; } // --------------------------------------------------------------------------- // Fake services // --------------------------------------------------------------------------- class FakeDiscoveryService implements AccountDiscoveryService { FakeDiscoveryService(this._result); final DiscoveryResult _result; @override Future discover(String email) async => _result; } class FakeConnectionTestService implements ConnectionTestService { FakeConnectionTestService({Exception? error}) : _error = error; final Exception? _error; @override Future testConnection(Account account, String password) async { if (_error != null) throw _error; return account.username.isNotEmpty ? account.username : account.email; } } class _NoOpManageSieveProbeService implements ManageSieveProbeService { @override Future probe(Account account) async { /* no-op in tests */ } } // --------------------------------------------------------------------------- // App builder // --------------------------------------------------------------------------- /// Builds a fully wired test app that starts at [initialLocation]. /// /// Providers are replaced with [overrides], so no database or network is used. /// A fresh [GoRouter] is created for every call so tests are independent. Widget buildApp({ required String initialLocation, required List overrides, UserPreferencesRepository? userPreferences, ThemeMode themeMode = ThemeMode.light, bool debugShowCheckedModeBanner = true, }) { final testRouter = GoRouter( initialLocation: initialLocation, routes: [ GoRoute( path: '/accounts', builder: (ctx, state) => const AccountListScreen(), routes: [ GoRoute( path: 'add', builder: (ctx, state) => const AddAccountScreen(), ), GoRoute( path: 'receive', builder: (ctx, state) => const AccountReceiveScreen(), ), GoRoute( path: 'send', builder: (ctx, state) => const AccountSendScreen(), ), GoRoute( path: 'preferences', builder: (ctx, state) => const UserPreferencesScreen(), ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( accountId: state.pathParameters['accountId']!, ), ), GoRoute( path: ':accountId/search', builder: (ctx, state) => SearchScreen(accountId: state.pathParameters['accountId']!), ), GoRoute( path: ':accountId/emails/by-address/:address', builder: (ctx, state) => AddressEmailsScreen( accountId: state.pathParameters['accountId']!, address: state.pathParameters['address']!, ), ), GoRoute( path: ':accountId/mailboxes', builder: (ctx, state) => MailboxListScreen( accountId: state.pathParameters['accountId']!, ), routes: [ GoRoute( path: ':mailboxPath/emails', builder: (ctx, state) => EmailListScreen( accountId: state.pathParameters['accountId']!, mailboxPath: state.pathParameters['mailboxPath']!, ), routes: [ GoRoute( path: ':emailId', builder: (ctx, state) => EmailDetailScreen( emailId: state.pathParameters['emailId']!, ), ), ], ), GoRoute( path: ':mailboxPath/threads/:threadId', builder: (ctx, state) => ThreadDetailScreen( accountId: state.pathParameters['accountId']!, mailboxPath: Uri.decodeComponent( state.pathParameters['mailboxPath']!, ), threadId: Uri.decodeComponent( state.pathParameters['threadId']!, ), ), ), ], ), ], ), GoRoute( path: '/compose', builder: (ctx, state) { final extra = state.extra as Map?; return ComposeScreen( accountId: extra?['accountId'] as String?, replyToEmailId: extra?['replyToEmailId'] as String?, prefillTo: extra?['prefillTo'] as String?, prefillCc: extra?['prefillCc'] as String?, prefillSubject: extra?['prefillSubject'] as String?, prefillBody: extra?['prefillBody'] as String?, ); }, ), ], ); return ProviderScope( // Defaults come first so tests can override them via [overrides]. // // syncLogRepositoryProvider is backed by a Drift StreamQuery. When the // provider is disposed, Drift schedules a Timer.run() for cache // debouncing. Flutter's test framework then fails the test with "A Timer // is still pending". Replacing it with a synchronous stream avoids this. // syncHealthProvider has the same issue and is overridden in baseOverrides. overrides: [ dbProvider.overrideWith((ref) { final db = AppDatabase(NativeDatabase.memory()); ref.onDispose(db.close); return db; }), syncLogRepositoryProvider.overrideWithValue( const NoOpSyncLogRepository(), ), userPreferencesRepositoryProvider.overrideWithValue( userPreferences ?? FakeUserPreferencesRepository(), ), ...overrides, manageSieveProbeServiceProvider.overrideWith( (ref) => _NoOpManageSieveProbeService(), ), ], child: MaterialApp.router( routerConfig: testRouter, themeMode: themeMode, debugShowCheckedModeBanner: debugShowCheckedModeBanner, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.indigo, brightness: Brightness.dark, ), useMaterial3: true, ), ), ); } /// Convenience override list used by most widget tests. /// /// Includes fakes for all repositories and the two network services so tests /// never hit the real database, network, or IMAP server. List baseOverrides({ List? accounts, List? mailboxes, DiscoveryResult? discovery, Exception? connectionError, ShareKeyRepository? shareKeyRepository, bool hasStoredPassword = true, SyncHealthRow? syncHealth, }) => [ accountRepositoryProvider.overrideWithValue( FakeAccountRepository(accounts)..hasPassword = hasStoredPassword, ), mailboxRepositoryProvider .overrideWithValue(FakeMailboxRepository(mailboxes)), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), accountDiscoveryServiceProvider.overrideWithValue( FakeDiscoveryService(discovery ?? UnknownDiscovery()), ), connectionTestServiceProvider.overrideWithValue( FakeConnectionTestService(error: connectionError), ), shareKeyRepositoryProvider.overrideWithValue( shareKeyRepository ?? FakeShareKeyRepository(), ), // syncHealthProvider is backed by a Drift StreamQuery; override with a // plain stream to avoid "A Timer is still pending" in tests. syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)), ]; // --------------------------------------------------------------------------- // Common test fixtures // --------------------------------------------------------------------------- const kTestAccount = Account( id: 'acc-1', displayName: 'Alice', email: 'alice@example.com', imapHost: 'imap.example.com', smtpHost: 'smtp.example.com', ); const kTestMailbox = Mailbox( id: 'acc-1:INBOX', accountId: 'acc-1', path: 'INBOX', name: 'INBOX', unreadCount: 3, totalCount: 10, ); Email testEmail({ String id = 'acc-1:42', String subject = 'Hello world', bool isSeen = false, bool isFlagged = false, bool hasAttachment = false, String? listUnsubscribeHeader, }) => Email( id: id, accountId: 'acc-1', mailboxPath: 'INBOX', uid: 42, subject: subject, receivedAt: DateTime(2024, 6), sentAt: DateTime(2024, 6), from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], to: const [EmailAddress(email: 'alice@example.com')], cc: const [], isSeen: isSeen, isFlagged: isFlagged, hasAttachment: hasAttachment, listUnsubscribeHeader: listUnsubscribeHeader, ); class FakeUserPreferencesRepository implements UserPreferencesRepository { FakeUserPreferencesRepository({ this.menuPosition = MenuPosition.bottom, this.mailViewButtonPosition = MenuPosition.bottom, this.afterMailViewAction = AfterMailViewAction.nextMessage, List? trustedImageSenders, }) : _trustedImageSenders = trustedImageSenders ?? []; MenuPosition menuPosition; MenuPosition mailViewButtonPosition; AfterMailViewAction afterMailViewAction; final List _trustedImageSenders; @override Stream observePreferences() => Stream.value( UserPreferences( menuPosition: menuPosition, mailViewButtonPosition: mailViewButtonPosition, afterMailViewAction: afterMailViewAction, ), ); @override Future updateMenuPosition(MenuPosition position) async { menuPosition = position; } @override Future updateMailViewButtonPosition(MenuPosition position) async { mailViewButtonPosition = position; } @override Future updateAfterMailViewAction(AfterMailViewAction action) async { afterMailViewAction = action; } @override Future updatePrefetchMode(PrefetchMode mode) async {} @override Future updateBodyCacheLimitMb(int mb) async {} @override Stream> observeTrustedImageSenders() => Stream.value(List.of(_trustedImageSenders)); @override Future addTrustedImageSender(String senderEmail) async { final normalized = senderEmail.toLowerCase(); if (!_trustedImageSenders.contains(normalized)) { _trustedImageSenders.add(normalized); } } @override Future removeTrustedImageSender(String senderEmail) async { _trustedImageSenders.remove(senderEmail.toLowerCase()); } } class FakeSearchHistoryRepository implements SearchHistoryRepository { final List _history = []; @override Future> getRecentSearches() async => List.unmodifiable(_history); @override Future saveSearch(String query) async { _history.remove(query); _history.insert(0, query); if (_history.length > 10) _history.removeLast(); } @override Future clearHistory() async => _history.clear(); }