Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9017eb362 |
@@ -0,0 +1,348 @@
|
||||
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 {}
|
||||
}
|
||||
|
||||
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) => Stream.value([]);
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(String a, String m) =>
|
||||
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> 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<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
|
||||
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
|
||||
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 {}
|
||||
}
|
||||
|
||||
class _FakeSyncLog implements SyncLogRepository {
|
||||
final logs = <bool>[];
|
||||
@override
|
||||
Future<void> log({
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user