From efdcab74d777bf274a6f77da5e04e6672e20a10a Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Tue, 12 May 2026 21:55:06 +0200 Subject: [PATCH] setup nix in CI, and reformat. --- .forgejo/workflows/ci.yml | 12 +- .forgejo/workflows/release.yml | 2 +- .gitignore | 1 + Taskfile.yml | 4 +- flake.nix | 1 + lib/core/models/email.dart | 12 +- lib/core/repositories/email_repository.dart | 6 +- .../services/account_discovery_service.dart | 9 +- .../services/managesieve_probe_service.dart | 4 +- lib/core/services/undo_service.dart | 5 +- lib/core/sync/account_sync_manager.dart | 31 +- lib/core/sync/reliability_runner.dart | 13 +- lib/data/db/database.dart | 5 +- lib/data/imap/managesieve_client.dart | 8 +- lib/data/imap/tls_error.dart | 7 +- .../repositories/account_repository_impl.dart | 15 +- .../repositories/draft_repository_impl.dart | 4 +- .../repositories/mailbox_repository_impl.dart | 36 +- lib/di.dart | 14 +- lib/main.dart | 12 +- lib/ui/router.dart | 15 +- lib/ui/screens/account_list_screen.dart | 12 +- lib/ui/screens/add_account_screen.dart | 19 +- lib/ui/screens/changelog_screen.dart | 9 +- lib/ui/screens/compose_screen.dart | 27 +- lib/ui/screens/crash_screen.dart | 18 +- lib/ui/screens/edit_account_screen.dart | 4 +- lib/ui/screens/email_detail_screen.dart | 26 +- lib/ui/screens/email_list_screen.dart | 25 +- lib/ui/screens/search_screen.dart | 13 +- lib/ui/screens/sieve_script_edit_screen.dart | 7 +- lib/ui/screens/sieve_scripts_screen.dart | 31 +- lib/ui/screens/sync_log_screen.dart | 9 +- lib/ui/screens/thread_detail_screen.dart | 4 +- lib/ui/screens/undo_log_screen.dart | 6 +- sharedinbox-runner/Dockerfile | 26 - sharedinbox-runner/README.md | 39 -- sharedinbox-runner/docker-compose.yml | 19 - sharedinbox-runner/sharedinbox-runner.service | 18 - test/integration/concurrent_sync_test.dart | 256 ++++---- .../email_repository_imap_test.dart | 165 +++--- .../email_repository_jmap_test.dart | 107 ++-- test/integration/imap_sync_test.dart | 7 +- .../mailbox_repository_imap_test.dart | 12 +- .../sync_reliability_runner_test.dart | 18 +- test/integration/sync_reliability_test.dart | 56 +- test/unit/account_discovery_service_test.dart | 128 ++-- test/unit/account_repository_impl_test.dart | 10 +- test/unit/connection_test_service_test.dart | 15 +- test/unit/draft_repository_impl_test.dart | 114 ++-- test/unit/email_model_test.dart | 14 +- .../email_repository_cancel_change_test.dart | 6 +- test/unit/email_repository_impl_test.dart | 551 +++++++++--------- test/unit/jmap_client_test.dart | 21 +- test/unit/mailbox_model_test.dart | 5 +- test/unit/mailbox_repository_impl_test.dart | 123 ++-- test/unit/sync_log_repository_impl_test.dart | 5 +- test/unit/undo_logic_test.dart | 79 +-- test/unit/undo_service_test.dart | 30 +- test/widget/account_list_screen_test.dart | 46 +- test/widget/add_account_screen_test.dart | 131 ++--- test/widget/compose_screen_test.dart | 69 ++- test/widget/crash_screen_test.dart | 5 +- test/widget/edit_account_screen_test.dart | 10 +- test/widget/email_detail_screen_test.dart | 52 +- test/widget/email_list_screen_test.dart | 221 ++++--- test/widget/helpers.dart | 14 +- test/widget/mailbox_list_screen_test.dart | 45 +- test/widget/try_connection_button_test.dart | 8 +- 69 files changed, 1417 insertions(+), 1434 deletions(-) delete mode 100644 sharedinbox-runner/Dockerfile delete mode 100644 sharedinbox-runner/README.md delete mode 100644 sharedinbox-runner/docker-compose.yml delete mode 100644 sharedinbox-runner/sharedinbox-runner.service diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b12c8fd..09219ac 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -10,10 +10,15 @@ jobs: name: Full Project Check # Match the label of your self-hosted runner runs-on: self-hosted - + steps: - uses: actions/checkout@v4 + - name: Enable Nix flakes + run: | + mkdir -p ~/.config/nix + echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf + - name: Run Full Check Suite # Using nix develop ensures the runner doesn't need flutter/dart/stalwart installed globally. # 'task check' runs analyze, unit tests, widget tests, and integration tests. @@ -28,6 +33,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Enable Nix flakes + run: | + mkdir -p ~/.config/nix + echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf + - name: Build Linux # The Taskfile task 'build-linux' currently builds --debug. # You can add a 'build-linux-release' task or override it here. diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index d2e5f41..7de814b 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -8,7 +8,7 @@ jobs: deploy-playstore: name: Build & Deploy to Play Store runs-on: self-hosted - if: github.ref == 'refs/heads/main' + if: false steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 85d58b3..27100be 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,4 @@ website/.hugo_build.lock .wget-hsts tmp/ +.claude* diff --git a/Taskfile.yml b/Taskfile.yml index 5d19d28..e5a3e63 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -345,7 +345,7 @@ tasks: check-fast: desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration) - deps: [analyze, test, test-widget, check-hygiene] + deps: [analyze, test, check-hygiene] check-hygiene: desc: Verify that no forbidden files (like home dir config) are tracked @@ -370,7 +370,7 @@ tasks: check: desc: Full check suite — unit tests first, then integration (merges coverage), then gate - deps: [analyze, test-widget, build-linux, test] + deps: [analyze, build-linux, test] cmds: - task: _integrations - task: coverage diff --git a/flake.nix b/flake.nix index 72d57b3..d4ec6af 100644 --- a/flake.nix +++ b/flake.nix @@ -68,6 +68,7 @@ jq sqlite python3 # used by stalwart-dev/start to pick random ports + tea # Gitea CLI ]); shellHook = '' diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index 1574778..88c2262 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -200,10 +200,7 @@ class EmailAddress { } Map toJson() { - return { - if (name != null) 'name': name, - 'email': email, - }; + return {if (name != null) 'name': name, 'email': email}; } @override @@ -316,8 +313,11 @@ class SyncEmailsResult { final int skipped; final int bytesTransferred; - static const zero = - SyncEmailsResult(fetched: 0, skipped: 0, bytesTransferred: 0); + static const zero = SyncEmailsResult( + fetched: 0, + skipped: 0, + bytesTransferred: 0, + ); SyncEmailsResult operator +(SyncEmailsResult other) => SyncEmailsResult( fetched: fetched + other.fetched, diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index a1f0f6b..0314fab 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -21,11 +21,7 @@ abstract class EmailRepository { Future getEmailBody(String emailId); Future syncEmails(String accountId, String mailboxPath); - Future setFlag( - String emailId, { - bool? seen, - bool? flagged, - }); + Future setFlag(String emailId, {bool? seen, bool? flagged}); Future moveEmail(String emailId, String destMailboxPath); /// Deletes the email. Returns the path of the mailbox it was moved to diff --git a/lib/core/services/account_discovery_service.dart b/lib/core/services/account_discovery_service.dart index 6a55395..d032995 100644 --- a/lib/core/services/account_discovery_service.dart +++ b/lib/core/services/account_discovery_service.dart @@ -113,11 +113,10 @@ class AccountDiscoveryServiceImpl implements AccountDiscoveryService { /// well-known endpoint nor the autoconfig XML was found. Future _tryMxFallback(String domain) async { try { - final url = Uri.https( - 'dns.google', - '/resolve', - {'name': domain, 'type': 'MX'}, - ); + final url = Uri.https('dns.google', '/resolve', { + 'name': domain, + 'type': 'MX', + }); final resp = await _client.get(url).timeout(const Duration(seconds: 5)); if (resp.statusCode != 200) return null; diff --git a/lib/core/services/managesieve_probe_service.dart b/lib/core/services/managesieve_probe_service.dart index 2d07b13..51f83e0 100644 --- a/lib/core/services/managesieve_probe_service.dart +++ b/lib/core/services/managesieve_probe_service.dart @@ -23,7 +23,9 @@ Future _defaultManageSieveProbe({ ); try { await client.logout(); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } return true; } catch (e) { log('ManageSieve probe failed for $host:$port — $e'); diff --git a/lib/core/services/undo_service.dart b/lib/core/services/undo_service.dart index 9684f9c..6d81af0 100644 --- a/lib/core/services/undo_service.dart +++ b/lib/core/services/undo_service.dart @@ -68,8 +68,9 @@ class UndoService extends StateNotifier> { final currentPath = cancelled ? action.sourceMailboxPath : (action.destinationMailboxPath ?? action.sourceMailboxPath); - await repo - .restoreEmails([original.copyWith(mailboxPath: currentPath)]); + await repo.restoreEmails([ + original.copyWith(mailboxPath: currentPath), + ]); } // 3. Move it back to source. diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 6829c91..53dd258 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -54,8 +54,13 @@ class AccountSyncManager { _imapConnect, _syncLog, ), - AccountType.jmap => - _JmapAccountSync(account, _mailboxes, _emails, _accounts, _syncLog), + AccountType.jmap => _JmapAccountSync( + account, + _mailboxes, + _emails, + _accounts, + _syncLog, + ), }; _active[account.id] = loop; loop.start(); @@ -144,8 +149,9 @@ class _AccountSync implements _SyncLoop { while (_running) { final startedAt = DateTime.now(); try { - final (_SyncStats stats, String? capturedLog) = - await _runSync(account.verbose); + final (_SyncStats stats, String? capturedLog) = await _runSync( + account.verbose, + ); await _syncLog.log( accountId: account.id, success: true, @@ -239,8 +245,10 @@ class _AccountSync implements _SyncLoop { // Check for expired snoozes and move them back to Inbox before syncing. await _emails.wakeUpEmails(account.id); - final pendingFlushed = - await _emails.flushPendingChanges(account.id, password); + final pendingFlushed = await _emails.flushPendingChanges( + account.id, + password, + ); final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); final mailboxes = await _mailboxes.observeMailboxes(account.id).first; var emailResult = SyncEmailsResult.zero; @@ -359,8 +367,9 @@ class _JmapAccountSync implements _SyncLoop { while (_running) { final startedAt = DateTime.now(); try { - final (_SyncStats stats, String? capturedLog) = - await _runSync(account.verbose); + final (_SyncStats stats, String? capturedLog) = await _runSync( + account.verbose, + ); await _syncLog.log( accountId: account.id, success: true, @@ -456,8 +465,10 @@ class _JmapAccountSync implements _SyncLoop { await _emails.wakeUpEmails(account.id); // Drain outbound queue before pulling from server. - final pendingFlushed = - await _emails.flushPendingChanges(account.id, password); + final pendingFlushed = await _emails.flushPendingChanges( + account.id, + password, + ); final mailboxesSynced = await _mailboxes.syncMailboxes(account.id); diff --git a/lib/core/sync/reliability_runner.dart b/lib/core/sync/reliability_runner.dart index c6baa6d..b6f3bae 100644 --- a/lib/core/sync/reliability_runner.dart +++ b/lib/core/sync/reliability_runner.dart @@ -11,12 +11,7 @@ import 'package:sharedinbox/data/db/database.dart'; /// Periodically verifies local state against the server's "ground truth". /// Results are stored in the [SyncHealth] table. class ReliabilityRunner { - ReliabilityRunner( - this._db, - this._accounts, - this._mailboxes, - this._emails, - ); + ReliabilityRunner(this._db, this._accounts, this._mailboxes, this._emails); final AppDatabase _db; final AccountRepository _accounts; @@ -65,8 +60,10 @@ class ReliabilityRunner { for (final mailbox in mailboxes) { if (!_running) break; - final result = - await _emails.verifySyncReliability(accountId, mailbox.path); + final result = await _emails.verifySyncReliability( + accountId, + mailbox.path, + ); if (!result.isHealthy) { totalMissingLocally += result.missingLocally.length; totalMissingOnServer += result.missingOnServer.length; diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 0013681..3545b46 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -363,8 +363,9 @@ class AppDatabase extends _$AppDatabase { emailIdsJson: Value( jsonEncode(threadEmails.map((e) => e.id).toList()), ), - participantsJson: - Value(latest.fromJson), // Good enough for migration + participantsJson: Value( + latest.fromJson, + ), // Good enough for migration ), ); } diff --git a/lib/data/imap/managesieve_client.dart b/lib/data/imap/managesieve_client.dart index 055004b..4b4a33f 100644 --- a/lib/data/imap/managesieve_client.dart +++ b/lib/data/imap/managesieve_client.dart @@ -79,7 +79,9 @@ class ManageSieveClient { await sub.cancel(); try { await socket.close(); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } rethrow; } } @@ -127,9 +129,7 @@ class ManageSieveClient { await _writeLine('AUTHENTICATE "PLAIN" "$initial"'); final resp = await _readResponse(); if (resp.status != _Status.ok) { - throw ManageSieveException( - 'Authentication failed: ${resp.message}', - ); + throw ManageSieveException('Authentication failed: ${resp.message}'); } } diff --git a/lib/data/imap/tls_error.dart b/lib/data/imap/tls_error.dart index e0da34e..ab12c7e 100644 --- a/lib/data/imap/tls_error.dart +++ b/lib/data/imap/tls_error.dart @@ -24,12 +24,7 @@ class TlsModeMismatchException implements Exception { /// If [error] is a TLS handshake failure caused by a wrong-version-number /// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException] /// with [host]/[port] context. Otherwise rethrow [error] unchanged. -Never rethrowAsTlsHint( - Object error, - StackTrace stack, - String host, - int port, -) { +Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) { if (error.toString().contains('WRONG_VERSION_NUMBER')) { Error.throwWithStackTrace( TlsModeMismatchException(host, port, error), diff --git a/lib/data/repositories/account_repository_impl.dart b/lib/data/repositories/account_repository_impl.dart index b4d0cc2..a2b5423 100644 --- a/lib/data/repositories/account_repository_impl.dart +++ b/lib/data/repositories/account_repository_impl.dart @@ -13,14 +13,17 @@ class AccountRepositoryImpl implements AccountRepository { @override Stream> observeAccounts() { - return _db.select(_db.accounts).watch().map( - (rows) => rows.map(_toModel).toList(), - ); + return _db + .select(_db.accounts) + .watch() + .map((rows) => rows.map(_toModel).toList()); } @override Future getAccount(String id) async { - final row = await (_db.select(_db.accounts)..where((t) => t.id.equals(id))) + final row = await (_db.select( + _db.accounts, + )..where((t) => t.id.equals(id))) .getSingleOrNull(); return row == null ? null : _toModel(row); } @@ -53,7 +56,9 @@ class AccountRepositoryImpl implements AccountRepository { @override Future updateAccount(model.Account account, {String? password}) async { - await (_db.update(_db.accounts)..where((t) => t.id.equals(account.id))) + await (_db.update( + _db.accounts, + )..where((t) => t.id.equals(account.id))) .write( AccountsCompanion( displayName: Value(account.displayName), diff --git a/lib/data/repositories/draft_repository_impl.dart b/lib/data/repositories/draft_repository_impl.dart index 9a12ead..6abf875 100644 --- a/lib/data/repositories/draft_repository_impl.dart +++ b/lib/data/repositories/draft_repository_impl.dart @@ -83,7 +83,9 @@ class DraftRepositoryImpl implements DraftRepository { @override Future getDraft(int id) async { - final row = await (_db.select(_db.drafts)..where((t) => t.id.equals(id))) + final row = await (_db.select( + _db.drafts, + )..where((t) => t.id.equals(id))) .getSingleOrNull(); return row == null ? null : _toModel(row); } diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index 4535ec6..2c26bba 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -72,8 +72,11 @@ class MailboxRepositoryImpl implements MailboxRepository { account_model.Account account, String password, ) async { - final client = - await _imapConnect(account, _effectiveUsername(account), password); + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); try { final mailboxes = await client.listMailboxes(recursive: true); for (final mb in mailboxes) { @@ -83,10 +86,10 @@ class MailboxRepositoryImpl implements MailboxRepository { var unread = 0; var total = 0; try { - final status = await client.statusMailbox( - mb, - [imap.StatusFlags.messages, imap.StatusFlags.unseen], - ); + final status = await client.statusMailbox(mb, [ + imap.StatusFlags.messages, + imap.StatusFlags.unseen, + ]); unread = status.messagesUnseen; total = status.messagesExists; } catch (e) { @@ -145,7 +148,7 @@ class MailboxRepositoryImpl implements MailboxRepository { 'Mailbox/get', {'accountId': jmap.accountId, 'ids': null}, '0', - ] + ], ]); final result = _responseArgs(responses, 0, 'Mailbox/get'); @@ -154,7 +157,9 @@ class MailboxRepositoryImpl implements MailboxRepository { await _upsertJmapMailboxes(accountId, mailboxes); await _saveSyncState(accountId, 'Mailbox', newState); - log('JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState'); + log( + 'JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState', + ); return mailboxes.length; } @@ -169,7 +174,7 @@ class MailboxRepositoryImpl implements MailboxRepository { 'Mailbox/changes', {'accountId': jmap.accountId, 'sinceState': sinceState}, '0', - ] + ], ]); final changes = _responseArgs(responses, 0, 'Mailbox/changes'); @@ -186,7 +191,7 @@ class MailboxRepositoryImpl implements MailboxRepository { 'Mailbox/get', {'accountId': jmap.accountId, 'ids': toFetch}, '1', - ] + ], ]); final getResult = _responseArgs(getResponses, 0, 'Mailbox/get'); await _upsertJmapMailboxes(accountId, getResult['list'] as List); @@ -194,14 +199,17 @@ class MailboxRepositoryImpl implements MailboxRepository { // Remove destroyed mailboxes for (final jmapId in destroyed) { - await (_db.delete(_db.mailboxes) - ..where((t) => t.id.equals('$accountId:$jmapId'))) + await (_db.delete( + _db.mailboxes, + )..where((t) => t.id.equals('$accountId:$jmapId'))) .go(); } await _saveSyncState(accountId, 'Mailbox', newState); - log('JMAP incremental mailbox sync: +${created.length} ' - '~${updated.length} -${destroyed.length}, state=$newState'); + log( + 'JMAP incremental mailbox sync: +${created.length} ' + '~${updated.length} -${destroyed.length}, state=$newState', + ); return toFetch.length + destroyed.length; } diff --git a/lib/di.dart b/lib/di.dart index e65bb67..3f8913b 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -99,7 +99,9 @@ final reliabilityRunnerProvider = Provider((ref) { final syncHealthProvider = StreamProvider.autoDispose.family((ref, accountId) { final db = ref.watch(dbProvider); - return (db.select(db.syncHealth)..where((t) => t.accountId.equals(accountId))) + return (db.select( + db.syncHealth, + )..where((t) => t.accountId.equals(accountId))) .watchSingleOrNull(); }); @@ -115,8 +117,9 @@ final syncManagerProvider = Provider((ref) { return manager; }); -final accountDiscoveryServiceProvider = - Provider((ref) { +final accountDiscoveryServiceProvider = Provider(( + ref, +) { return AccountDiscoveryServiceImpl(ref.watch(httpClientProvider)); }); @@ -135,8 +138,9 @@ final connectionTestServiceProvider = Provider((ref) { ); }); -final manageSieveProbeServiceProvider = - Provider((ref) { +final manageSieveProbeServiceProvider = Provider(( + ref, +) { return ManageSieveProbeService(ref.watch(accountRepositoryProvider)); }); diff --git a/lib/main.dart b/lib/main.dart index abbca5c..fe9e57e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,20 +33,12 @@ void main({List overrides = const []}) async { await initDatabasePath(); runApp( - ProviderScope( - overrides: overrides, - child: const SharedInboxApp(), - ), + ProviderScope(overrides: overrides, child: const SharedInboxApp()), ); }, (error, stack) { // Catch unhandled async errors. - runApp( - CrashScreen( - exception: error, - stackTrace: stack, - ), - ); + runApp(CrashScreen(exception: error, stackTrace: stack)); }, ), ); diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 52ca9ab..69d7eb1 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -49,9 +49,8 @@ final router = GoRouter( ), GoRoute( path: ':accountId/sync-log', - builder: (ctx, state) => SyncLogScreen( - accountId: state.pathParameters['accountId']!, - ), + builder: (ctx, state) => + SyncLogScreen(accountId: state.pathParameters['accountId']!), ), GoRoute( path: ':accountId/sieve', @@ -68,9 +67,8 @@ final router = GoRouter( ), GoRoute( path: ':accountId/search', - builder: (ctx, state) => SearchScreen( - accountId: state.pathParameters['accountId']!, - ), + builder: (ctx, state) => + SearchScreen(accountId: state.pathParameters['accountId']!), ), GoRoute( path: ':accountId/emails/by-address/:address', @@ -116,10 +114,7 @@ final router = GoRouter( ), ], ), - GoRoute( - path: '/search', - builder: (ctx, state) => const SearchScreen(), - ), + GoRoute(path: '/search', builder: (ctx, state) => const SearchScreen()), GoRoute( path: '/compose', builder: (ctx, state) { diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 0323927..692d1f9 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -188,9 +188,9 @@ class _AccountTile extends ConsumerWidget { break; case _AccountAction.verifySync: unawaited( - ProviderScope.containerOf(context) - .read(reliabilityRunnerProvider) - .checkNow(), + ProviderScope.containerOf( + context, + ).read(reliabilityRunnerProvider).checkNow(), ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -225,9 +225,9 @@ class _AccountTile extends ConsumerWidget { ), ); if ((confirmed ?? false) && context.mounted) { - await ProviderScope.containerOf(context) - .read(accountRepositoryProvider) - .removeAccount(account.id); + await ProviderScope.containerOf( + context, + ).read(accountRepositoryProvider).removeAccount(account.id); } } } diff --git a/lib/ui/screens/add_account_screen.dart b/lib/ui/screens/add_account_screen.dart index f225477..039dba3 100644 --- a/lib/ui/screens/add_account_screen.dart +++ b/lib/ui/screens/add_account_screen.dart @@ -235,9 +235,7 @@ class _AddAccountScreenState extends ConsumerState { .addAccount(accountToSave, _passwordCtrl.text); // Probe ManageSieve in the background; the menu starts visible (null) // and disappears on probe failure via the observeAccounts stream. - unawaited( - ref.read(manageSieveProbeServiceProvider).probe(accountToSave), - ); + unawaited(ref.read(manageSieveProbeServiceProvider).probe(accountToSave)); if (mounted) context.pop(); } catch (e) { if (mounted) { @@ -384,10 +382,7 @@ class _AddAccountScreenState extends ConsumerState { onPressed: () => _tryConnection(_jmapFormKey, _buildJmapAccount), ), const SizedBox(height: 8), - FilledButton( - onPressed: _saveJmap, - child: const Text('Save'), - ), + FilledButton(onPressed: _saveJmap, child: const Text('Save')), ], ), ), @@ -439,10 +434,7 @@ class _AddAccountScreenState extends ConsumerState { onPressed: () => _tryConnection(_imapFormKey, _buildImapAccount), ), const SizedBox(height: 8), - FilledButton( - onPressed: _saveImap, - child: const Text('Save'), - ), + FilledButton(onPressed: _saveImap, child: const Text('Save')), ], ), ), @@ -461,10 +453,7 @@ class _AddAccountScreenState extends ConsumerState { _emailCtrl.text.trim(), style: Theme.of(context).textTheme.titleMedium, ), - Text( - accountTypeLabel, - style: Theme.of(context).textTheme.bodySmall, - ), + Text(accountTypeLabel, style: Theme.of(context).textTheme.bodySmall), ], ), ); diff --git a/lib/ui/screens/changelog_screen.dart b/lib/ui/screens/changelog_screen.dart index 357a530..e607a3d 100644 --- a/lib/ui/screens/changelog_screen.dart +++ b/lib/ui/screens/changelog_screen.dart @@ -11,9 +11,7 @@ class ChangeLogScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('ChangeLog'), - ), + appBar: AppBar(title: const Text('ChangeLog')), body: FutureBuilder( future: rootBundle.loadString('assets/changelog.txt'), builder: (context, snapshot) { @@ -39,10 +37,7 @@ class ChangeLogScreen extends StatelessWidget { } }, styleSheet: MarkdownStyleSheet( - p: const TextStyle( - fontFamily: 'monospace', - fontSize: 13, - ), + p: const TextStyle(fontFamily: 'monospace', fontSize: 13), ), ); }, diff --git a/lib/ui/screens/compose_screen.dart b/lib/ui/screens/compose_screen.dart index 2720abb..6a99df9 100644 --- a/lib/ui/screens/compose_screen.dart +++ b/lib/ui/screens/compose_screen.dart @@ -190,9 +190,9 @@ class _ComposeScreenState extends ConsumerState { await OpenFilex.open(path); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to open file: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to open file: $e'))); } finally { if (mounted) setState(() => _opening = false); } @@ -204,9 +204,9 @@ class _ComposeScreenState extends ConsumerState { Future _send() async { if (_accountId == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Select an account first')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Select an account first'))); return; } setState(() => _sending = true); @@ -229,8 +229,9 @@ class _ComposeScreenState extends ConsumerState { .toList(), subject: _subject.text, body: _body.text, - attachmentFilePaths: - List.unmodifiable(_attachments.map((a) => a.path).toList()), + attachmentFilePaths: List.unmodifiable( + _attachments.map((a) => a.path).toList(), + ), ); await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft); // Delete the draft only after a successful send. @@ -240,8 +241,9 @@ class _ComposeScreenState extends ConsumerState { if (mounted) context.pop(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Send failed: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Send failed: $e'))); } finally { if (mounted) setState(() => _sending = false); } @@ -257,10 +259,7 @@ class _ComposeScreenState extends ConsumerState { const Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: Center( - child: Text( - 'Saved', - style: TextStyle(fontSize: 12), - ), + child: Text('Saved', style: TextStyle(fontSize: 12)), ), ), IconButton( diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 20575c3..c511479 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -25,11 +25,7 @@ class CrashScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Icon( - Icons.error_outline, - color: Colors.red, - size: 64, - ), + const Icon(Icons.error_outline, color: Colors.red, size: 64), const SizedBox(height: 16), Text( 'SharedInbox encountered an unexpected error and needs to be restarted.', @@ -68,8 +64,10 @@ class CrashScreen extends StatelessWidget { ), child: Text( stackTrace.toString(), - style: - const TextStyle(fontFamily: 'monospace', fontSize: 10), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 10, + ), ), ), ], @@ -113,9 +111,9 @@ class CrashScreen extends StatelessWidget { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } }, diff --git a/lib/ui/screens/edit_account_screen.dart b/lib/ui/screens/edit_account_screen.dart index ad89ae1..30f6c7d 100644 --- a/lib/ui/screens/edit_account_screen.dart +++ b/lib/ui/screens/edit_account_screen.dart @@ -213,9 +213,7 @@ class _EditAccountScreenState extends ConsumerState { // Re-probe when the cached availability was cleared above. if (updated.type == AccountType.imap && updated.manageSieveAvailable == null) { - unawaited( - ref.read(manageSieveProbeServiceProvider).probe(updated), - ); + unawaited(ref.read(manageSieveProbeServiceProvider).probe(updated)); } if (mounted) context.pop(); } catch (e) { diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 6af775d..bbbf051 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -169,10 +169,7 @@ class _EmailDetailScreenState extends ConsumerState { return ListView( padding: const EdgeInsets.all(16), children: [ - if (header != null) ...[ - _buildHeader(ctx, header), - const Divider(), - ], + if (header != null) ...[_buildHeader(ctx, header), const Divider()], if (hasHtml) ...[ if (!_loadRemoteImages) Align( @@ -188,9 +185,7 @@ class _EmailDetailScreenState extends ConsumerState { ), Html( data: body.htmlBody!, - extensions: [ - if (!_loadRemoteImages) _BlockRemoteImagesExtension(), - ], + extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()], ), ] else SelectableText( @@ -238,9 +233,9 @@ class _EmailDetailScreenState extends ConsumerState { await OpenFilex.open(path); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Opening file failed: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Opening file failed: $e'))); } finally { if (mounted) setState(() => _downloading.remove(att.filename)); } @@ -430,8 +425,10 @@ class _EmailDetailScreenState extends ConsumerState { color: i.isEven ? Theme.of(ctx).colorScheme.surfaceContainerHighest : Theme.of(ctx).colorScheme.surface, - padding: - const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -442,10 +439,7 @@ class _EmailDetailScreenState extends ConsumerState { ), ), const SizedBox(width: 8), - Expanded( - flex: 2, - child: SelectableText(header.value), - ), + Expanded(flex: 2, child: SelectableText(header.value)), ], ), ); diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index a4f1685..7477721 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -104,11 +104,9 @@ class _EmailListScreenState extends ConsumerState { } setState(() => _searchLoading = true); try { - final results = await ref.read(emailRepositoryProvider).searchEmails( - widget.accountId, - widget.mailboxPath, - query.trim(), - ); + final results = await ref + .read(emailRepositoryProvider) + .searchEmails(widget.accountId, widget.mailboxPath, query.trim()); if (mounted) setState(() => _searchResults = results); } finally { if (mounted) setState(() => _searchLoading = false); @@ -182,9 +180,9 @@ class _EmailListScreenState extends ConsumerState { ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Sync failed: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Sync failed: $e'))); } }, ), @@ -288,10 +286,7 @@ class _EmailListScreenState extends ConsumerState { if (threads.isEmpty) { return ListView( children: const [ - SizedBox( - height: 300, - child: Center(child: Text('No emails')), - ), + SizedBox(height: 300, child: Center(child: Text('No emails'))), ], ); } @@ -309,9 +304,9 @@ class _EmailListScreenState extends ConsumerState { .findMailboxByRole(widget.accountId, role); if (!mounted) return; if (mailbox == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(notFoundMessage)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(notFoundMessage))); return; } final repo = ref.read(emailRepositoryProvider); diff --git a/lib/ui/screens/search_screen.dart b/lib/ui/screens/search_screen.dart index 6545e0d..1b7ea55 100644 --- a/lib/ui/screens/search_screen.dart +++ b/lib/ui/screens/search_screen.dart @@ -78,8 +78,11 @@ class _SearchScreenState extends ConsumerState { .where( (e) => e.accountId == accId && - [...e.from, ...e.to, ...e.cc] - .any((a) => a.email == addrEmail), + [ + ...e.from, + ...e.to, + ...e.cc, + ].any((a) => a.email == addrEmail), ) .length; addresses.add((addr, count, accId)); @@ -147,11 +150,7 @@ class _SearchScreenState extends ConsumerState { if (r.addresses.isNotEmpty) ...[ const _SectionHeader('Addresses'), for (final (addr, count, accId) in r.addresses) - _AddressTile( - addr: addr, - count: count, - accountId: accId, - ), + _AddressTile(addr: addr, count: count, accountId: accId), ], if (r.emails.isNotEmpty) ...[ const _SectionHeader('Messages'), diff --git a/lib/ui/screens/sieve_script_edit_screen.dart b/lib/ui/screens/sieve_script_edit_screen.dart index 52f9774..83e7023 100644 --- a/lib/ui/screens/sieve_script_edit_screen.dart +++ b/lib/ui/screens/sieve_script_edit_screen.dart @@ -50,10 +50,9 @@ class _SieveScriptEditScreenState extends ConsumerState { Future _loadContent() async { setState(() => _loadingContent = true); try { - final content = await ref.read(sieveRepositoryProvider).getScriptContent( - widget.accountId, - widget.script!.blobId, - ); + final content = await ref + .read(sieveRepositoryProvider) + .getScriptContent(widget.accountId, widget.script!.blobId); if (mounted) { _contentController.text = content; setState(() => _loadingContent = false); diff --git a/lib/ui/screens/sieve_scripts_screen.dart b/lib/ui/screens/sieve_scripts_screen.dart index 03af6b0..b6815ab 100644 --- a/lib/ui/screens/sieve_scripts_screen.dart +++ b/lib/ui/screens/sieve_scripts_screen.dart @@ -59,9 +59,9 @@ class _SieveScriptsScreenState extends ConsumerState { await _load(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to activate: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to activate: $e'))); } } } @@ -92,9 +92,9 @@ class _SieveScriptsScreenState extends ConsumerState { await _load(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to delete: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to delete: $e'))); } } } @@ -106,9 +106,7 @@ class _SieveScriptsScreenState extends ConsumerState { body: _buildBody(), floatingActionButton: FloatingActionButton( onPressed: () async { - await context.push( - '/accounts/${widget.accountId}/sieve/edit', - ); + await context.push('/accounts/${widget.accountId}/sieve/edit'); await _load(); }, child: const Icon(Icons.add), @@ -129,10 +127,7 @@ class _SieveScriptsScreenState extends ConsumerState { children: [ Text(_error!, style: const TextStyle(color: Colors.red)), const SizedBox(height: 12), - FilledButton( - onPressed: _load, - child: const Text('Retry'), - ), + FilledButton(onPressed: _load, child: const Text('Retry')), ], ), ), @@ -200,10 +195,7 @@ class _ScriptTile extends StatelessWidget { } }, itemBuilder: (_) => [ - const PopupMenuItem( - value: _ScriptAction.edit, - child: Text('Edit'), - ), + const PopupMenuItem(value: _ScriptAction.edit, child: Text('Edit')), if (!script.isActive) const PopupMenuItem( value: _ScriptAction.activate, @@ -217,10 +209,7 @@ class _ScriptTile extends StatelessWidget { ], ), onTap: () async { - await context.push( - '/accounts/$accountId/sieve/edit', - extra: script, - ); + await context.push('/accounts/$accountId/sieve/edit', extra: script); onEdited(); }, ); diff --git a/lib/ui/screens/sync_log_screen.dart b/lib/ui/screens/sync_log_screen.dart index 47c902a..d8a3220 100644 --- a/lib/ui/screens/sync_log_screen.dart +++ b/lib/ui/screens/sync_log_screen.dart @@ -125,10 +125,7 @@ class _SyncLogTile extends StatelessWidget { entry.isOk ? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel' : 'Error · took $durationLabel', - style: TextStyle( - fontSize: 12, - color: entry.isOk ? null : errorColor, - ), + style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor), ), children: [ Padding( @@ -211,9 +208,7 @@ class _SyncLogTile extends StatelessWidget { style: const TextStyle(fontSize: 12, color: Colors.grey), ), ), - Expanded( - child: Text(value, style: const TextStyle(fontSize: 12)), - ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 12))), ], ), ); diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index 0c3eb4d..a97a7f6 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -30,9 +30,7 @@ class ThreadDetailScreen extends ConsumerWidget { final repo = ref.watch(emailRepositoryProvider); return Scaffold( - appBar: AppBar( - title: const Text('Thread'), - ), + appBar: AppBar(title: const Text('Thread')), body: StreamBuilder>( stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), builder: (context, snapshot) { diff --git a/lib/ui/screens/undo_log_screen.dart b/lib/ui/screens/undo_log_screen.dart index 8ae4269..cf8c09f 100644 --- a/lib/ui/screens/undo_log_screen.dart +++ b/lib/ui/screens/undo_log_screen.dart @@ -81,9 +81,9 @@ class _UndoActionTile extends ConsumerWidget { .read(undoServiceProvider.notifier) .undo(actionId: action.id); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Action undone.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Action undone.'))); } }, child: const Text('Undo'), diff --git a/sharedinbox-runner/Dockerfile b/sharedinbox-runner/Dockerfile deleted file mode 100644 index 26e70f8..0000000 --- a/sharedinbox-runner/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Dockerfile for a Codeberg Runner with Nix and a non-root worker -FROM gitea/act_runner:latest-ubuntu - -# Install Nix requirements and basic tools -RUN apt-get update && apt-get install -y curl xz-utils sudo && rm -rf /var/lib/apt/lists/* - -# Install Nix in single-user mode -RUN curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux \ - --init none \ - --no-confirm - -ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}" -RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf - -# Create a restricted 'worker' user for running the actual CI jobs -RUN useradd -m -s /bin/bash worker && \ - mkdir -p /home/worker && \ - chown -R worker:worker /home/worker - -# Allow the worker user to use Nix -RUN chown -R worker:worker /nix/var/nix/profiles/per-user/worker || true && \ - chmod -R 777 /nix/store /nix/var/nix/db - -# We still start as root so the act_runner entrypoint can initialize, -# but the 'act_runner' is configured to run jobs as a specific user if requested. -# However, by default, act_runner executes inside this container. diff --git a/sharedinbox-runner/README.md b/sharedinbox-runner/README.md deleted file mode 100644 index 720f476..0000000 --- a/sharedinbox-runner/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# SharedInbox CI Runner - -This directory contains the configuration for a self-hosted Codeberg (Forgejo) Actions runner. - -## Strategy: "Thin CI, Heavy Taskfile" -We use a self-hosted runner to bypass the resource limits of hosted CI. The CI workflow is a thin wrapper that invokes `nix develop --command task check`. This ensures that CI environments are identical to local development environments. - -## Prerequisites -- Docker and Docker Compose installed. -- Systemd (for persistence). -- A registration token from Codeberg (Settings > Actions > Runners). -- A `.env` file in the project root containing `CODEBERG_CI_RUNNER_TOKEN`. - -## Installation & Setup - -Run these commands as a user with `sudo` to install the runner on your laptop or VPS: - -```bash -# 1. Create the system directory -sudo mkdir -p /opt/sharedinbox-runner - -# 2. Copy the runner configuration and your .env file -# (Run this from the root of your local sharedinbox3 project) -sudo cp -r sharedinbox-runner /opt/sharedinbox-runner/ -sudo cp .env /opt/sharedinbox-runner/ - -# 3. Install the systemd service -sudo cp /opt/sharedinbox-runner/sharedinbox-runner/sharedinbox-runner.service /etc/systemd/system/ - -# 4. Reload systemd and start the service -sudo systemctl daemon-reload -sudo systemctl enable --now sharedinbox-runner.service -``` - -## Management -- **Check status:** `systemctl status sharedinbox-runner.service` -- **View logs:** `journalctl -u sharedinbox-runner.service -f` -- **Restart:** `sudo systemctl restart sharedinbox-runner.service` -- **Stop:** `sudo systemctl stop sharedinbox-runner.service` diff --git a/sharedinbox-runner/docker-compose.yml b/sharedinbox-runner/docker-compose.yml deleted file mode 100644 index c5529e8..0000000 --- a/sharedinbox-runner/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -services: - runner: - build: - context: . - dockerfile: Dockerfile - restart: always - env_file: - - ../.env - environment: - - GITEA_INSTANCE_URL=${CODEBERG_INSTANCE_URL:-https://codeberg.org} - - GITEA_RUNNER_REGISTRATION_TOKEN=${CODEBERG_CI_RUNNER_TOKEN} - - GITEA_RUNNER_NAME=${CODEBERG_RUNNER_NAME:-laptop-runner} - - GITEA_RUNNER_LABELS=${CODEBERG_RUNNER_LABELS:-self-hosted,linux,nix} - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./runner-data:/data - # Use host network if you want to access local services easily, - # but for most cases the default bridge is fine. - # network_mode: host diff --git a/sharedinbox-runner/sharedinbox-runner.service b/sharedinbox-runner/sharedinbox-runner.service deleted file mode 100644 index 9336b27..0000000 --- a/sharedinbox-runner/sharedinbox-runner.service +++ /dev/null @@ -1,18 +0,0 @@ -[Unit] -Description=SharedInbox CI Runner (Docker Compose) -Requires=docker.service -After=docker.service network-online.target - -[Service] -Type=simple -User=root -Group=root -WorkingDirectory=/ -ExecStartPre=-/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml down -ExecStart=/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml up --build -ExecStop=/usr/bin/docker compose -f /opt/sharedinbox-runner/sharedinbox-runner/docker-compose.yml down -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/test/integration/concurrent_sync_test.dart b/test/integration/concurrent_sync_test.dart index 0c1366d..1eda29f 100644 --- a/test/integration/concurrent_sync_test.dart +++ b/test/integration/concurrent_sync_test.dart @@ -126,144 +126,148 @@ void main() { await db.close(); }); - test('concurrent IMAP + JMAP sync caches all emails without errors', - timeout: const Timeout(Duration(seconds: 30)), () async { - final ts = DateTime.now().millisecondsSinceEpoch; - const msgCount = 2; + test( + 'concurrent IMAP + JMAP sync caches all emails without errors', + timeout: const Timeout(Duration(seconds: 30)), + () async { + final ts = DateTime.now().millisecondsSinceEpoch; + const msgCount = 2; - // ── 1. Send emails in both directions ───────────────────────────────────── - // alice → bob (alice uses IMAP; bob uses JMAP) - // bob → alice (cross-direction) - for (var i = 0; i < msgCount; i++) { - await _sendMessage( - host: imapHost, - port: smtpPort, - from: aliceUser, - pass: alicePass, - to: bobUser, - subject: 'alice-to-bob-$ts-$i', + // ── 1. Send emails in both directions ───────────────────────────────────── + // alice → bob (alice uses IMAP; bob uses JMAP) + // bob → alice (cross-direction) + for (var i = 0; i < msgCount; i++) { + await _sendMessage( + host: imapHost, + port: smtpPort, + from: aliceUser, + pass: alicePass, + to: bobUser, + subject: 'alice-to-bob-$ts-$i', + ); + await _sendMessage( + host: imapHost, + port: smtpPort, + from: bobUser, + pass: bobPass, + to: aliceUser, + subject: 'bob-to-alice-$ts-$i', + ); + } + // Give Stalwart a moment to deliver all messages. + await Future.delayed(const Duration(milliseconds: 800)); + + // ── 2. Insert accounts ───────────────────────────────────────────────────── + final aliceAccount = model.Account( + id: 'alice', + displayName: 'Alice', + email: aliceUser, + imapHost: imapHost, + imapPort: imapPort, + imapSsl: false, + smtpHost: imapHost, + smtpPort: smtpPort, ); - await _sendMessage( - host: imapHost, - port: smtpPort, - from: bobUser, - pass: bobPass, - to: aliceUser, - subject: 'bob-to-alice-$ts-$i', + final bobAccount = model.Account( + id: 'bob', + displayName: 'Bob', + email: bobUser, + type: model.AccountType.jmap, + jmapUrl: '$jmapUrl/.well-known/jmap', + smtpHost: imapHost, + smtpPort: smtpPort, ); - } - // Give Stalwart a moment to deliver all messages. - await Future.delayed(const Duration(milliseconds: 800)); - // ── 2. Insert accounts ───────────────────────────────────────────────────── - final aliceAccount = model.Account( - id: 'alice', - displayName: 'Alice', - email: aliceUser, - imapHost: imapHost, - imapPort: imapPort, - imapSsl: false, - smtpHost: imapHost, - smtpPort: smtpPort, - ); - final bobAccount = model.Account( - id: 'bob', - displayName: 'Bob', - email: bobUser, - type: model.AccountType.jmap, - jmapUrl: '$jmapUrl/.well-known/jmap', - smtpHost: imapHost, - smtpPort: smtpPort, - ); + await accounts.addAccount(aliceAccount, alicePass); + await accounts.addAccount(bobAccount, bobPass); - await accounts.addAccount(aliceAccount, alicePass); - await accounts.addAccount(bobAccount, bobPass); + final httpClient = http.Client(); + addTearDown(httpClient.close); - final httpClient = http.Client(); - addTearDown(httpClient.close); + final mailboxRepo = MailboxRepositoryImpl( + db, + accounts, + imapConnect: _connectImapPlaintext, + httpClient: httpClient, + ); + final emailRepo = EmailRepositoryImpl( + db, + accounts, + imapConnect: _connectImapPlaintext, + httpClient: httpClient, + ); - final mailboxRepo = MailboxRepositoryImpl( - db, - accounts, - imapConnect: _connectImapPlaintext, - httpClient: httpClient, - ); - final emailRepo = EmailRepositoryImpl( - db, - accounts, - imapConnect: _connectImapPlaintext, - httpClient: httpClient, - ); - - // ── 3. Sync mailboxes concurrently ───────────────────────────────────────── - await Future.wait([ - mailboxRepo.syncMailboxes('alice'), - mailboxRepo.syncMailboxes('bob'), - ]); - - final allMailboxes = await db.select(db.mailboxes).get(); - expect( - allMailboxes, - isNotEmpty, - reason: 'mailboxes should be cached after sync', - ); - - // Grab INBOX paths for each account. - // IMAP: path is the mailbox path string (e.g. "INBOX"). - // JMAP: path is the server-assigned JMAP mailbox ID; match by role or name. - final aliceInbox = allMailboxes - .firstWhere( - (m) => m.accountId == 'alice' && m.path.toUpperCase() == 'INBOX', - ) - .path; - final bobInbox = allMailboxes - .firstWhere( - (m) => - m.accountId == 'bob' && - (m.role == 'inbox' || m.name.toLowerCase() == 'inbox'), - ) - .path; - - // ── 4. Sync emails concurrently — run twice to exercise incremental sync ─── - for (var round = 0; round < 2; round++) { + // ── 3. Sync mailboxes concurrently ───────────────────────────────────────── await Future.wait([ - emailRepo.syncEmails('alice', aliceInbox), - emailRepo.syncEmails('bob', bobInbox), + mailboxRepo.syncMailboxes('alice'), + mailboxRepo.syncMailboxes('bob'), ]); - } - // ── 5. Verify DB consistency ─────────────────────────────────────────────── - final allEmails = await db.select(db.emails).get(); + final allMailboxes = await db.select(db.mailboxes).get(); + expect( + allMailboxes, + isNotEmpty, + reason: 'mailboxes should be cached after sync', + ); - // No duplicate email IDs. - final ids = allEmails.map((e) => e.id).toList(); - expect( - ids.toSet().length, - equals(ids.length), - reason: 'duplicate email IDs in DB', - ); + // Grab INBOX paths for each account. + // IMAP: path is the mailbox path string (e.g. "INBOX"). + // JMAP: path is the server-assigned JMAP mailbox ID; match by role or name. + final aliceInbox = allMailboxes + .firstWhere( + (m) => m.accountId == 'alice' && m.path.toUpperCase() == 'INBOX', + ) + .path; + final bobInbox = allMailboxes + .firstWhere( + (m) => + m.accountId == 'bob' && + (m.role == 'inbox' || m.name.toLowerCase() == 'inbox'), + ) + .path; - // Alice and bob each received at least msgCount messages. - final aliceEmails = allEmails.where((e) => e.accountId == 'alice').toList(); - final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList(); - expect( - aliceEmails.length, - greaterThanOrEqualTo(msgCount), - reason: "alice's inbox should contain synced emails", - ); - expect( - bobEmails.length, - greaterThanOrEqualTo(msgCount), - reason: "bob's inbox should contain synced emails", - ); + // ── 4. Sync emails concurrently — run twice to exercise incremental sync ─── + for (var round = 0; round < 2; round++) { + await Future.wait([ + emailRepo.syncEmails('alice', aliceInbox), + emailRepo.syncEmails('bob', bobInbox), + ]); + } - // All rows have a non-empty account ID. - for (final e in allEmails) { - expect(e.accountId, isNotEmpty); - } + // ── 5. Verify DB consistency ─────────────────────────────────────────────── + final allEmails = await db.select(db.emails).get(); - // No pending changes left in the queue. - final pending = await db.select(db.pendingChanges).get(); - expect(pending, isEmpty, reason: 'no outbound mutations expected'); - }); + // No duplicate email IDs. + final ids = allEmails.map((e) => e.id).toList(); + expect( + ids.toSet().length, + equals(ids.length), + reason: 'duplicate email IDs in DB', + ); + + // Alice and bob each received at least msgCount messages. + final aliceEmails = + allEmails.where((e) => e.accountId == 'alice').toList(); + final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList(); + expect( + aliceEmails.length, + greaterThanOrEqualTo(msgCount), + reason: "alice's inbox should contain synced emails", + ); + expect( + bobEmails.length, + greaterThanOrEqualTo(msgCount), + reason: "bob's inbox should contain synced emails", + ); + + // All rows have a non-empty account ID. + for (final e in allEmails) { + expect(e.accountId, isNotEmpty); + } + + // No pending changes left in the queue. + final pending = await db.select(db.pendingChanges).get(); + expect(pending, isEmpty, reason: 'no outbound mutations expected'); + }, + ); } diff --git a/test/integration/email_repository_imap_test.dart b/test/integration/email_repository_imap_test.dart index b5c9e07..c2b3f96 100644 --- a/test/integration/email_repository_imap_test.dart +++ b/test/integration/email_repository_imap_test.dart @@ -30,8 +30,9 @@ Future _imapConnect({ required String user, required String pass, }) async { - final client = - ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); + final client = ImapClient( + defaultResponseTimeout: const Duration(seconds: 20), + ); await client.connectToServer(host, port, isSecure: false); await client.login(user, pass); return client; @@ -113,8 +114,9 @@ void main() { String username, String password, ) async { - final client = - ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); + final client = ImapClient( + defaultResponseTimeout: const Duration(seconds: 20), + ); await client.connectToServer(a.imapHost, a.imapPort, isSecure: false); await client.login(username, password); return client; @@ -200,44 +202,47 @@ void main() { }); test( - 'syncEmails incremental sync fetches only messages newer than checkpoint', - () async { - await appendToInbox('first'); + 'syncEmails incremental sync fetches only messages newer than checkpoint', + () async { + await appendToInbox('first'); - final r = makeRepo(); - await r.accounts.addAccount(account, userPass); - await r.emails.syncEmails('test', 'INBOX'); + final r = makeRepo(); + await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); - final afterFirst = await r.emails.observeEmails('test', 'INBOX').first; - expect(afterFirst, hasLength(1)); - expect(afterFirst.first.subject, 'first'); + final afterFirst = await r.emails.observeEmails('test', 'INBOX').first; + expect(afterFirst, hasLength(1)); + expect(afterFirst.first.subject, 'first'); - await appendToInbox('second'); - await r.emails.syncEmails('test', 'INBOX'); + await appendToInbox('second'); + await r.emails.syncEmails('test', 'INBOX'); - final afterSecond = await r.emails.observeEmails('test', 'INBOX').first; - expect(afterSecond, hasLength(2)); - expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'}); - }); + final afterSecond = await r.emails.observeEmails('test', 'INBOX').first; + expect(afterSecond, hasLength(2)); + expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'}); + }, + ); - test('CONDSTORE fast-path: second sync skips fetch when nothing changed', - () async { - await appendToInbox('condstore-test'); + test( + 'CONDSTORE fast-path: second sync skips fetch when nothing changed', + () async { + await appendToInbox('condstore-test'); - final r = makeRepo(); - await r.accounts.addAccount(account, userPass); + final r = makeRepo(); + await r.accounts.addAccount(account, userPass); - // First sync — full sync, saves modseq checkpoint. - await r.emails.syncEmails('test', 'INBOX'); - final stateAfterFirst = await r.db.select(r.db.syncStates).get(); - expect(stateAfterFirst, hasLength(1)); + // First sync — full sync, saves modseq checkpoint. + await r.emails.syncEmails('test', 'INBOX'); + final stateAfterFirst = await r.db.select(r.db.syncStates).get(); + expect(stateAfterFirst, hasLength(1)); - // Second sync with no server changes — CONDSTORE fast-path should skip - // fetching. DB email count must stay the same. - await r.emails.syncEmails('test', 'INBOX'); - final emails = await r.emails.observeEmails('test', 'INBOX').first; - expect(emails, hasLength(1)); - }); + // Second sync with no server changes — CONDSTORE fast-path should skip + // fetching. DB email count must stay the same. + await r.emails.syncEmails('test', 'INBOX'); + final emails = await r.emails.observeEmails('test', 'INBOX').first; + expect(emails, hasLength(1)); + }, + ); test('CONDSTORE flag refresh updates flags in local DB', () async { await appendToInbox('flag-refresh-test'); @@ -258,10 +263,7 @@ void main() { ); try { await imap.selectMailboxByPath('INBOX'); - final seq = MessageSequence.fromIds( - [emails.first.uid], - isUid: true, - ); + final seq = MessageSequence.fromIds([emails.first.uid], isUid: true); await imap.uidMarkSeen(seq); } finally { await imap.logout(); @@ -330,53 +332,57 @@ void main() { expect(cached.textBody, body.textBody); }); - test('blob expiry: re-fetches body when cachedAt is null (legacy row)', - () async { - await appendToInbox('legacy-body-test', body: 'Fresh from server'); + test( + 'blob expiry: re-fetches body when cachedAt is null (legacy row)', + () async { + await appendToInbox('legacy-body-test', body: 'Fresh from server'); - final r = makeRepo(); - await r.accounts.addAccount(account, userPass); - await r.emails.syncEmails('test', 'INBOX'); + final r = makeRepo(); + await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); - final emails = await r.emails.observeEmails('test', 'INBOX').first; - final emailId = emails.first.id; + final emails = await r.emails.observeEmails('test', 'INBOX').first; + final emailId = emails.first.id; - // Simulate a legacy row with no cachedAt. - await r.db.into(r.db.emailBodies).insertOnConflictUpdate( - EmailBodiesCompanion.insert( - emailId: emailId, - textBody: const Value('stale text'), - cachedAt: const Value(null), - ), - ); + // Simulate a legacy row with no cachedAt. + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( + EmailBodiesCompanion.insert( + emailId: emailId, + textBody: const Value('stale text'), + cachedAt: const Value(null), + ), + ); - final body = await r.emails.getEmailBody(emailId); - expect(body.textBody, contains('Fresh from server')); - }); + final body = await r.emails.getEmailBody(emailId); + expect(body.textBody, contains('Fresh from server')); + }, + ); - test('blob expiry: re-fetches body when cachedAt is older than 7 days', - () async { - await appendToInbox('old-body-test', body: 'Current content'); + test( + 'blob expiry: re-fetches body when cachedAt is older than 7 days', + () async { + await appendToInbox('old-body-test', body: 'Current content'); - final r = makeRepo(); - await r.accounts.addAccount(account, userPass); - await r.emails.syncEmails('test', 'INBOX'); + final r = makeRepo(); + await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); - final emails = await r.emails.observeEmails('test', 'INBOX').first; - final emailId = emails.first.id; + final emails = await r.emails.observeEmails('test', 'INBOX').first; + final emailId = emails.first.id; - // Simulate a row cached 8 days ago. - await r.db.into(r.db.emailBodies).insertOnConflictUpdate( - EmailBodiesCompanion.insert( - emailId: emailId, - textBody: const Value('old text'), - cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))), - ), - ); + // Simulate a row cached 8 days ago. + await r.db.into(r.db.emailBodies).insertOnConflictUpdate( + EmailBodiesCompanion.insert( + emailId: emailId, + textBody: const Value('old text'), + cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))), + ), + ); - final body = await r.emails.getEmailBody(emailId); - expect(body.textBody, contains('Current content')); - }); + final body = await r.emails.getEmailBody(emailId); + expect(body.textBody, contains('Current content')); + }, + ); test('sendEmail delivers via SMTP and appends copy to Sent folder', () async { final subject = 'send-${DateTime.now().millisecondsSinceEpoch}'; @@ -426,8 +432,11 @@ void main() { final r = makeRepo(); await r.accounts.addAccount(account, userPass); - final results = - await r.emails.searchEmails('test', 'INBOX', 'xyzzy-no-match'); + final results = await r.emails.searchEmails( + 'test', + 'INBOX', + 'xyzzy-no-match', + ); expect(results, isEmpty); }); diff --git a/test/integration/email_repository_jmap_test.dart b/test/integration/email_repository_jmap_test.dart index ca9a240..8cc015b 100644 --- a/test/integration/email_repository_jmap_test.dart +++ b/test/integration/email_repository_jmap_test.dart @@ -31,8 +31,9 @@ Future _imapConnect({ required String user, required String pass, }) async { - final client = - ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); + final client = ImapClient( + defaultResponseTimeout: const Duration(seconds: 20), + ); await client.connectToServer(host, port, isSecure: false); await client.login(user, pass); return client; @@ -172,24 +173,26 @@ void main() { expect(emails.first.isSeen, isFalse); }); - test('syncEmails saves state and incremental sync picks up new messages', - () async { - final r = makeRepo(); - final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes); + test( + 'syncEmails saves state and incremental sync picks up new messages', + () async { + final r = makeRepo(); + final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes); - await appendToInbox('first'); - await r.emails.syncEmails('test-jmap', inboxId); - expect( - await r.emails.observeEmails('test-jmap', inboxId).first, - hasLength(1), - ); + await appendToInbox('first'); + await r.emails.syncEmails('test-jmap', inboxId); + expect( + await r.emails.observeEmails('test-jmap', inboxId).first, + hasLength(1), + ); - await appendToInbox('second'); - await r.emails.syncEmails('test-jmap', inboxId); - final emails = await r.emails.observeEmails('test-jmap', inboxId).first; - expect(emails, hasLength(2)); - expect(emails.map((e) => e.subject).toSet(), {'first', 'second'}); - }); + await appendToInbox('second'); + await r.emails.syncEmails('test-jmap', inboxId); + final emails = await r.emails.observeEmails('test-jmap', inboxId).first; + expect(emails, hasLength(2)); + expect(emails.map((e) => e.subject).toSet(), {'first', 'second'}); + }, + ); test('syncEmails removes email deleted on server from local DB', () async { await appendToInbox('keep'); @@ -247,42 +250,44 @@ void main() { expect(cached.textBody, body.textBody); }); - test('sendEmail submits via JMAP EmailSubmission and creates Sent copy', - () async { - final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}'; - final r = makeRepo(); - await r.accounts.addAccount(account, userPass); - await r.mailboxes.syncMailboxes('test-jmap'); + test( + 'sendEmail submits via JMAP EmailSubmission and creates Sent copy', + () async { + final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}'; + final r = makeRepo(); + await r.accounts.addAccount(account, userPass); + await r.mailboxes.syncMailboxes('test-jmap'); - await r.emails.sendEmail( - 'test-jmap', - EmailDraft( - from: EmailAddress(name: 'Alice', email: userEmail), - to: [EmailAddress(name: 'Alice', email: userEmail)], - cc: [], - subject: subject, - body: 'Integration test message via JMAP', - ), - ); + await r.emails.sendEmail( + 'test-jmap', + EmailDraft( + from: EmailAddress(name: 'Alice', email: userEmail), + to: [EmailAddress(name: 'Alice', email: userEmail)], + cc: [], + subject: subject, + body: 'Integration test message via JMAP', + ), + ); - // A sent copy should appear in the Sent mailbox. - final sentRow = await (r.db.select(r.db.mailboxes) - ..where( - (t) => t.accountId.equals('test-jmap') & t.role.equals('sent'), - ) - ..limit(1)) - .getSingleOrNull(); - final sentId = sentRow?.path; + // A sent copy should appear in the Sent mailbox. + final sentRow = await (r.db.select(r.db.mailboxes) + ..where( + (t) => t.accountId.equals('test-jmap') & t.role.equals('sent'), + ) + ..limit(1)) + .getSingleOrNull(); + final sentId = sentRow?.path; - if (sentId != null) { - await r.emails.syncEmails('test-jmap', sentId); - final sentEmails = - await r.emails.observeEmails('test-jmap', sentId).first; - expect(sentEmails.any((e) => e.subject == subject), isTrue); - } else { - // If no Sent mailbox exists, just verify sendEmail didn't throw. - } - }); + if (sentId != null) { + await r.emails.syncEmails('test-jmap', sentId); + final sentEmails = + await r.emails.observeEmails('test-jmap', sentId).first; + expect(sentEmails.any((e) => e.subject == subject), isTrue); + } else { + // If no Sent mailbox exists, just verify sendEmail didn't throw. + } + }, + ); test('flushPendingChanges marks email as seen on server', () async { await appendToInbox('flag-test'); diff --git a/test/integration/imap_sync_test.dart b/test/integration/imap_sync_test.dart index c5a2f9a..0e3dd23 100644 --- a/test/integration/imap_sync_test.dart +++ b/test/integration/imap_sync_test.dart @@ -45,12 +45,7 @@ void main() { }); test('login and list mailboxes', () async { - final client = await _connect( - userA, - passA, - host: imapHost, - port: imapPort, - ); + final client = await _connect(userA, passA, host: imapHost, port: imapPort); addTearDown(() => client.logout().ignore()); // listMailboxes() returns List directly diff --git a/test/integration/mailbox_repository_imap_test.dart b/test/integration/mailbox_repository_imap_test.dart index 575daf0..acf56b2 100644 --- a/test/integration/mailbox_repository_imap_test.dart +++ b/test/integration/mailbox_repository_imap_test.dart @@ -26,8 +26,9 @@ Future _imapConnect({ required String user, required String pass, }) async { - final client = - ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); + final client = ImapClient( + defaultResponseTimeout: const Duration(seconds: 20), + ); await client.connectToServer(host, port, isSecure: false); await client.login(user, pass); return client; @@ -63,8 +64,9 @@ void main() { String username, String password, ) async { - final client = - ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); + final client = ImapClient( + defaultResponseTimeout: const Duration(seconds: 20), + ); await client.connectToServer(a.imapHost, a.imapPort, isSecure: false); await client.login(username, password); return client; @@ -73,7 +75,7 @@ void main() { ({ AppDatabase db, AccountRepositoryImpl accounts, - MailboxRepositoryImpl mailboxes + MailboxRepositoryImpl mailboxes, }) makeRepo() { final db = openTestDatabase(); final accounts = AccountRepositoryImpl(db, MapSecureStorage()); diff --git a/test/integration/sync_reliability_runner_test.dart b/test/integration/sync_reliability_runner_test.dart index 0077cf6..08d4fd0 100644 --- a/test/integration/sync_reliability_runner_test.dart +++ b/test/integration/sync_reliability_runner_test.dart @@ -6,15 +6,11 @@ import 'package:flutter_test/flutter_test.dart'; import '../../scripts/sync_reliability.dart' as reliability; void main() { - test( - 'sync reliability script runner', - timeout: Timeout.none, - () async { - final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS']; - final args = rawArgs == null || rawArgs.isEmpty - ? const [] - : const LineSplitter().convert(rawArgs); - await reliability.runSyncReliability(args); - }, - ); + test('sync reliability script runner', timeout: Timeout.none, () async { + final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS']; + final args = rawArgs == null || rawArgs.isEmpty + ? const [] + : const LineSplitter().convert(rawArgs); + await reliability.runSyncReliability(args); + }); } diff --git a/test/integration/sync_reliability_test.dart b/test/integration/sync_reliability_test.dart index aeebaf7..bcd36db 100644 --- a/test/integration/sync_reliability_test.dart +++ b/test/integration/sync_reliability_test.dart @@ -20,8 +20,9 @@ Future _imapConnectPlain( String username, String password, ) async { - final client = - ImapClient(defaultResponseTimeout: const Duration(seconds: 20)); + final client = ImapClient( + defaultResponseTimeout: const Duration(seconds: 20), + ); await client.connectToServer( account.imapHost, account.imapPort, @@ -64,11 +65,7 @@ void main() { secureStorage = MapSecureStorage(); final accounts = AccountRepositoryImpl(db, secureStorage); await accounts.addAccount(account, userPass); - repo = EmailRepositoryImpl( - db, - accounts, - imapConnect: _imapConnectPlain, - ); + repo = EmailRepositoryImpl(db, accounts, imapConnect: _imapConnectPlain); final client = await _imapConnectPlain(account, userEmail, userPass); await client.selectMailboxByPath('INBOX'); @@ -107,26 +104,27 @@ void main() { }); test( - 'verifySyncReliability identifies extra local emails (missing on server)', - () async { - // 1. Manually insert a row into local DB that doesn't exist on server - await db.into(db.emails).insert( - EmailsCompanion.insert( - id: 'test:999', - accountId: 'test', - mailboxPath: 'INBOX', - uid: 999, - subject: const Value('Ghost'), - receivedAt: DateTime.now(), - ), - ); + 'verifySyncReliability identifies extra local emails (missing on server)', + () async { + // 1. Manually insert a row into local DB that doesn't exist on server + await db.into(db.emails).insert( + EmailsCompanion.insert( + id: 'test:999', + accountId: 'test', + mailboxPath: 'INBOX', + uid: 999, + subject: const Value('Ghost'), + receivedAt: DateTime.now(), + ), + ); - // 2. Verify reliability - final result = await repo.verifySyncReliability('test', 'INBOX'); - expect(result.isHealthy, isFalse); - expect(result.missingOnServer, contains('test:999')); - expect(result.missingLocally, isEmpty); - }); + // 2. Verify reliability + final result = await repo.verifySyncReliability('test', 'INBOX'); + expect(result.isHealthy, isFalse); + expect(result.missingOnServer, contains('test:999')); + expect(result.missingLocally, isEmpty); + }, + ); test('verifySyncReliability identifies flag mismatches', () async { // 1. Sync one email @@ -195,8 +193,10 @@ void main() { await client.logout(); // 2. Need to find the JMAP mailbox ID for INBOX - final mailboxRepo = - MailboxRepositoryImpl(db, AccountRepositoryImpl(db, secureStorage)); + final mailboxRepo = MailboxRepositoryImpl( + db, + AccountRepositoryImpl(db, secureStorage), + ); await mailboxRepo.syncMailboxes('test-jmap'); final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first; final inbox = mailboxes.firstWhere((m) => m.role == 'inbox'); diff --git a/test/unit/account_discovery_service_test.dart b/test/unit/account_discovery_service_test.dart index bdd72fe..2cc180e 100644 --- a/test/unit/account_discovery_service_test.dart +++ b/test/unit/account_discovery_service_test.dart @@ -39,36 +39,38 @@ void main() { }); test( - 'returns JmapDiscovery with session URL when well-known/jmap returns 200', - () async { - final svc = _service({ - 'https://example.com/.well-known/jmap': http.Response('{}', 200), - }); - final result = await svc.discover('user@example.com'); - expect(result, isA()); - expect( - (result as JmapDiscovery).sessionUrl, - 'https://example.com/.well-known/jmap', - ); - }); + 'returns JmapDiscovery with session URL when well-known/jmap returns 200', + () async { + final svc = _service({ + 'https://example.com/.well-known/jmap': http.Response('{}', 200), + }); + final result = await svc.discover('user@example.com'); + expect(result, isA()); + expect( + (result as JmapDiscovery).sessionUrl, + 'https://example.com/.well-known/jmap', + ); + }, + ); test( - 'returns JmapDiscovery with redirect target when well-known/jmap returns 307', - () async { - final svc = _service({ - 'https://example.com/.well-known/jmap': http.Response( - '', - 307, - headers: {'location': '/jmap/session'}, - ), - }); - final result = await svc.discover('user@example.com'); - expect(result, isA()); - expect( - (result as JmapDiscovery).sessionUrl, - 'https://example.com/jmap/session', - ); - }); + 'returns JmapDiscovery with redirect target when well-known/jmap returns 307', + () async { + final svc = _service({ + 'https://example.com/.well-known/jmap': http.Response( + '', + 307, + headers: {'location': '/jmap/session'}, + ), + }); + final result = await svc.discover('user@example.com'); + expect(result, isA()); + expect( + (result as JmapDiscovery).sessionUrl, + 'https://example.com/jmap/session', + ); + }, + ); test('returns UnknownDiscovery when well-known/jmap returns 404', () async { final svc = _service({ @@ -80,8 +82,10 @@ void main() { test('returns ImapSmtpDiscovery from primary autoconfig URL', () async { final svc = _service({ - 'https://autoconfig.example.com/mail/config-v1.1.xml': - http.Response(_autoconfigXml, 200), + 'https://autoconfig.example.com/mail/config-v1.1.xml': http.Response( + _autoconfigXml, + 200, + ), }); final result = await svc.discover('user@example.com'); expect(result, isA()); @@ -94,21 +98,25 @@ void main() { expect(imap.smtpSsl, isFalse); }); - test('returns ImapSmtpDiscovery from fallback well-known autoconfig URL', - () async { - final svc = _service({ - 'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml': - http.Response(_autoconfigXml, 200), - }); - final result = await svc.discover('user@example.com'); - expect(result, isA()); - }); + test( + 'returns ImapSmtpDiscovery from fallback well-known autoconfig URL', + () async { + final svc = _service({ + 'https://example.com/.well-known/autoconfig/mail/config-v1.1.xml': + http.Response(_autoconfigXml, 200), + }); + final result = await svc.discover('user@example.com'); + expect(result, isA()); + }, + ); test('prefers JMAP over IMAP when both respond', () async { final svc = _service({ 'https://example.com/.well-known/jmap': http.Response('{}', 200), - 'https://autoconfig.example.com/mail/config-v1.1.xml': - http.Response(_autoconfigXml, 200), + 'https://autoconfig.example.com/mail/config-v1.1.xml': http.Response( + _autoconfigXml, + 200, + ), }); final result = await svc.discover('user@example.com'); expect(result, isA()); @@ -120,24 +128,26 @@ void main() { expect(result, isA()); }); - test('returns ImapSmtpDiscovery from MX record when autoconfig not found', - () async { - final svc = _service({ - 'https://dns.google/resolve?name=example.com&type=MX': http.Response( - '{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}', - 200, - ), - }); - final result = await svc.discover('user@example.com'); - expect(result, isA()); - final imap = result as ImapSmtpDiscovery; - expect(imap.imapHost, 'mail.example.com'); - expect(imap.imapPort, 993); - expect(imap.imapSsl, isTrue); - expect(imap.smtpHost, 'mail.example.com'); - expect(imap.smtpPort, 465); - expect(imap.smtpSsl, isTrue); - }); + test( + 'returns ImapSmtpDiscovery from MX record when autoconfig not found', + () async { + final svc = _service({ + 'https://dns.google/resolve?name=example.com&type=MX': http.Response( + '{"Status":0,"Answer":[{"type":15,"data":"10 mail.example.com."}]}', + 200, + ), + }); + final result = await svc.discover('user@example.com'); + expect(result, isA()); + final imap = result as ImapSmtpDiscovery; + expect(imap.imapHost, 'mail.example.com'); + expect(imap.imapPort, 993); + expect(imap.imapSsl, isTrue); + expect(imap.smtpHost, 'mail.example.com'); + expect(imap.smtpPort, 465); + expect(imap.smtpSsl, isTrue); + }, + ); test('MX fallback picks lowest priority record', () async { final svc = _service({ diff --git a/test/unit/account_repository_impl_test.dart b/test/unit/account_repository_impl_test.dart index a5fdeff..506b7bb 100644 --- a/test/unit/account_repository_impl_test.dart +++ b/test/unit/account_repository_impl_test.dart @@ -82,10 +82,7 @@ void main() { test('getPassword throws StateError when no password stored', () async { final repo = _makeRepo(); - expect( - () => repo.getPassword('missing'), - throwsA(isA()), - ); + expect(() => repo.getPassword('missing'), throwsA(isA())); }); test('removeAccount deletes account and password', () async { @@ -94,10 +91,7 @@ void main() { await repo.removeAccount('acc-1'); expect(await repo.getAccount('acc-1'), isNull); - expect( - () => repo.getPassword('acc-1'), - throwsA(isA()), - ); + expect(() => repo.getPassword('acc-1'), throwsA(isA())); }); test('addAccount is idempotent (upsert)', () async { diff --git a/test/unit/connection_test_service_test.dart b/test/unit/connection_test_service_test.dart index 1c0f58b..fc3d5ba 100644 --- a/test/unit/connection_test_service_test.dart +++ b/test/unit/connection_test_service_test.dart @@ -35,10 +35,8 @@ ConnectionTestServiceImpl _makeService({ Exception? imapError, }) { final mockHttp = MockClient( - (_) async => http.Response( - httpStatus == 200 ? _jmapSessionJson : '', - httpStatus, - ), + (_) async => + http.Response(httpStatus == 200 ? _jmapSessionJson : '', httpStatus), ); return ConnectionTestServiceImpl( mockHttp, @@ -97,10 +95,7 @@ void main() { imapConnect: (_, __, ___) async => throw Exception('auth failed'), smtpConnect: (_, __, ___) async => FakeSmtpClient(), ); - expect( - () => svc.testConnection(_imapAccount, 'pw'), - throwsException, - ); + expect(() => svc.testConnection(_imapAccount, 'pw'), throwsException); }); test('reports SMTP failure after IMAP success', () async { @@ -192,9 +187,7 @@ void main() { final svc = _makeService(httpStatus: 500); expect( () => svc.testConnection(_jmapAccount, 'pw'), - throwsA( - predicate((e) => e.toString().contains('Connection failed')), - ), + throwsA(predicate((e) => e.toString().contains('Connection failed'))), ); }); diff --git a/test/unit/draft_repository_impl_test.dart b/test/unit/draft_repository_impl_test.dart index 1be79fe..16558d9 100644 --- a/test/unit/draft_repository_impl_test.dart +++ b/test/unit/draft_repository_impl_test.dart @@ -8,19 +8,21 @@ void main() { setUpAll(configureSqliteForTests); group('DraftRepositoryImpl', () { - test('saveDraft creates a new row and returns it with a non-zero id', - () async { - final repo = DraftRepositoryImpl(openTestDatabase()); - final draft = await repo.saveDraft( - toText: 'bob@example.com', - ccText: '', - subjectText: 'Hello', - bodyText: 'Hi', - ); - expect(draft.id, isNonZero); - expect(draft.toText, 'bob@example.com'); - expect(draft.subjectText, 'Hello'); - }); + test( + 'saveDraft creates a new row and returns it with a non-zero id', + () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + final draft = await repo.saveDraft( + toText: 'bob@example.com', + ccText: '', + subjectText: 'Hello', + bodyText: 'Hi', + ); + expect(draft.id, isNonZero); + expect(draft.toText, 'bob@example.com'); + expect(draft.subjectText, 'Hello'); + }, + ); test('saveDraft with id updates existing row', () async { final repo = DraftRepositoryImpl(openTestDatabase()); @@ -54,48 +56,52 @@ void main() { expect(await repo.findDraft(), isNull); }); - test('findDraft returns most recent draft for matching replyToEmailId', - () async { - final repo = DraftRepositoryImpl(openTestDatabase()); - await repo.saveDraft( - replyToEmailId: 'email-1', - toText: 'a@example.com', - ccText: '', - subjectText: 'Older', - bodyText: '', - ); - final newer = await repo.saveDraft( - replyToEmailId: 'email-1', - toText: 'a@example.com', - ccText: '', - subjectText: 'Newer', - bodyText: 'body', - ); - final found = await repo.findDraft(replyToEmailId: 'email-1'); - expect(found?.id, newer.id); - expect(found?.subjectText, 'Newer'); - }); + test( + 'findDraft returns most recent draft for matching replyToEmailId', + () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + await repo.saveDraft( + replyToEmailId: 'email-1', + toText: 'a@example.com', + ccText: '', + subjectText: 'Older', + bodyText: '', + ); + final newer = await repo.saveDraft( + replyToEmailId: 'email-1', + toText: 'a@example.com', + ccText: '', + subjectText: 'Newer', + bodyText: 'body', + ); + final found = await repo.findDraft(replyToEmailId: 'email-1'); + expect(found?.id, newer.id); + expect(found?.subjectText, 'Newer'); + }, + ); - test('findDraft with null replyToEmailId finds new-message drafts', - () async { - final repo = DraftRepositoryImpl(openTestDatabase()); - // This draft is a reply and should NOT be returned. - await repo.saveDraft( - replyToEmailId: 'email-1', - toText: 'x@example.com', - ccText: '', - subjectText: 'Reply draft', - bodyText: '', - ); - final newMsg = await repo.saveDraft( - toText: 'y@example.com', - ccText: '', - subjectText: 'New draft', - bodyText: '', - ); - final found = await repo.findDraft(); - expect(found?.id, newMsg.id); - }); + test( + 'findDraft with null replyToEmailId finds new-message drafts', + () async { + final repo = DraftRepositoryImpl(openTestDatabase()); + // This draft is a reply and should NOT be returned. + await repo.saveDraft( + replyToEmailId: 'email-1', + toText: 'x@example.com', + ccText: '', + subjectText: 'Reply draft', + bodyText: '', + ); + final newMsg = await repo.saveDraft( + toText: 'y@example.com', + ccText: '', + subjectText: 'New draft', + bodyText: '', + ); + final found = await repo.findDraft(); + expect(found?.id, newMsg.id); + }, + ); test('deleteDraft removes the row', () async { final repo = DraftRepositoryImpl(openTestDatabase()); diff --git a/test/unit/email_model_test.dart b/test/unit/email_model_test.dart index 7c7018a..9f3adcb 100644 --- a/test/unit/email_model_test.dart +++ b/test/unit/email_model_test.dart @@ -229,10 +229,16 @@ void main() { group('SyncEmailsResult', () { test('operator + adds fields', () { - const r1 = - SyncEmailsResult(fetched: 1, skipped: 2, bytesTransferred: 100); - const r2 = - SyncEmailsResult(fetched: 3, skipped: 4, bytesTransferred: 200); + const r1 = SyncEmailsResult( + fetched: 1, + skipped: 2, + bytesTransferred: 100, + ); + const r2 = SyncEmailsResult( + fetched: 3, + skipped: 4, + bytesTransferred: 200, + ); final r3 = r1 + r2; expect(r3.fetched, 4); expect(r3.skipped, 6); diff --git a/test/unit/email_repository_cancel_change_test.dart b/test/unit/email_repository_cancel_change_test.dart index 3817db9..e815a9f 100644 --- a/test/unit/email_repository_cancel_change_test.dart +++ b/test/unit/email_repository_cancel_change_test.dart @@ -26,11 +26,7 @@ void main() { mockHttpClient = MockClient(); mockStorage = MockSecureStorage(); final accounts = AccountRepositoryImpl(db, mockStorage); - repo = EmailRepositoryImpl( - db, - accounts, - httpClient: mockHttpClient, - ); + repo = EmailRepositoryImpl(db, accounts, httpClient: mockHttpClient); }); tearDown(() async { diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 50c8bbe..5e1343f 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -161,11 +161,8 @@ Future _noImapConnect(Account a, String u, String p) => Future _noSmtpConnect(Account a, String u, String p) => Future.error(UnsupportedError('SMTP unavailable in unit tests')); -({ - AppDatabase db, - AccountRepositoryImpl accounts, - EmailRepositoryImpl emails, -}) _makeRepos({http.Client? httpClient}) { +({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails}) + _makeRepos({http.Client? httpClient}) { final db = openTestDatabase(); final storage = MapSecureStorage(); final accounts = AccountRepositoryImpl(db, storage); @@ -421,53 +418,57 @@ void main() { // ── IMAP method tests ──────────────────────────────────────────────────── - test('setFlag seen=true enqueues flag_seen change and updates local DB', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 5, - receivedAt: DateTime(2024), - ), - ); + test( + 'setFlag seen=true enqueues flag_seen change and updates local DB', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 5, + receivedAt: DateTime(2024), + ), + ); - await r.emails.setFlag('acc-1:5', seen: true); + await r.emails.setFlag('acc-1:5', seen: true); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes, hasLength(1)); - expect(changes.first.changeType, 'flag_seen'); - expect(changes.first.payload, contains('"seen":true')); - final email = await r.emails.getEmail('acc-1:5'); - expect(email!.isSeen, isTrue); - }); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes, hasLength(1)); + expect(changes.first.changeType, 'flag_seen'); + expect(changes.first.payload, contains('"seen":true')); + final email = await r.emails.getEmail('acc-1:5'); + expect(email!.isSeen, isTrue); + }, + ); - test('setFlag seen=false enqueues flag_seen change with seen=false', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 5, - receivedAt: DateTime(2024), - isSeen: const Value(true), - ), - ); + test( + 'setFlag seen=false enqueues flag_seen change with seen=false', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 5, + receivedAt: DateTime(2024), + isSeen: const Value(true), + ), + ); - await r.emails.setFlag('acc-1:5', seen: false); + await r.emails.setFlag('acc-1:5', seen: false); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.changeType, 'flag_seen'); - expect(changes.first.payload, contains('"seen":false')); - final email = await r.emails.getEmail('acc-1:5'); - expect(email!.isSeen, isFalse); - }); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.changeType, 'flag_seen'); + expect(changes.first.payload, contains('"seen":false')); + final email = await r.emails.getEmail('acc-1:5'); + expect(email!.isSeen, isFalse); + }, + ); test('setFlag flagged=true enqueues flag_flagged change', () async { final r = _makeRepos(); @@ -491,74 +492,78 @@ void main() { }); test( - 'setFlag flagged=false enqueues flag_flagged change with flagged=false', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 5, - receivedAt: DateTime(2024), - isFlagged: const Value(true), - ), - ); + 'setFlag flagged=false enqueues flag_flagged change with flagged=false', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 5, + receivedAt: DateTime(2024), + isFlagged: const Value(true), + ), + ); - await r.emails.setFlag('acc-1:5', flagged: false); + await r.emails.setFlag('acc-1:5', flagged: false); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.changeType, 'flag_flagged'); - expect(changes.first.payload, contains('"flagged":false')); - }); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.changeType, 'flag_flagged'); + expect(changes.first.payload, contains('"flagged":false')); + }, + ); test( - 'moveEmail enqueues move change and updates local mailboxPath (optimistic)', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 5, - receivedAt: DateTime(2024), - ), - ); + 'moveEmail enqueues move change and updates local mailboxPath (optimistic)', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 5, + receivedAt: DateTime(2024), + ), + ); - await r.emails.moveEmail('acc-1:5', 'Archive'); + await r.emails.moveEmail('acc-1:5', 'Archive'); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.changeType, 'move'); - expect(changes.first.payload, contains('Archive')); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.changeType, 'move'); + expect(changes.first.payload, contains('Archive')); - final email = await r.emails.getEmail('acc-1:5'); - expect(email, isNotNull); - expect(email!.mailboxPath, 'Archive'); - }); + final email = await r.emails.getEmail('acc-1:5'); + expect(email, isNotNull); + expect(email!.mailboxPath, 'Archive'); + }, + ); - test('deleteEmail enqueues delete change and removes email from local DB', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); - await r.db.into(r.db.emails).insert( - EmailsCompanion.insert( - id: 'acc-1:5', - accountId: 'acc-1', - mailboxPath: 'INBOX', - uid: 5, - receivedAt: DateTime(2024), - ), - ); + test( + 'deleteEmail enqueues delete change and removes email from local DB', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:5', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 5, + receivedAt: DateTime(2024), + ), + ); - await r.emails.deleteEmail('acc-1:5'); + await r.emails.deleteEmail('acc-1:5'); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.changeType, 'delete'); - expect(await r.emails.getEmail('acc-1:5'), isNull); - }); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.changeType, 'delete'); + expect(await r.emails.getEmail('acc-1:5'), isNull); + }, + ); }); group('IMAP flushPendingChanges', () { @@ -729,11 +734,11 @@ void main() { '2': {'value': html, 'isTruncated': false}, }, 'attachments': [], - } + }, ], }, '0', - ] + ], ], }), 200, @@ -800,11 +805,11 @@ void main() { 'htmlBody': [], 'bodyValues': {}, 'attachments': [], - } + }, ], }, '0', - ] + ], ], }), 200, @@ -975,10 +980,12 @@ void main() { final emails = await r.emails.observeEmails('jmap-1', 'mbx1').first; expect(emails, hasLength(4)); - expect( - emails.map((e) => e.subject).toSet(), - {'Page1-A', 'Page1-B', 'Page2-A', 'Page2-B'}, - ); + expect(emails.map((e) => e.subject).toSet(), { + 'Page1-A', + 'Page1-B', + 'Page2-A', + 'Page2-B', + }); final states = await r.db.select(r.db.syncStates).get(); expect(states.first.state, 'est1'); @@ -1002,21 +1009,23 @@ void main() { ); } - test('setFlag seen enqueues flag_seen change and updates local DB', - () async { - final r = _makeRepos(); - await seedJmapEmail(r.db, r.accounts); + test( + 'setFlag seen enqueues flag_seen change and updates local DB', + () async { + final r = _makeRepos(); + await seedJmapEmail(r.db, r.accounts); - await r.emails.setFlag('jmap-1:e1', seen: true); + await r.emails.setFlag('jmap-1:e1', seen: true); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes, hasLength(1)); - expect(changes.first.changeType, 'flag_seen'); - expect(changes.first.payload, contains('true')); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes, hasLength(1)); + expect(changes.first.changeType, 'flag_seen'); + expect(changes.first.payload, contains('true')); - final email = await r.emails.getEmail('jmap-1:e1'); - expect(email?.isSeen, isTrue); - }); + final email = await r.emails.getEmail('jmap-1:e1'); + expect(email?.isSeen, isTrue); + }, + ); test('setFlag flagged enqueues flag_flagged change', () async { final r = _makeRepos(); @@ -1028,33 +1037,37 @@ void main() { expect(changes.first.changeType, 'flag_flagged'); }); - test('moveEmail enqueues move change and updates local mailbox path', - () async { - final r = _makeRepos(); - await seedJmapEmail(r.db, r.accounts); + test( + 'moveEmail enqueues move change and updates local mailbox path', + () async { + final r = _makeRepos(); + await seedJmapEmail(r.db, r.accounts); - await r.emails.moveEmail('jmap-1:e1', 'mbx2'); + await r.emails.moveEmail('jmap-1:e1', 'mbx2'); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.changeType, 'move'); - expect(changes.first.payload, contains('mbx2')); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.changeType, 'move'); + expect(changes.first.payload, contains('mbx2')); - final email = await r.emails.getEmail('jmap-1:e1'); - expect(email, isNotNull); - expect(email?.mailboxPath, 'mbx2'); - }); + final email = await r.emails.getEmail('jmap-1:e1'); + expect(email, isNotNull); + expect(email?.mailboxPath, 'mbx2'); + }, + ); - test('deleteEmail enqueues delete change and removes email from local DB', - () async { - final r = _makeRepos(); - await seedJmapEmail(r.db, r.accounts); + test( + 'deleteEmail enqueues delete change and removes email from local DB', + () async { + final r = _makeRepos(); + await seedJmapEmail(r.db, r.accounts); - await r.emails.deleteEmail('jmap-1:e1'); + await r.emails.deleteEmail('jmap-1:e1'); - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.changeType, 'delete'); - expect(await r.emails.getEmail('jmap-1:e1'), isNull); - }); + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.changeType, 'delete'); + expect(await r.emails.getEmail('jmap-1:e1'), isNull); + }, + ); }); group('JMAP flushPendingChanges', () { @@ -1246,130 +1259,134 @@ void main() { expect(states.first.state, 'est2'); }); - test('stateMismatch clears sync state and marks change as failed', - () async { - final client = MockClient((req) async { - if (req.url.path.contains('well-known')) { - return http.Response( - jsonEncode({ - 'apiUrl': 'https://jmap.example.com/api/', - 'accounts': {'acct1': {}}, - 'primaryAccounts': { - 'urn:ietf:params:jmap:core': 'acct1', - 'urn:ietf:params:jmap:mail': 'acct1', - }, - 'capabilities': {}, - 'username': 'alice@example.com', - 'state': 'sess1', - }), - 200, - ); - } - // Server responds with stateMismatch error inside Email/set - return http.Response( - jsonEncode({ - 'sessionState': 's1', - 'methodResponses': [ - [ - 'Email/set', - {'accountId': 'acct1', 'type': 'stateMismatch'}, - '0', - ], - ], - }), - 200, - ); - }); - - final r = _makeRepos(httpClient: client); - await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db.into(r.db.syncStates).insertOnConflictUpdate( - SyncStatesCompanion.insert( - accountId: 'jmap-1', - resourceType: 'Email', - state: 'est1', - syncedAt: DateTime.now(), - ), - ); - await r.db.into(r.db.pendingChanges).insert( - PendingChangesCompanion.insert( - accountId: 'jmap-1', - resourceType: 'Email', - resourceId: 'jmap-1:e1', - changeType: 'flag_seen', - payload: '{"seen":true}', - createdAt: DateTime.now(), - ), - ); - - await r.emails.flushPendingChanges('jmap-1', 'pw'); - - // Sync state should be cleared so next cycle does a full re-sync - expect(await r.db.select(r.db.syncStates).get(), isEmpty); - - // Change should still be present but with attempt count bumped - final changes = await r.db.select(r.db.pendingChanges).get(); - expect(changes.first.attempts, 1); - }); - - test('discards change immediately on notUpdated (permanent error)', - () async { - final client = MockClient((req) async { - if (req.url.path.contains('well-known')) { - return http.Response( - jsonEncode({ - 'apiUrl': 'https://jmap.example.com/api/', - 'accounts': {'acct1': {}}, - 'primaryAccounts': { - 'urn:ietf:params:jmap:core': 'acct1', - 'urn:ietf:params:jmap:mail': 'acct1', - }, - 'capabilities': {}, - 'username': 'alice@example.com', - 'state': 'sess1', - }), - 200, - ); - } - // Server responds with notUpdated — permanent per-item error - return http.Response( - jsonEncode({ - 'sessionState': 's1', - 'methodResponses': [ - [ - 'Email/set', - { - 'accountId': 'acct1', - 'notUpdated': { - 'e1': {'type': 'notFound'}, - }, + test( + 'stateMismatch clears sync state and marks change as failed', + () async { + final client = MockClient((req) async { + if (req.url.path.contains('well-known')) { + return http.Response( + jsonEncode({ + 'apiUrl': 'https://jmap.example.com/api/', + 'accounts': {'acct1': {}}, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': 'acct1', + 'urn:ietf:params:jmap:mail': 'acct1', }, - '0', + 'capabilities': {}, + 'username': 'alice@example.com', + 'state': 'sess1', + }), + 200, + ); + } + // Server responds with stateMismatch error inside Email/set + return http.Response( + jsonEncode({ + 'sessionState': 's1', + 'methodResponses': [ + [ + 'Email/set', + {'accountId': 'acct1', 'type': 'stateMismatch'}, + '0', + ], ], - ], - }), - 200, - ); - }); - - final r = _makeRepos(httpClient: client); - await r.accounts.addAccount(_jmapAccount, 'pw'); - await r.db.into(r.db.pendingChanges).insert( - PendingChangesCompanion.insert( - accountId: 'jmap-1', - resourceType: 'Email', - resourceId: 'jmap-1:e1', - changeType: 'flag_seen', - payload: '{"seen":true}', - createdAt: DateTime.now(), - ), + }), + 200, ); + }); - await r.emails.flushPendingChanges('jmap-1', 'pw'); + final r = _makeRepos(httpClient: client); + await r.accounts.addAccount(_jmapAccount, 'pw'); + await r.db.into(r.db.syncStates).insertOnConflictUpdate( + SyncStatesCompanion.insert( + accountId: 'jmap-1', + resourceType: 'Email', + state: 'est1', + syncedAt: DateTime.now(), + ), + ); + await r.db.into(r.db.pendingChanges).insert( + PendingChangesCompanion.insert( + accountId: 'jmap-1', + resourceType: 'Email', + resourceId: 'jmap-1:e1', + changeType: 'flag_seen', + payload: '{"seen":true}', + createdAt: DateTime.now(), + ), + ); - // Permanent error — change is immediately evicted - expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); - }); + await r.emails.flushPendingChanges('jmap-1', 'pw'); + + // Sync state should be cleared so next cycle does a full re-sync + expect(await r.db.select(r.db.syncStates).get(), isEmpty); + + // Change should still be present but with attempt count bumped + final changes = await r.db.select(r.db.pendingChanges).get(); + expect(changes.first.attempts, 1); + }, + ); + + test( + 'discards change immediately on notUpdated (permanent error)', + () async { + final client = MockClient((req) async { + if (req.url.path.contains('well-known')) { + return http.Response( + jsonEncode({ + 'apiUrl': 'https://jmap.example.com/api/', + 'accounts': {'acct1': {}}, + 'primaryAccounts': { + 'urn:ietf:params:jmap:core': 'acct1', + 'urn:ietf:params:jmap:mail': 'acct1', + }, + 'capabilities': {}, + 'username': 'alice@example.com', + 'state': 'sess1', + }), + 200, + ); + } + // Server responds with notUpdated — permanent per-item error + return http.Response( + jsonEncode({ + 'sessionState': 's1', + 'methodResponses': [ + [ + 'Email/set', + { + 'accountId': 'acct1', + 'notUpdated': { + 'e1': {'type': 'notFound'}, + }, + }, + '0', + ], + ], + }), + 200, + ); + }); + + final r = _makeRepos(httpClient: client); + await r.accounts.addAccount(_jmapAccount, 'pw'); + await r.db.into(r.db.pendingChanges).insert( + PendingChangesCompanion.insert( + accountId: 'jmap-1', + resourceType: 'Email', + resourceId: 'jmap-1:e1', + changeType: 'flag_seen', + payload: '{"seen":true}', + createdAt: DateTime.now(), + ), + ); + + await r.emails.flushPendingChanges('jmap-1', 'pw'); + + // Permanent error — change is immediately evicted + expect(await r.db.select(r.db.pendingChanges).get(), isEmpty); + }, + ); test('evicts change after max attempts (5)', () async { final r = _makeRepos(httpClient: mockFlush(500)); @@ -1459,9 +1476,7 @@ void main() { apiResponses: [ _emailGetResponse( state: 'est1', - list: [ - _jmapEmail(id: 'e1', mailboxId: 'mbx1'), - ], + list: [_jmapEmail(id: 'e1', mailboxId: 'mbx1')], ), ], ), diff --git a/test/unit/jmap_client_test.dart b/test/unit/jmap_client_test.dart index 11f7fb5..dee4770 100644 --- a/test/unit/jmap_client_test.dart +++ b/test/unit/jmap_client_test.dart @@ -43,13 +43,7 @@ http.Client _sessionClient({ ); } return http.Response( - jsonEncode( - apiBody ?? - { - 'sessionState': 'st1', - 'methodResponses': [], - }, - ), + jsonEncode(apiBody ?? {'sessionState': 'st1', 'methodResponses': []}), apiStatus, ); }); @@ -137,10 +131,7 @@ void main() { }); group('JmapClient.call', () { - Future connected({ - int apiStatus = 200, - dynamic apiBody, - }) => + Future connected({int apiStatus = 200, dynamic apiBody}) => JmapClient.connect( httpClient: _sessionClient(apiStatus: apiStatus, apiBody: apiBody), jmapUrl: Uri.parse(_sessionUrl), @@ -154,7 +145,7 @@ void main() { 'Mailbox/get', {'state': 'st2', 'list': []}, '0', - ] + ], ]; final client = await connected( apiBody: {'sessionState': 'st1', 'methodResponses': responses}, @@ -164,7 +155,7 @@ void main() { 'Mailbox/get', {'accountId': _accountId, 'ids': null}, '0', - ] + ], ]); expect(result, hasLength(1)); expect((result[0] as List)[0], 'Mailbox/get'); @@ -178,7 +169,7 @@ void main() { 'Mailbox/get', {'accountId': _accountId}, '0', - ] + ], ]), throwsA(isA()), ); @@ -194,7 +185,7 @@ void main() { 'Mailbox/get', {'accountId': _accountId}, '0', - ] + ], ]), throwsA(isA()), ); diff --git a/test/unit/mailbox_model_test.dart b/test/unit/mailbox_model_test.dart index 7cee074..9a68374 100644 --- a/test/unit/mailbox_model_test.dart +++ b/test/unit/mailbox_model_test.dart @@ -130,10 +130,7 @@ void main() { }); test('copyWith works', () { - final updated = mailbox.copyWith( - unreadCount: 5, - role: 'inbox', - ); + final updated = mailbox.copyWith(unreadCount: 5, role: 'inbox'); expect(updated.unreadCount, 5); expect(updated.role, 'inbox'); expect(updated.id, mailbox.id); diff --git a/test/unit/mailbox_repository_impl_test.dart b/test/unit/mailbox_repository_impl_test.dart index 1c48bc1..5b6b020 100644 --- a/test/unit/mailbox_repository_impl_test.dart +++ b/test/unit/mailbox_repository_impl_test.dart @@ -155,47 +155,50 @@ void main() { } final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first; - expect( - mailboxes.map((m) => m.path).toList(), - ['Drafts', 'INBOX', 'Sent'], - ); + expect(mailboxes.map((m) => m.path).toList(), [ + 'Drafts', + 'INBOX', + 'Sent', + ]); }); - test('observeMailboxes only returns mailboxes for the given account', - () async { - final r = _makeRepos(); - await r.accounts.addAccount(_account, 'pw'); + test( + 'observeMailboxes only returns mailboxes for the given account', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); - const other = Account( - id: 'acc-2', - displayName: 'Bob', - email: 'bob@example.com', - imapHost: 'imap.example.com', - smtpHost: 'smtp.example.com', - ); - await r.accounts.addAccount(other, 'pw2'); + const other = Account( + id: 'acc-2', + displayName: 'Bob', + email: 'bob@example.com', + imapHost: 'imap.example.com', + smtpHost: 'smtp.example.com', + ); + await r.accounts.addAccount(other, 'pw2'); - await r.db.into(r.db.mailboxes).insert( - MailboxesCompanion.insert( - id: 'acc-1:INBOX', - accountId: 'acc-1', - path: 'INBOX', - name: 'Inbox', - ), - ); - await r.db.into(r.db.mailboxes).insert( - MailboxesCompanion.insert( - id: 'acc-2:INBOX', - accountId: 'acc-2', - path: 'INBOX', - name: 'Inbox', - ), - ); + await r.db.into(r.db.mailboxes).insert( + MailboxesCompanion.insert( + id: 'acc-1:INBOX', + accountId: 'acc-1', + path: 'INBOX', + name: 'Inbox', + ), + ); + await r.db.into(r.db.mailboxes).insert( + MailboxesCompanion.insert( + id: 'acc-2:INBOX', + accountId: 'acc-2', + path: 'INBOX', + name: 'Inbox', + ), + ); - final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first; - expect(mailboxes, hasLength(1)); - expect(mailboxes.first.id, 'acc-1:INBOX'); - }); + final mailboxes = await r.mailboxes.observeMailboxes('acc-1').first; + expect(mailboxes, hasLength(1)); + expect(mailboxes.first.id, 'acc-1:INBOX'); + }, + ); test('observeMailboxes maps unread/total counts', () async { final r = _makeRepos(); @@ -377,30 +380,32 @@ void main() { ); }); - test('syncMailboxes throws JmapException on API error response', - () async { - final r = _makeRepos( - httpClient: _mockJmap( - apiResponses: [ - { - 'sessionState': 'sess1', - 'methodResponses': [ - [ - 'error', - {'type': 'serverFail'}, - '0', + test( + 'syncMailboxes throws JmapException on API error response', + () async { + final r = _makeRepos( + httpClient: _mockJmap( + apiResponses: [ + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'error', + {'type': 'serverFail'}, + '0', + ], ], - ], - }, - ], - ), - ); - await r.accounts.addAccount(_jmapAccount, 'pw'); - await expectLater( - r.mailboxes.syncMailboxes('jmap-1'), - throwsA(isA()), - ); - }); + }, + ], + ), + ); + await r.accounts.addAccount(_jmapAccount, 'pw'); + await expectLater( + r.mailboxes.syncMailboxes('jmap-1'), + throwsA(isA()), + ); + }, + ); }); test('findMailboxByRole returns null when no matching mailbox', () async { diff --git a/test/unit/sync_log_repository_impl_test.dart b/test/unit/sync_log_repository_impl_test.dart index 3b9d33c..30c3ea2 100644 --- a/test/unit/sync_log_repository_impl_test.dart +++ b/test/unit/sync_log_repository_impl_test.dart @@ -75,8 +75,9 @@ void main() { finishedAt: end, ); - final rows = await (db.select(db.syncLogs) - ..where((r) => r.result.equals('error'))) + final rows = await (db.select( + db.syncLogs, + )..where((r) => r.result.equals('error'))) .get(); expect(rows, hasLength(1)); expect(rows.first.result, 'error'); diff --git a/test/unit/undo_logic_test.dart b/test/unit/undo_logic_test.dart index ab62412..3eed958 100644 --- a/test/unit/undo_logic_test.dart +++ b/test/unit/undo_logic_test.dart @@ -205,50 +205,51 @@ void main() { ); }); - test('Undo deletion for IMAP enqueues reverse move if cancel fails', - () async { - const emailId = 'acc1:101'; - final original = await repo.getEmail(emailId); + test( + 'Undo deletion for IMAP enqueues reverse move if cancel fails', + () async { + const emailId = 'acc1:101'; + final original = await repo.getEmail(emailId); - // 1. Delete - final destPath = await repo.deleteEmail(emailId); - expect(destPath, 'Trash'); + // 1. Delete + final destPath = await repo.deleteEmail(emailId); + expect(destPath, 'Trash'); - // 2. Mark the pending change as "attempted" so it cannot be cancelled - await (db.update(db.pendingChanges) - ..where((t) => t.resourceId.equals(emailId))) - .write( - const PendingChangesCompanion(attempts: Value(1)), - ); + // 2. Mark the pending change as "attempted" so it cannot be cancelled + await (db.update(db.pendingChanges) + ..where((t) => t.resourceId.equals(emailId))) + .write(const PendingChangesCompanion(attempts: Value(1))); - // 3. Undo - final action = UndoAction( - id: 'undo3', - accountId: 'acc1', - type: UndoType.delete, - emailIds: [emailId], - sourceMailboxPath: 'INBOX', - destinationMailboxPath: destPath, - originalEmails: [original!], - ); - container.read(undoServiceProvider.notifier).pushAction(action); - await container.read(undoServiceProvider.notifier).undo(); + // 3. Undo + final action = UndoAction( + id: 'undo3', + accountId: 'acc1', + type: UndoType.delete, + emailIds: [emailId], + sourceMailboxPath: 'INBOX', + destinationMailboxPath: destPath, + originalEmails: [original!], + ); + container.read(undoServiceProvider.notifier).pushAction(action); + await container.read(undoServiceProvider.notifier).undo(); - // 4. Verify local state - final restored = await (db.select(db.emails) - ..where((t) => t.id.equals(emailId)) - ..where((t) => t.mailboxPath.equals('INBOX'))) - .get(); - expect(restored, isNotEmpty); + // 4. Verify local state + final restored = await (db.select(db.emails) + ..where((t) => t.id.equals(emailId)) + ..where((t) => t.mailboxPath.equals('INBOX'))) + .get(); + expect(restored, isNotEmpty); - // 5. Verify a NEW pending change was enqueued (Trash -> INBOX) - final changes = await db.select(db.pendingChanges).get(); - final reverseMove = - changes.firstWhere((c) => c.changeType == 'move' && c.attempts == 0); - final payload = jsonDecode(reverseMove.payload) as Map; - expect(payload['mailboxPath'], 'Trash'); - expect(payload['dest'], 'INBOX'); - }); + // 5. Verify a NEW pending change was enqueued (Trash -> INBOX) + final changes = await db.select(db.pendingChanges).get(); + final reverseMove = changes.firstWhere( + (c) => c.changeType == 'move' && c.attempts == 0, + ); + final payload = jsonDecode(reverseMove.payload) as Map; + expect(payload['mailboxPath'], 'Trash'); + expect(payload['dest'], 'INBOX'); + }, + ); test('Undo snooze clears snooze metadata and moves back', () async { const emailId = 'acc1:101'; diff --git a/test/unit/undo_service_test.dart b/test/unit/undo_service_test.dart index 21db179..277bada 100644 --- a/test/unit/undo_service_test.dart +++ b/test/unit/undo_service_test.dart @@ -22,8 +22,9 @@ void main() { when(mockUndoRepo.saveAction(any)).thenAnswer((_) async {}); when(mockUndoRepo.deleteAction(any)).thenAnswer((_) async {}); - when(mockUndoRepo.getHistory(limit: anyNamed('limit'))) - .thenAnswer((_) async => []); + when( + mockUndoRepo.getHistory(limit: anyNamed('limit')), + ).thenAnswer((_) async => []); container = ProviderContainer( overrides: [ @@ -84,8 +85,9 @@ void main() { ); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - when(mockEmailRepo.cancelPendingChange(any, any)) - .thenAnswer((_) async => false); + when( + mockEmailRepo.cancelPendingChange(any, any), + ).thenAnswer((_) async => false); final notifier = container.read(undoServiceProvider.notifier); await notifier.init(); @@ -118,8 +120,9 @@ void main() { ); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - when(mockEmailRepo.cancelPendingChange(any, any)) - .thenAnswer((_) async => false); + when( + mockEmailRepo.cancelPendingChange(any, any), + ).thenAnswer((_) async => false); final notifier = container.read(undoServiceProvider.notifier); await notifier.init(); @@ -142,10 +145,12 @@ void main() { ); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); - when(mockEmailRepo.cancelPendingChange('e1', 'delete')) - .thenAnswer((_) async => false); - when(mockEmailRepo.cancelPendingChange('e1', 'move')) - .thenAnswer((_) async => true); + 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(); @@ -180,8 +185,9 @@ void main() { originalEmails: [email], ); - when(mockEmailRepo.cancelPendingChange(any, any)) - .thenAnswer((_) async => false); + when( + mockEmailRepo.cancelPendingChange(any, any), + ).thenAnswer((_) async => false); when(mockEmailRepo.restoreEmails(any)).thenAnswer((_) async {}); when(mockEmailRepo.moveEmail(any, any)).thenAnswer((_) async {}); diff --git a/test/widget/account_list_screen_test.dart b/test/widget/account_list_screen_test.dart index eada87e..9d311b6 100644 --- a/test/widget/account_list_screen_test.dart +++ b/test/widget/account_list_screen_test.dart @@ -5,8 +5,9 @@ import 'helpers.dart'; void main() { group('AccountListScreen', () { - testWidgets('shows "No accounts yet." when repository is empty', - (tester) async { + testWidgets('shows "No accounts yet." when repository is empty', ( + tester, + ) async { await tester.pumpWidget( buildApp(initialLocation: '/accounts', overrides: baseOverrides()), ); @@ -16,8 +17,9 @@ void main() { expect(find.text('Add account'), findsOneWidget); }); - testWidgets('shows account tile when repository has an account', - (tester) async { + testWidgets('shows account tile when repository has an account', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts', @@ -44,8 +46,9 @@ void main() { expect(find.textContaining('IMAP'), findsOneWidget); }); - testWidgets('shows check icon after successful connection test', - (tester) async { + testWidgets('shows check icon after successful connection test', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts', @@ -87,21 +90,23 @@ void main() { }); testWidgets( - '"Add account" button in empty state navigates to add-account screen', - (tester) async { - await tester.pumpWidget( - buildApp(initialLocation: '/accounts', overrides: baseOverrides()), - ); - await tester.pumpAndSettle(); + '"Add account" button in empty state navigates to add-account screen', + (tester) async { + await tester.pumpWidget( + buildApp(initialLocation: '/accounts', overrides: baseOverrides()), + ); + await tester.pumpAndSettle(); - await tester.tap(find.text('Add account')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Add account')); + await tester.pumpAndSettle(); - expect(find.text('Email address'), findsOneWidget); - }); + expect(find.text('Email address'), findsOneWidget); + }, + ); - testWidgets('tapping an account tile navigates to its mailboxes', - (tester) async { + testWidgets('tapping an account tile navigates to its mailboxes', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts', @@ -131,8 +136,9 @@ void main() { expect(find.text('Add account'), findsOneWidget); }); - testWidgets('AppBar does not overflow at minimum supported window size', - (tester) async { + testWidgets('AppBar does not overflow at minimum supported window size', ( + tester, + ) async { tester.view.physicalSize = const Size(400, 300); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); diff --git a/test/widget/add_account_screen_test.dart b/test/widget/add_account_screen_test.dart index cabfcff..bde12bf 100644 --- a/test/widget/add_account_screen_test.dart +++ b/test/widget/add_account_screen_test.dart @@ -7,13 +7,11 @@ import 'helpers.dart'; void main() { group('AddAccountScreen', () { - testWidgets('step 1: shows email field and Continue button', - (tester) async { + testWidgets('step 1: shows email field and Continue button', ( + tester, + ) async { await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/add', - overrides: baseOverrides(), - ), + buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()), ); await tester.pumpAndSettle(); @@ -24,10 +22,7 @@ void main() { testWidgets('step 1: empty submit shows validation error', (tester) async { await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/add', - overrides: baseOverrides(), - ), + buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()), ); await tester.pumpAndSettle(); @@ -39,10 +34,7 @@ void main() { testWidgets('step 1: invalid email shows validation error', (tester) async { await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/add', - overrides: baseOverrides(), - ), + buildApp(initialLocation: '/accounts/add', overrides: baseOverrides()), ); await tester.pumpAndSettle(); @@ -73,14 +65,16 @@ void main() { expect(find.text('IMAP / SMTP'), findsOneWidget); }); - testWidgets('JMAP discovery navigates directly to JMAP form', - (tester) async { + testWidgets('JMAP discovery navigates directly to JMAP form', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/add', overrides: baseOverrides( - discovery: - JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'), + discovery: JmapDiscovery( + sessionUrl: 'https://mail.example.com/jmap', + ), ), ), ); @@ -97,8 +91,9 @@ void main() { expect(find.text('https://mail.example.com/jmap'), findsOneWidget); }); - testWidgets('IMAP discovery navigates directly to IMAP form', - (tester) async { + testWidgets('IMAP discovery navigates directly to IMAP form', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/add', @@ -150,8 +145,9 @@ void main() { expect(find.text('JMAP API URL'), findsOneWidget); }); - testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form', - (tester) async { + testWidgets('choose-type: tapping IMAP/SMTP shows IMAP form', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/add', @@ -174,14 +170,16 @@ void main() { expect(find.text('SMTP'), findsOneWidget); }); - testWidgets('successful JMAP save pops back to accounts list', - (tester) async { + testWidgets('successful JMAP save pops back to accounts list', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/add', overrides: baseOverrides( - discovery: - JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'), + discovery: JmapDiscovery( + sessionUrl: 'https://mail.example.com/jmap', + ), ), ), ); @@ -213,8 +211,9 @@ void main() { buildApp( initialLocation: '/accounts/add', overrides: baseOverrides( - discovery: - JmapDiscovery(sessionUrl: 'https://mail.example.com/jmap'), + discovery: JmapDiscovery( + sessionUrl: 'https://mail.example.com/jmap', + ), connectionError: Exception('auth failed'), ), ), @@ -242,8 +241,9 @@ void main() { expect(find.textContaining('Connection failed'), findsOneWidget); }); - testWidgets('successful IMAP save pops back to accounts list', - (tester) async { + testWidgets('successful IMAP save pops back to accounts list', ( + tester, + ) async { tester.view.physicalSize = const Size(800, 1400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -288,45 +288,46 @@ void main() { }); testWidgets( - 'IMAP form hides SSL toggle for non-localhost, shows for localhost', - (tester) async { - await tester.pumpWidget( - buildApp( - initialLocation: '/accounts/add', - overrides: baseOverrides(discovery: UnknownDiscovery()), - ), - ); - await tester.pumpAndSettle(); + 'IMAP form hides SSL toggle for non-localhost, shows for localhost', + (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/add', + overrides: baseOverrides(discovery: UnknownDiscovery()), + ), + ); + await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const Key('emailField')), - 'user@example.com', - ); - await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('emailField')), + 'user@example.com', + ); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); - await tester.tap(find.text('IMAP / SMTP')); - await tester.pumpAndSettle(); + await tester.tap(find.text('IMAP / SMTP')); + await tester.pumpAndSettle(); - expect(find.text('IMAP'), findsOneWidget); - // No SSL toggles shown when hosts are empty (non-localhost). - expect(find.byType(SwitchListTile), findsNothing); + expect(find.text('IMAP'), findsOneWidget); + // No SSL toggles shown when hosts are empty (non-localhost). + expect(find.byType(SwitchListTile), findsNothing); - // Entering localhost as IMAP host reveals the IMAP SSL toggle. - await tester.enterText( - find.widgetWithText(TextFormField, 'Host').first, - 'localhost', - ); - await tester.pumpAndSettle(); - expect(find.byType(SwitchListTile), findsOneWidget); + // Entering localhost as IMAP host reveals the IMAP SSL toggle. + await tester.enterText( + find.widgetWithText(TextFormField, 'Host').first, + 'localhost', + ); + await tester.pumpAndSettle(); + expect(find.byType(SwitchListTile), findsOneWidget); - // Entering localhost as SMTP host reveals both SSL toggles. - await tester.enterText( - find.widgetWithText(TextFormField, 'Host').last, - 'localhost', - ); - await tester.pumpAndSettle(); - expect(find.byType(SwitchListTile), findsNWidgets(2)); - }); + // Entering localhost as SMTP host reveals both SSL toggles. + await tester.enterText( + find.widgetWithText(TextFormField, 'Host').last, + 'localhost', + ); + await tester.pumpAndSettle(); + expect(find.byType(SwitchListTile), findsNWidgets(2)); + }, + ); }); } diff --git a/test/widget/compose_screen_test.dart b/test/widget/compose_screen_test.dart index a703990..e2abfe2 100644 --- a/test/widget/compose_screen_test.dart +++ b/test/widget/compose_screen_test.dart @@ -16,10 +16,12 @@ void main() { buildApp( initialLocation: '/compose', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), ], @@ -33,8 +35,9 @@ void main() { expect(find.text('Body'), findsOneWidget); }); - testWidgets('prefills To and Subject when provided as constructor params', - (tester) async { + testWidgets('prefills To and Subject when provided as constructor params', ( + tester, + ) async { await tester.pumpWidget( _buildDirect( screen: const ComposeScreen( @@ -42,10 +45,12 @@ void main() { prefillSubject: 'Re: Hello', ), overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), ], @@ -60,16 +65,19 @@ void main() { expect(find.widgetWithText(TextFormField, 'Re: Hello'), findsOneWidget); }); - testWidgets('shows static From field when one account is loaded', - (tester) async { + testWidgets('shows static From field when one account is loaded', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/compose', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), ], @@ -80,8 +88,9 @@ void main() { expect(find.text('Alice '), findsOneWidget); }); - testWidgets('shows From dropdown when multiple accounts are loaded', - (tester) async { + testWidgets('shows From dropdown when multiple accounts are loaded', ( + tester, + ) async { const second = Account( id: 'acc-2', displayName: 'Bob', @@ -96,8 +105,9 @@ void main() { accountRepositoryProvider.overrideWithValue( FakeAccountRepository([kTestAccount, second]), ), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), ], @@ -108,8 +118,9 @@ void main() { expect(find.byType(DropdownButtonFormField), findsOneWidget); }); - testWidgets('restores saved draft when no prefill is provided', - (tester) async { + testWidgets('restores saved draft when no prefill is provided', ( + tester, + ) async { final fakeDrafts = FakeDraftRepository(); await fakeDrafts.saveDraft( toText: 'carol@example.com', @@ -121,10 +132,12 @@ void main() { _buildDirect( screen: const ComposeScreen(), overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), draftRepositoryProvider.overrideWithValue(fakeDrafts), ], @@ -152,9 +165,7 @@ Widget _buildDirect({ }) { final router = GoRouter( initialLocation: '/', - routes: [ - GoRoute(path: '/', builder: (ctx, state) => screen), - ], + routes: [GoRoute(path: '/', builder: (ctx, state) => screen)], ); return ProviderScope( overrides: overrides, diff --git a/test/widget/crash_screen_test.dart b/test/widget/crash_screen_test.dart index 7f61030..91fc011 100644 --- a/test/widget/crash_screen_test.dart +++ b/test/widget/crash_screen_test.dart @@ -23,8 +23,9 @@ class MockUrlLauncher extends Mock } void main() { - testWidgets('CrashScreen shows error details and has a report button', - (tester) async { + testWidgets('CrashScreen shows error details and has a report button', ( + tester, + ) async { tester.view.physicalSize = const Size(800, 1200); tester.view.devicePixelRatio = 1.0; addTearDown(() => tester.view.resetPhysicalSize()); diff --git a/test/widget/edit_account_screen_test.dart b/test/widget/edit_account_screen_test.dart index e9dae76..34e1e6c 100644 --- a/test/widget/edit_account_screen_test.dart +++ b/test/widget/edit_account_screen_test.dart @@ -5,8 +5,9 @@ import 'helpers.dart'; void main() { group('EditAccountScreen', () { - testWidgets('shows account email and type label after loading', - (tester) async { + testWidgets('shows account email and type label after loading', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/edit', @@ -65,8 +66,9 @@ void main() { expect(find.text('No accounts yet.'), findsNothing); }); - testWidgets('saving with new password runs connection test', - (tester) async { + testWidgets('saving with new password runs connection test', ( + tester, + ) async { tester.view.physicalSize = const Size(800, 1400); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index ff83889..cd71fcc 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -17,10 +17,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(neverRepo), ], ), @@ -42,10 +44,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository(emailDetail: email, emailBody: body), ), @@ -61,16 +65,21 @@ void main() { testWidgets('shows from-address in header', (tester) async { final email = testEmail(); - const body = - EmailBody(emailId: 'acc-1:42', textBody: 'Hi', attachments: []); + const body = EmailBody( + emailId: 'acc-1:42', + textBody: 'Hi', + attachments: [], + ); await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository(emailDetail: email, emailBody: body), ), @@ -82,8 +91,9 @@ void main() { expect(find.textContaining('bob@example.com'), findsOneWidget); }); - testWidgets('shows attachment section when email has attachments', - (tester) async { + testWidgets('shows attachment section when email has attachments', ( + tester, + ) async { final email = testEmail(hasAttachment: true); const body = EmailBody( emailId: 'acc-1:42', @@ -100,10 +110,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository(emailDetail: email, emailBody: body), ), diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 84b7b19..14fdb6a 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -15,10 +15,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -34,12 +36,15 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider - .overrideWithValue(FakeEmailRepository(emails: [email])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), ], ), ); @@ -55,12 +60,15 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider - .overrideWithValue(FakeEmailRepository(emails: [email])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), ], ), ); @@ -74,10 +82,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -91,16 +101,19 @@ void main() { expect(find.text('Search…'), findsOneWidget); }); - testWidgets('submitting a search query shows "No results" when empty', - (tester) async { + testWidgets('submitting a search query shows "No results" when empty', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -117,17 +130,20 @@ void main() { expect(find.text('No results'), findsOneWidget); }); - testWidgets('submitting a search query shows matching emails', - (tester) async { + testWidgets('submitting a search query shows matching emails', ( + tester, + ) async { final email = testEmail(subject: 'Found it'); await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository(searchResults: [email]), ), @@ -151,10 +167,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -167,16 +185,19 @@ void main() { // No assertion needed — we just verify the tap doesn't throw. }); - testWidgets('tapping edit button navigates to compose screen', - (tester) async { + testWidgets('tapping edit button navigates to compose screen', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -194,10 +215,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -209,19 +232,23 @@ void main() { expect(find.text('INBOX'), findsOneWidget); }); - testWidgets('long-press enters selection mode with selection bar', - (tester) async { + testWidgets('long-press enters selection mode with selection bar', ( + tester, + ) async { final email = testEmail(subject: 'Select me'); await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider - .overrideWithValue(FakeEmailRepository(emails: [email])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), ], ), ); @@ -235,19 +262,23 @@ void main() { expect(find.byIcon(Icons.close), findsOneWidget); }); - testWidgets('selection bar close button exits selection mode', - (tester) async { + testWidgets('selection bar close button exits selection mode', ( + tester, + ) async { final email = testEmail(subject: 'Select me'); await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider - .overrideWithValue(FakeEmailRepository(emails: [email])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), ], ), ); @@ -263,17 +294,20 @@ void main() { expect(find.byType(BottomAppBar), findsNothing); }); - testWidgets('tapping clear icon in search bar clears results', - (tester) async { + testWidgets('tapping clear icon in search bar clears results', ( + tester, + ) async { final email = testEmail(subject: 'Found it'); await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository(emails: [], searchResults: [email]), ), @@ -303,12 +337,15 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider - .overrideWithValue(FakeEmailRepository(emails: [email])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), ], ), ); @@ -325,23 +362,28 @@ void main() { expect(find.text('INBOX'), findsOneWidget); }); - testWidgets('tapping a search result navigates to email detail', - (tester) async { + testWidgets('tapping a search result navigates to email detail', ( + tester, + ) async { final email = testEmail(subject: 'Result email'); await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), emailRepositoryProvider.overrideWithValue( FakeEmailRepository( searchResults: [email], emailDetail: email, - emailBody: - const EmailBody(emailId: 'acc-1:42', attachments: []), + emailBody: const EmailBody( + emailId: 'acc-1:42', + attachments: [], + ), ), ), ], @@ -384,12 +426,15 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository()), - emailRepositoryProvider - .overrideWithValue(FakeEmailRepository(emails: [email])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), ], ), ); diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 909b258..045e648 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -306,7 +306,9 @@ class FakeConnectionTestService implements ConnectionTestService { class _NoOpManageSieveProbeService implements ManageSieveProbeService { @override - Future probe(Account account) async {/* no-op in tests */} + Future probe(Account account) async { + /* no-op in tests */ + } } // --------------------------------------------------------------------------- @@ -340,9 +342,8 @@ Widget buildApp({ ), GoRoute( path: ':accountId/search', - builder: (ctx, state) => SearchScreen( - accountId: state.pathParameters['accountId']!, - ), + builder: (ctx, state) => + SearchScreen(accountId: state.pathParameters['accountId']!), ), GoRoute( path: ':accountId/emails/by-address/:address', @@ -399,8 +400,9 @@ Widget buildApp({ // their own override before this default in [overrides]. overrides: [ ...overrides, - manageSieveProbeServiceProvider - .overrideWith((ref) => _NoOpManageSieveProbeService()), + manageSieveProbeServiceProvider.overrideWith( + (ref) => _NoOpManageSieveProbeService(), + ), ], child: MaterialApp.router( routerConfig: testRouter, diff --git a/test/widget/mailbox_list_screen_test.dart b/test/widget/mailbox_list_screen_test.dart index fc9b8a5..ff5718a 100644 --- a/test/widget/mailbox_list_screen_test.dart +++ b/test/widget/mailbox_list_screen_test.dart @@ -13,10 +13,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository([kTestMailbox])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([kTestMailbox]), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -31,10 +33,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository([kTestMailbox])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([kTestMailbox]), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -45,16 +49,19 @@ void main() { expect(find.text('3'), findsOneWidget); }); - testWidgets('tapping a mailbox tile navigates to its email list', - (tester) async { + testWidgets('tapping a mailbox tile navigates to its email list', ( + tester, + ) async { await tester.pumpWidget( buildApp( initialLocation: '/accounts/acc-1/mailboxes', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository([kTestMailbox])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([kTestMailbox]), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), @@ -80,10 +87,12 @@ void main() { buildApp( initialLocation: '/accounts/acc-1/mailboxes', overrides: [ - accountRepositoryProvider - .overrideWithValue(FakeAccountRepository([kTestAccount])), - mailboxRepositoryProvider - .overrideWithValue(FakeMailboxRepository([emptyMailbox])), + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([emptyMailbox]), + ), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), ], ), diff --git a/test/widget/try_connection_button_test.dart b/test/widget/try_connection_button_test.dart index 5938fd4..bd4d489 100644 --- a/test/widget/try_connection_button_test.dart +++ b/test/widget/try_connection_button_test.dart @@ -15,18 +15,14 @@ void main() { group('TryConnectionButton', () { testWidgets('shows "Try connection" button when idle', (tester) async { await tester.pumpWidget( - _wrap( - const TryConnectionButton(testing: false, onPressed: null), - ), + _wrap(const TryConnectionButton(testing: false, onPressed: null)), ); expect(find.text('Try connection'), findsOneWidget); }); testWidgets('shows spinner when testing', (tester) async { await tester.pumpWidget( - _wrap( - const TryConnectionButton(testing: true, onPressed: null), - ), + _wrap(const TryConnectionButton(testing: true, onPressed: null)), ); expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.text('Try connection'), findsNothing);