// Integration test for AccountSyncManager — requires a running Stalwart instance. // Run via: stalwart-dev/test.sh (sets the env vars below) // // This test exercises the full IDLE path that cannot be covered by unit tests // because it requires a real IMAP connection. import 'dart:async'; import 'dart:io'; import 'package:enough_mail/enough_mail.dart' show ImapClient, SmtpClient, MessageBuilder, MailAddress; import 'package:flutter_test/flutter_test.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart'; // ── Helpers ─────────────────────────────────────────────────────────────────── String _env(String key) { final v = Platform.environment[key]; if (v == null || v.isEmpty) throw StateError('$key not set'); return v; } // Fake repos that do nothing — the sync manager only needs real IMAP for IDLE. class _FakeAccounts implements AccountRepository { final _ctrl = StreamController>.broadcast(sync: true); String password = ''; @override Stream> observeAccounts() => _ctrl.stream; @override Future getAccount(String id) async => null; @override Future addAccount(Account account, String pass) async {} @override Future updateAccount(Account account, {String? password}) async {} @override Future removeAccount(String id) async {} @override Future getPassword(String accountId) async => password; void push(List accounts) => _ctrl.add(accounts); } class _FakeMailboxes implements MailboxRepository { @override Stream> observeMailboxes(String accountId) => Stream.value([]); @override Future syncMailboxes(String accountId) async => 0; @override Future findMailboxByRole(String accountId, String role) async => null; } class _FakeEmails implements EmailRepository { @override Stream> observeEmails(String a, String m) => Stream.value([]); @override Stream> observeThreads(String a, String m) => Stream.value([]); @override Future getEmail(String id) async => null; @override Future getEmailBody(String id) async => const EmailBody(emailId: '', attachments: []); @override Future syncEmails(String a, String m) async => SyncEmailsResult.zero; @override Future setFlag(String id, {bool? seen, bool? flagged}) async {} @override Future moveEmail(String id, String dest) async {} @override Future deleteEmail(String id) async {} @override Future flushPendingChanges(String accountId, String password) async => 0; @override Future sendEmail(String a, EmailDraft d) async {} @override Future downloadAttachment( String emailId, EmailAttachment attachment, ) async => '/tmp/${attachment.filename}'; @override Future> searchEmails(String a, String m, String q) async => []; @override Future> searchEmailsGlobal(String a, String q) async => []; @override Future> getEmailsByAddress(String a, String address) async => []; @override Stream watchJmapPush(String accountId, String password) => const Stream.empty(); @override Stream> observeFailedMutations(String accountId) => Stream.value([]); @override Future discardMutation(int id) async {} @override Future retryMutation(int id) async {} } Future _sendMessage({ required String host, required int port, required String from, required String pass, required String to, required String subject, }) async { final smtp = SmtpClient('sharedinbox-test'); await smtp.connectToServer(host, port, isSecure: false); await smtp.ehlo(); await smtp.authenticate(from, pass); final builder = MessageBuilder() ..from = [MailAddress('', from)] ..to = [MailAddress('', to)] ..subject = subject ..text = 'IDLE wake-up test body'; await smtp.sendMessage(builder.buildMimeMessage()); await smtp.quit(); } // ── Tests ───────────────────────────────────────────────────────────────────── void main() { late String imapHost; late int imapPort; late int smtpPort; late String user, pass; setUpAll(() { imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1'; imapPort = int.parse(_env('STALWART_IMAP_PORT')); smtpPort = int.parse(_env('STALWART_SMTP_PORT')); user = _env('STALWART_USER_B'); // alice pass = _env('STALWART_PASS_B'); }); test( 'IDLE connects, wakes on new message, and shuts down cleanly', timeout: const Timeout(Duration(seconds: 30)), () async { final firstIdleConnected = Completer(); final secondIdleConnected = Completer(); Object? connectError; Future trackingConnect( Account account, String username, String password, ) async { try { final client = ImapClient( defaultResponseTimeout: const Duration(seconds: 20), ); await client.connectToServer( account.imapHost, account.imapPort, isSecure: false, ); await client.login(username, password); if (!firstIdleConnected.isCompleted) { firstIdleConnected.complete(); } else if (!secondIdleConnected.isCompleted) { secondIdleConnected.complete(); } return client; } catch (e) { connectError ??= e; rethrow; } } final fakeAccounts = _FakeAccounts()..password = pass; final account = Account( id: 'integration-test', displayName: 'Integration Test', email: user, imapHost: imapHost, imapPort: imapPort, imapSsl: false, smtpHost: imapHost, smtpPort: smtpPort, ); final mgr = AccountSyncManager( fakeAccounts, _FakeMailboxes(), _FakeEmails(), imapConnect: trackingConnect, ); addTearDown(mgr.dispose); mgr.start(); fakeAccounts.push([account]); // 1. IDLE connects await firstIdleConnected.future.timeout( const Duration(seconds: 5), onTimeout: () => fail('IDLE did not connect within 5s; error: $connectError'), ); expect(connectError, isNull, reason: 'IMAP connect should succeed'); // 2. Wakes on new message — deliver a message and wait for IDLE to // reconnect, which proves the manager woke up and re-entered IDLE. await _sendMessage( host: imapHost, port: smtpPort, from: user, pass: pass, to: user, subject: 'wake-idle-${DateTime.now().millisecondsSinceEpoch}', ); await secondIdleConnected.future.timeout( const Duration(seconds: 10), onTimeout: () => fail('IDLE did not reconnect after new message within 10s'), ); expect(connectError, isNull, reason: 'reconnect should succeed'); // 3. Shuts down cleanly — dispose() must return quickly without hanging // on the 25-minute IDLE cap. mgr.dispose(); await Future.delayed(const Duration(seconds: 1)); }, ); }