Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 d9017eb362 test(R6): add fake_async backoff stress tests for AccountSyncManager
Verifies that the sync loop's exponential backoff caps at 900 s, resets
after a successful sync, and stays bounded across multiple concurrent accounts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:33:49 +02:00
Bot of Thomas Güttler fc592c475f feat(R4): dismissible sync error banner in email list (#23) 2026-05-13 23:14:44 +02:00
Bot of Thomas Güttler beae8d8843 feat(R2): force full sync escape hatch in account edit screen (#22) 2026-05-13 22:57:36 +02:00
Bot of Thomas Güttler eddcc17c41 fix(R1): persist undo history across restarts (#20) 2026-05-13 22:35:08 +02:00
20 changed files with 719 additions and 60 deletions
@@ -99,4 +99,9 @@ abstract class EmailRepository {
String accountId,
String mailboxPath,
);
/// Deletes all locally-cached email rows and pending changes for [accountId],
/// while preserving EmailBodies so already-downloaded content is not lost.
/// Also resets sync-state checkpoints so the next sync fetches everything fresh.
Future<void> clearForResync(String accountId);
}
@@ -8,4 +8,7 @@ abstract class MailboxRepository {
/// Returns the first mailbox with the given [role] for [accountId], or null.
Future<Mailbox?> findMailboxByRole(String accountId, String role);
/// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId);
}
@@ -65,6 +65,10 @@ abstract class SyncLogRepository {
});
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId);
/// Emits the error message of the most recent sync attempt for [accountId],
/// or null when the last sync succeeded (or no syncs have run yet).
Stream<String?> observeLastError(String accountId);
}
class NoOpSyncLogRepository implements SyncLogRepository {
@@ -90,4 +94,7 @@ class NoOpSyncLogRepository implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
+13 -4
View File
@@ -10,12 +10,19 @@ class UndoService extends StateNotifier<List<UndoAction>> {
final Ref _ref;
static const int _maxHistory = 10;
// Resolves once init() has loaded persisted history. Default to an already-
// resolved future so operations are safe even if init() is never called.
Future<void> _ready = Future.value();
Future<void> init() async {
final repo = _ref.read(undoRepositoryProvider);
state = await repo.getHistory();
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
if (mounted) state = history;
});
await _ready;
}
void pushAction(UndoAction action) {
Future<void> pushAction(UndoAction action) async {
await _ready;
final newList = [...state, action];
if (newList.length > _maxHistory) {
final removed = newList.removeAt(0);
@@ -25,12 +32,14 @@ class UndoService extends StateNotifier<List<UndoAction>> {
unawaited(_ref.read(undoRepositoryProvider).saveAction(action));
}
void clear() {
Future<void> clear() async {
await _ready;
state = [];
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
}
Future<void> undo({String? actionId}) async {
await _ready;
if (state.isEmpty) return;
final UndoAction action;
+37
View File
@@ -88,6 +88,43 @@ class AccountSyncManager {
void syncNow(String accountId) {
_active[accountId]?.kick();
}
/// Clears all locally-cached emails and mailboxes for [accountId], then
/// immediately starts a fresh sync cycle. Use this as an escape hatch when
/// the local DB is believed to be out of sync with the server.
Future<void> forceResync(String accountId) async {
_active.remove(accountId)?.stop();
await _emails.clearForResync(accountId);
await _mailboxes.clearForResync(accountId);
final accounts = await _accounts.observeAccounts().first;
final account = accounts.cast<Account?>().firstWhere(
(a) => a?.id == accountId,
orElse: () => null,
);
if (account == null) return;
final loop = switch (account.type) {
AccountType.imap => _AccountSync(
account,
_accounts,
_mailboxes,
_emails,
_imapConnect,
_syncLog,
),
AccountType.jmap => _JmapAccountSync(
account,
_mailboxes,
_emails,
_accounts,
_syncLog,
),
};
_active[accountId] = loop;
loop.start();
}
}
// ── Shared interface ──────────────────────────────────────────────────────────
@@ -2739,4 +2739,27 @@ class EmailRepositoryImpl implements EmailRepository {
const PendingChangesCompanion(attempts: Value(0), lastError: Value(null)),
);
}
@override
Future<void> clearForResync(String accountId) async {
// Disable FK constraints so EmailBodies rows survive the emails deletion.
// When emails are re-inserted after the next sync with the same IDs, the
// cached body content will be reused without a network round-trip.
await _db.customStatement('PRAGMA foreign_keys = OFF');
try {
await _db.transaction(() async {
await (_db.delete(_db.emails)
..where((t) => t.accountId.equals(accountId)))
.go();
await (_db.delete(_db.pendingChanges)
..where((t) => t.accountId.equals(accountId)))
.go();
await (_db.delete(_db.syncStates)
..where((t) => t.accountId.equals(accountId)))
.go();
});
} finally {
await _db.customStatement('PRAGMA foreign_keys = ON');
}
}
}
@@ -303,4 +303,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
if (mb.isJunk) return 'junk';
return null;
}
@override
Future<void> clearForResync(String accountId) async {
await (_db.delete(_db.mailboxes)
..where((t) => t.accountId.equals(accountId)))
.go();
}
}
@@ -99,4 +99,14 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
return entries;
});
}
@override
Stream<String?> observeLastError(String accountId) {
return (_db.select(_db.syncLogs)
..where((t) => t.accountId.equals(accountId))
..orderBy([(t) => OrderingTerm.desc(t.startedAt)])
..limit(1))
.watchSingleOrNull()
.map((row) => (row?.result == 'error') ? row?.errorMessage : null);
}
}
+5
View File
@@ -85,6 +85,11 @@ final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
final syncLastErrorProvider =
StreamProvider.autoDispose.family<String?, String>((ref, accountId) {
return ref.watch(syncLogRepositoryProvider).observeLastError(accountId);
});
final reliabilityRunnerProvider = Provider<ReliabilityRunner>((ref) {
final runner = ReliabilityRunner(
ref.watch(dbProvider),
+49 -4
View File
@@ -43,6 +43,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
bool _tryTesting = false;
String? _tryOk;
String? _tryErr;
bool _resyncing = false;
@override
void initState() {
@@ -170,6 +171,43 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
}
}
Future<void> _forceResync() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Force full sync?'),
content: const Text(
'This clears all locally-cached emails and mailboxes for this '
'account and immediately re-downloads everything from the server. '
'Previously viewed email content will not need to be re-downloaded.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Force sync'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _resyncing = true);
try {
await ref.read(syncManagerProvider).forceResync(widget.accountId);
if (mounted) context.pop();
} catch (e) {
if (mounted) {
setState(() {
_resyncing = false;
_errorMessage = 'Force sync failed: $e';
});
}
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final password = _passwordCtrl.text.isNotEmpty ? _passwordCtrl.text : null;
@@ -230,11 +268,9 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit account')),
body: _loading
body: _loading || _saving || _resyncing
? const Center(child: CircularProgressIndicator())
: _saving
? const Center(child: CircularProgressIndicator())
: _buildForm(),
: _buildForm(),
);
}
@@ -350,6 +386,15 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
),
const SizedBox(height: 8),
FilledButton(onPressed: _save, child: const Text('Save')),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.sync_problem),
label: const Text('Force full sync'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: _forceResync,
),
],
),
),
+24 -20
View File
@@ -123,17 +123,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
),
),
);
);
}
if (context.mounted) context.pop();
@@ -354,16 +356,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: chosen,
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: chosen,
),
),
);
);
if (context.mounted) context.pop();
}
@@ -384,7 +388,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
sourceMailboxPath: header.mailboxPath,
originalEmails: [header],
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
await repo.snoozeEmail(widget.emailId, until);
if (context.mounted) {
+56 -9
View File
@@ -35,6 +35,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searchLoading = false;
bool get _searching => _searchController.text.isNotEmpty;
// Error banner — tracks the last error message that the user dismissed.
String? _dismissedError;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
@@ -131,9 +134,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
body: Column(
children: [
_buildSyncErrorBanner(),
Expanded(
child: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
),
],
),
);
}
@@ -267,6 +277,39 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
return _buildEmailList(_searchResults!);
}
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.valueOrNull;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text(
error,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
leading: Icon(
Icons.sync_problem,
color: Theme.of(context).colorScheme.error,
),
backgroundColor: Theme.of(context).colorScheme.errorContainer,
actions: [
TextButton(
onPressed: () {
ref.read(syncManagerProvider).syncNow(widget.accountId);
},
child: const Text('Retry'),
),
TextButton(
onPressed: () => setState(() => _dismissedError = error),
child: const Text('Dismiss'),
),
],
);
}
Widget _buildStreamBody(EmailRepository emailRepo) {
return RefreshIndicator(
onRefresh: () async {
@@ -331,7 +374,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: mailbox.path,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() =>
@@ -364,7 +407,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchMarkSpam() =>
@@ -426,7 +469,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: chosen,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchSnooze() async {
@@ -458,7 +501,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
sourceMailboxPath: widget.mailboxPath,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
if (!mounted) return;
@@ -609,7 +652,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
} else {
String? lastDestPath;
for (final id in t.emailIds) {
@@ -625,7 +670,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
ref.read(undoServiceProvider.notifier).pushAction(action);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
}
},
child: tile,
+12 -10
View File
@@ -256,17 +256,19 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
final destPath = await repo.deleteEmail(widget.email.id);
if (original != null) {
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
),
),
);
);
}
}
}
+4 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
@@ -22,7 +24,8 @@ class UndoLogScreen extends ConsumerWidget {
tooltip: 'Clear history',
onPressed: history.isEmpty
? null
: () => ref.read(undoServiceProvider.notifier).clear(),
: () =>
unawaited(ref.read(undoServiceProvider.notifier).clear()),
),
],
),
@@ -96,6 +96,9 @@ class _FakeMailboxes implements MailboxRepository {
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
null;
@override
Future<void> clearForResync(String accountId) async {}
}
class _FakeEmails implements EmailRepository {
@@ -191,6 +194,9 @@ class _FakeEmails implements EmailRepository {
@override
Future<void> retryMutation(int id) async {}
@override
Future<void> clearForResync(String accountId) async {}
}
class _FakeLogs implements SyncLogRepository {
@@ -214,4 +220,7 @@ class _FakeLogs implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
+8
View File
@@ -98,6 +98,9 @@ class FakeEmailRepository implements EmailRepository {
String mailboxPath,
) async =>
ReliabilityResult.healthy;
@override
Future<void> clearForResync(String accountId) async {}
}
class _Log {
@@ -129,6 +132,9 @@ class FakeSyncLogRepository implements SyncLogRepository {
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value([]);
@override
Stream<String?> observeLastError(String accountId) => Stream.value(null);
}
class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@@ -148,4 +154,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<int> syncMailboxes(String id) async => 1;
@override
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override
Future<void> clearForResync(String accountId) async {}
}
+348
View File
@@ -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);
}
+4 -4
View File
@@ -109,7 +109,7 @@ void main() {
sourceMailboxPath: 'INBOX',
originalEmails: [original!],
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox
@@ -190,7 +190,7 @@ void main() {
emailIds: [emailId],
sourceMailboxPath: 'INBOX',
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox
@@ -230,7 +230,7 @@ void main() {
destinationMailboxPath: destPath,
originalEmails: [original!],
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 4. Verify local state
@@ -273,7 +273,7 @@ void main() {
sourceMailboxPath: 'INBOX',
originalEmails: [original!],
);
container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).pushAction(action);
await container.read(undoServiceProvider.notifier).undo();
// 3. Verify it is back in Inbox and metadata is cleared
+90 -8
View File
@@ -61,10 +61,10 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init(); // Wait for persistent load
notifier.pushAction(action1);
await notifier.pushAction(action1);
expect(container.read(undoServiceProvider), [action1]);
notifier.pushAction(action2);
await notifier.pushAction(action2);
expect(container.read(undoServiceProvider), [action1, action2]);
});
@@ -91,8 +91,8 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action1);
notifier.pushAction(action2);
await notifier.pushAction(action1);
await notifier.pushAction(action2);
await notifier.undo();
expect(container.read(undoServiceProvider), [action1]);
@@ -126,8 +126,8 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action1);
notifier.pushAction(action2);
await notifier.pushAction(action1);
await notifier.pushAction(action2);
await notifier.undo(actionId: '1');
expect(container.read(undoServiceProvider), [action2]);
@@ -154,7 +154,7 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action);
await notifier.pushAction(action);
await notifier.undo();
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
@@ -193,11 +193,93 @@ void main() {
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
notifier.pushAction(action);
await notifier.pushAction(action);
await notifier.undo();
verify(mockEmailRepo.restoreEmails(any)).called(1);
verify(mockEmailRepo.moveEmail('e1', 'INBOX')).called(1);
});
test('init loads persisted history from repository', () async {
final persisted = UndoAction(
id: '99',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e99'],
sourceMailboxPath: 'INBOX',
);
when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => [persisted]);
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
expect(container.read(undoServiceProvider), [persisted]);
});
test('pushAction after restart appends to persisted history', () async {
final persisted = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
final newAction = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e2'],
sourceMailboxPath: 'INBOX',
);
when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer((_) async => [persisted]);
final notifier = container.read(undoServiceProvider.notifier);
await notifier.init();
await notifier.pushAction(newAction);
expect(container.read(undoServiceProvider), [persisted, newAction]);
});
test('pushAction concurrent with init waits for init to complete', () async {
final persisted = UndoAction(
id: '1',
accountId: 'acc1',
type: UndoType.move,
emailIds: ['e1'],
sourceMailboxPath: 'INBOX',
);
final raced = UndoAction(
id: '2',
accountId: 'acc1',
type: UndoType.delete,
emailIds: ['e2'],
sourceMailboxPath: 'INBOX',
);
// Simulate slow DB load
when(
mockUndoRepo.getHistory(limit: anyNamed('limit')),
).thenAnswer(
(_) => Future.delayed(
const Duration(milliseconds: 10),
() => [persisted],
),
);
final notifier = container.read(undoServiceProvider.notifier);
final initFuture = notifier.init();
// pushAction issued before init completes — it must still see persisted history
final pushFuture = notifier.pushAction(raced);
await Future.wait([initFuture, pushFuture]);
expect(container.read(undoServiceProvider), [persisted, raced]);
});
}
+5
View File
@@ -132,6 +132,8 @@ class FakeMailboxRepository implements MailboxRepository {
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull;
@override
Future<void> clearForResync(String accountId) async {}
}
class FakeEmailRepository implements EmailRepository {
@@ -279,6 +281,9 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> retryMutation(int id) async {}
@override
Future<void> clearForResync(String accountId) async {}
}
// ---------------------------------------------------------------------------