391 lines
12 KiB
Dart
391 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
|
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
Account _account({String id = 'a1'}) => Account(
|
|
id: id,
|
|
displayName: 'Test',
|
|
email: 'test@example.com',
|
|
imapHost: 'localhost',
|
|
);
|
|
|
|
class _FakeAccounts implements AccountRepository {
|
|
final List<Account> accounts;
|
|
_FakeAccounts([Account? account]) : accounts = [account ?? _account()];
|
|
|
|
@override
|
|
Stream<List<Account>> observeAccounts() => Stream.value(accounts);
|
|
@override
|
|
Future<Account?> getAccount(String id) async => accounts
|
|
.cast<Account?>()
|
|
.firstWhere((a) => a?.id == id, orElse: () => null);
|
|
@override
|
|
Future<void> addAccount(Account account, String password) async {}
|
|
@override
|
|
Future<void> updateAccount(Account account, {String? password}) async {}
|
|
@override
|
|
Future<void> removeAccount(String id) async {}
|
|
@override
|
|
Future<String> getPassword(String id) async => 'secret';
|
|
}
|
|
|
|
class _FakeMailboxes implements MailboxRepository {
|
|
final List<Mailbox> mailboxes;
|
|
_FakeMailboxes([this.mailboxes = const []]);
|
|
@override
|
|
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
|
|
Stream.value(mailboxes);
|
|
@override
|
|
Future<int> syncMailboxes(String accountId) async => 0;
|
|
@override
|
|
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
|
null;
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
@override
|
|
Future<Mailbox> createMailboxWithRole(
|
|
String accountId,
|
|
String name,
|
|
String role,
|
|
) async =>
|
|
Mailbox(
|
|
id: '$accountId:$name',
|
|
accountId: accountId,
|
|
path: name,
|
|
name: name,
|
|
role: role,
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
);
|
|
}
|
|
|
|
class _CountingEmails implements EmailRepository {
|
|
int syncCount = 0;
|
|
int wakeUpCount = 0;
|
|
final Exception? syncError;
|
|
|
|
_CountingEmails({this.syncError});
|
|
|
|
@override
|
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailbox) async {
|
|
syncCount++;
|
|
if (syncError != null) throw syncError!;
|
|
return SyncEmailsResult.zero;
|
|
}
|
|
|
|
@override
|
|
Future<int> wakeUpEmails(String accountId) async {
|
|
wakeUpCount++;
|
|
return 0;
|
|
}
|
|
|
|
@override
|
|
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
|
@override
|
|
Stream<List<Email>> observeEmails(String a, String m, {int limit = 50}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<EmailThread>> observeThreads(
|
|
String a,
|
|
String m, {
|
|
int limit = 50,
|
|
}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50}) =>
|
|
Stream.value([]);
|
|
@override
|
|
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
|
Stream.value([]);
|
|
@override
|
|
Future<Email?> getEmail(String id) async => null;
|
|
@override
|
|
Future<EmailBody> getEmailBody(String id) async =>
|
|
const EmailBody(emailId: '', attachments: []);
|
|
@override
|
|
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
|
@override
|
|
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
|
@override
|
|
Future<void> moveEmail(String id, String dest) async {}
|
|
@override
|
|
Future<String?> deleteEmail(String id) async => null;
|
|
@override
|
|
Future<void> sendEmail(String accountId, EmailDraft draft) async {}
|
|
@override
|
|
Future<String> downloadAttachment(String id, EmailAttachment att) async => '';
|
|
@override
|
|
Future<String> fetchRawRfc822(String emailId) async => '';
|
|
@override
|
|
Future<List<Email>> searchEmails(String a, String m, String q) async => [];
|
|
@override
|
|
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
|
@override
|
|
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
|
@override
|
|
Future<List<EmailAddress>> searchAddresses(
|
|
String? a,
|
|
String q, {
|
|
int limit = 10,
|
|
}) async =>
|
|
[];
|
|
@override
|
|
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
|
Stream.value([]);
|
|
@override
|
|
Future<void> discardMutation(int id) async {}
|
|
@override
|
|
Future<void> retryMutation(int id) async {}
|
|
@override
|
|
Future<bool> cancelPendingChange(String id, String type) async => false;
|
|
@override
|
|
Future<void> snoozeEmail(String id, DateTime until) async {}
|
|
@override
|
|
Future<void> restoreEmails(List<Email> emails) async {}
|
|
@override
|
|
Future<Email?> findEmailByMessageId(
|
|
String accountId,
|
|
String messageId,
|
|
) async =>
|
|
null;
|
|
@override
|
|
Stream<String> get onChangesQueued => const Stream.empty();
|
|
@override
|
|
Stream<void> watchJmapPush(String accountId, String password) =>
|
|
const Stream.empty();
|
|
@override
|
|
Future<ReliabilityResult> verifySyncReliability(
|
|
String accountId,
|
|
String mailboxPath,
|
|
) async =>
|
|
ReliabilityResult.healthy;
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
@override
|
|
Future<int> applySieveRules(String accountId) async => 0;
|
|
}
|
|
|
|
class _FakeSyncLog implements SyncLogRepository {
|
|
final logs = <bool>[];
|
|
@override
|
|
Future<void> log({
|
|
required String accountId,
|
|
required bool success,
|
|
String? errorMessage,
|
|
String? stackTrace,
|
|
bool isPermanent = false,
|
|
required String protocol,
|
|
required int emailsFetched,
|
|
required int emailsSkipped,
|
|
required int mailboxesSynced,
|
|
required int pendingFlushed,
|
|
required int bytesTransferred,
|
|
required DateTime startedAt,
|
|
required DateTime finishedAt,
|
|
List<MailboxSyncStats> mailboxStats = const [],
|
|
String? protocolLog,
|
|
}) async {
|
|
logs.add(success);
|
|
}
|
|
|
|
@override
|
|
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
|
|
Stream.value([]);
|
|
|
|
@override
|
|
Stream<String?> observeLastError(String accountId) => Stream.value(null);
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
void main() {
|
|
group('AccountSyncManager backoff', () {
|
|
test('backoff is capped at 900 s after repeated failures', () {
|
|
fakeAsync((async) {
|
|
final emails = _CountingEmails(
|
|
syncError: Exception('connection refused'),
|
|
);
|
|
final syncLog = _FakeSyncLog();
|
|
final manager = AccountSyncManager(
|
|
_FakeAccounts(),
|
|
_FakeMailboxes([
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
]),
|
|
emails,
|
|
syncLog: syncLog,
|
|
imapConnect: (_, __, ___) async =>
|
|
throw Exception('connection refused'),
|
|
);
|
|
|
|
manager.start();
|
|
|
|
// Advance 3 hours — long enough to observe many retries.
|
|
// With max backoff 900 s, we expect at least floor(3*3600/900) = 12
|
|
// attempts, and at most 3*3600/5 = 2160 (if backoff never grew).
|
|
async.elapse(const Duration(hours: 3));
|
|
|
|
final failCount = syncLog.logs.where((ok) => !ok).length;
|
|
expect(
|
|
failCount,
|
|
greaterThan(10),
|
|
reason: 'should have retried many times within 3 h',
|
|
);
|
|
expect(
|
|
failCount,
|
|
lessThan(2200),
|
|
reason: 'backoff must have kicked in — not every 5 s for 3 h',
|
|
);
|
|
|
|
manager.dispose();
|
|
async.elapse(const Duration(seconds: 1));
|
|
});
|
|
});
|
|
|
|
test('backoff resets to 5 s after a successful sync', () {
|
|
fakeAsync((async) {
|
|
int callCount = 0;
|
|
final syncLog = _FakeSyncLog();
|
|
|
|
var failsLeft = 5;
|
|
final customEmails = _OverrideEmails(
|
|
onSync: (_) async {
|
|
callCount++;
|
|
if (failsLeft > 0) {
|
|
failsLeft--;
|
|
throw Exception('transient error');
|
|
}
|
|
return SyncEmailsResult.zero;
|
|
},
|
|
);
|
|
|
|
final manager = AccountSyncManager(
|
|
_FakeAccounts(),
|
|
_FakeMailboxes([
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
]),
|
|
customEmails,
|
|
syncLog: syncLog,
|
|
imapConnect: (_, __, ___) async =>
|
|
throw Exception('skip idle — force immediate loop'),
|
|
);
|
|
|
|
manager.start();
|
|
|
|
// Allow errors + backoff to build up, then a success, then more loops.
|
|
async.elapse(const Duration(seconds: 3600));
|
|
|
|
// After success, backoff should reset; failures before success should
|
|
// be exactly 5, and subsequent loops should fire frequently.
|
|
final successCount = syncLog.logs.where((ok) => ok).length;
|
|
expect(
|
|
successCount,
|
|
greaterThan(0),
|
|
reason: 'should have at least one success',
|
|
);
|
|
expect(
|
|
callCount,
|
|
greaterThan(5),
|
|
reason: 'should retry after failures and continue after success',
|
|
);
|
|
|
|
manager.dispose();
|
|
async.elapse(const Duration(seconds: 1));
|
|
});
|
|
});
|
|
|
|
test('concurrent sync errors from multiple accounts stay bounded', () {
|
|
fakeAsync((async) {
|
|
final accounts = _FakeAccounts()
|
|
..accounts.add(_account(id: 'a2'))
|
|
..accounts.add(_account(id: 'a3'));
|
|
final syncLog = _FakeSyncLog();
|
|
final manager = AccountSyncManager(
|
|
accounts,
|
|
_FakeMailboxes([
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a1',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a2',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
const Mailbox(
|
|
id: 'INBOX',
|
|
accountId: 'a3',
|
|
path: 'INBOX',
|
|
name: 'Inbox',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
),
|
|
]),
|
|
_CountingEmails(syncError: Exception('network error')),
|
|
syncLog: syncLog,
|
|
imapConnect: (_, __, ___) async =>
|
|
throw Exception('connection refused'),
|
|
);
|
|
|
|
manager.start();
|
|
async.elapse(const Duration(hours: 2));
|
|
|
|
// All 3 accounts retry, each bounded by the 900 s cap.
|
|
final failCount = syncLog.logs.where((ok) => !ok).length;
|
|
expect(failCount, greaterThan(5));
|
|
expect(
|
|
failCount,
|
|
lessThan(5000),
|
|
reason: 'backoff must be in effect across all accounts',
|
|
);
|
|
|
|
manager.dispose();
|
|
async.elapse(const Duration(seconds: 1));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── _OverrideEmails ───────────────────────────────────────────────────────────
|
|
|
|
class _OverrideEmails extends _CountingEmails {
|
|
_OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync})
|
|
: _onSync = onSync;
|
|
|
|
final Future<SyncEmailsResult> Function(String) _onSync;
|
|
|
|
@override
|
|
Future<SyncEmailsResult> syncEmails(String accountId, String mailbox) =>
|
|
_onSync(mailbox);
|
|
}
|