Files
sharedinbox/test/unit/account_sync_manager_test.dart
T
Thomas GüttlerandClaude Sonnet 4.6 a27342c7e9 feat: add per-mailbox breakdown to sync log (schema v12)
Each sync cycle now records per-mailbox fetched/skipped/bytes in a new
sync_log_mailboxes table and displays a collapsible "Per mailbox" section
in the sync log screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:19:40 +02:00

447 lines
13 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';
// ── Fakes ─────────────────────────────────────────────────────────────────────
class FakeAccountRepository implements AccountRepository {
// sync:true so listeners fire immediately inside push() — lets us control
// the event order in tests without await.
final _ctrl = StreamController<List<Account>>.broadcast(sync: true);
@override
Stream<List<Account>> observeAccounts() => _ctrl.stream;
@override
Future<Account?> getAccount(String id) async => 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 accountId) async => 'password';
void push(List<Account> accounts) => _ctrl.add(accounts);
void close() => _ctrl.close();
}
class FakeMailboxRepository implements MailboxRepository {
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) => Stream.value([]);
@override
Future<int> syncMailboxes(String accountId) async => 0;
}
class FailingMailboxRepository implements MailboxRepository {
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) => Stream.value([]);
@override
Future<int> syncMailboxes(String accountId) async =>
throw Exception('simulated sync failure');
}
class FakeEmailRepository implements EmailRepository {
@override
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) =>
Stream.value([]);
@override
Future<Email?> getEmail(String emailId) async => null;
@override
Future<EmailBody> getEmailBody(String emailId) async =>
const EmailBody(emailId: '', attachments: []);
@override
Future<SyncEmailsResult> syncEmails(
String accountId,
String mailboxPath,
) async =>
SyncEmailsResult.zero;
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
@override
Future<void> deleteEmail(String emailId) async {}
@override
Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override
Future<void> sendEmail(String accountId, EmailDraft draft) async {}
@override
Future<String> downloadAttachment(
String emailId,
EmailAttachment attachment,
) async =>
'/tmp/${attachment.filename}';
@override
Future<List<Email>> searchEmails(
String accountId,
String mailboxPath,
String query,
) async =>
[];
@override
Stream<void> watchJmapPush(String accountId, String password) =>
const Stream.empty();
@override
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
Stream.value([]);
@override
Future<void> discardMutation(int id) async {}
@override
Future<void> retryMutation(int id) async {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account(
id: 'test-account',
displayName: 'Test',
email: 'test@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
const _jmapAccount = Account(
id: 'jmap-account',
displayName: 'Test JMAP',
email: 'test@example.com',
type: AccountType.jmap,
jmapUrl: 'https://jmap.example.com/.well-known/jmap',
);
class FakeMailboxRepositoryWithInbox implements MailboxRepository {
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) => Stream.value([
const Mailbox(
id: 'jmap-account:mbx1',
accountId: 'jmap-account',
path: 'mbx1',
name: 'Inbox',
unreadCount: 0,
totalCount: 0,
),
]);
@override
Future<int> syncMailboxes(String accountId) async => 0;
}
class _CountingEmailRepository extends FakeEmailRepository {
final List<String> syncedPaths = [];
@override
Future<SyncEmailsResult> syncEmails(
String accountId,
String mailboxPath,
) async {
syncedPaths.add(mailboxPath);
return SyncEmailsResult.zero;
}
}
class FailingJmapEmailRepository extends FakeEmailRepository {
int syncCount = 0;
@override
Future<SyncEmailsResult> syncEmails(
String accountId,
String mailboxPath,
) async {
syncCount++;
if (syncCount == 1) throw Exception('simulated JMAP failure');
return SyncEmailsResult.zero;
}
}
class _CapturingSyncLogRepository implements SyncLogRepository {
final List<SyncLogEntry> entries = [];
@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 [],
}) async {
entries.add(
SyncLogEntry(
id: entries.length,
result: success ? 'ok' : 'error',
errorMessage: errorMessage,
protocol: protocol,
emailsFetched: emailsFetched,
emailsSkipped: emailsSkipped,
mailboxesSynced: mailboxesSynced,
pendingFlushed: pendingFlushed,
bytesTransferred: bytesTransferred,
startedAt: startedAt,
finishedAt: finishedAt,
),
);
}
@override
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
Stream.value(List.of(entries));
}
// ── Tests ─────────────────────────────────────────────────────────────────────
void main() {
group('AccountSyncManager', () {
test('dispose without start does not throw', () {
final mgr = AccountSyncManager(
FakeAccountRepository(),
FakeMailboxRepository(),
FakeEmailRepository(),
);
expect(mgr.dispose, returnsNormally);
});
test('start and immediate dispose (no accounts) does not throw', () {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepository(),
FakeEmailRepository(),
);
mgr.start();
mgr.dispose();
});
test('starts a sync when an account is pushed, then stops on dispose',
() async {
final accounts = FakeAccountRepository();
final mailboxes = FakeMailboxRepository();
final emails = FakeEmailRepository();
final mgr = AccountSyncManager(accounts, mailboxes, emails);
mgr.start();
// With sync:true controller the listener fires synchronously, creating
// an _AccountSync and calling start(). The async _loop() then suspends
// at the first await inside _sync().
accounts.push([_account]);
// Calling dispose() here sets _running = false on the sync before the
// loop reaches _idle(), so _idle() exits early without a network call.
mgr.dispose();
// Drain microtasks so the abandoned _loop() future completes cleanly.
await pumpEventQueue(times: 10);
});
test('stops sync for removed account', () async {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepository(),
FakeEmailRepository(),
);
mgr.start();
accounts.push([_account]);
// Remove the account — the manager should stop its sync.
accounts.push([]);
mgr.dispose();
await pumpEventQueue(times: 10);
});
test('IMAP _sync iterates all mailboxes from mailbox repository', () {
fakeAsync((async) {
final accounts = FakeAccountRepository();
final emails = _CountingEmailRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepositoryWithInbox(),
emails,
imapConnect: (_, __, ___) =>
Future.error(Exception('no IMAP in test')),
);
mgr.start();
accounts.push([_account]);
async.flushMicrotasks();
expect(emails.syncedPaths, contains('mbx1'));
mgr.dispose();
async.elapse(const Duration(seconds: 10));
async.flushMicrotasks();
});
});
group('JMAP accounts', () {
test('starts a JMAP sync loop when a JMAP account is pushed', () async {
final accounts = FakeAccountRepository();
final emails = FakeEmailRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepositoryWithInbox(),
emails,
);
mgr.start();
accounts.push([_jmapAccount]);
mgr.dispose();
await pumpEventQueue();
});
test('JMAP stop() interrupts the poll wait', () {
fakeAsync((async) {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepositoryWithInbox(),
FakeEmailRepository(),
);
mgr.start();
accounts.push([_jmapAccount]);
async.flushMicrotasks();
// Dispose before the 30-second poll interval elapses.
mgr.dispose();
async.elapse(const Duration(seconds: 35));
async.flushMicrotasks();
});
});
test('JMAP backoff on sync failure', () {
fakeAsync((async) {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepositoryWithInbox(),
FailingJmapEmailRepository(),
);
mgr.start();
accounts.push([_jmapAccount]);
async.flushMicrotasks();
mgr.dispose();
async.elapse(const Duration(seconds: 10));
async.flushMicrotasks();
});
});
});
test('logs error and applies backoff when sync fails', () {
fakeAsync((async) {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FailingMailboxRepository(),
FakeEmailRepository(),
);
mgr.start();
// Sync: true controller fires the listener synchronously; _loop()
// starts and suspends at the first await inside _sync().
accounts.push([_account]);
// Drain microtasks: syncMailboxes throws, the catch block runs and
// schedules a Future.delayed(5 s) backoff timer.
async.flushMicrotasks();
// Stop the manager before the backoff expires so the loop exits
// cleanly after the delay rather than retrying indefinitely.
mgr.dispose();
// Advance past the 5-second backoff so Future.delayed completes and
// the _backoffSeconds update (line 97) is executed.
async.elapse(const Duration(seconds: 10));
async.flushMicrotasks();
});
});
group('sync errors are visible in the sync log', () {
test('IMAP sync failure writes an error entry to the sync log', () {
fakeAsync((async) {
final accounts = FakeAccountRepository();
final syncLog = _CapturingSyncLogRepository();
final mgr = AccountSyncManager(
accounts,
FailingMailboxRepository(),
FakeEmailRepository(),
syncLog: syncLog,
);
mgr.start();
accounts.push([_account]);
async.flushMicrotasks();
expect(syncLog.entries, hasLength(1));
expect(syncLog.entries.first.isOk, isFalse);
expect(syncLog.entries.first.errorMessage, isNotEmpty);
expect(syncLog.entries.first.protocol, 'imap');
mgr.dispose();
async.elapse(const Duration(seconds: 10));
async.flushMicrotasks();
});
});
test('JMAP sync failure writes an error entry to the sync log', () {
fakeAsync((async) {
final accounts = FakeAccountRepository();
final syncLog = _CapturingSyncLogRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepositoryWithInbox(),
FailingJmapEmailRepository(),
syncLog: syncLog,
);
mgr.start();
accounts.push([_jmapAccount]);
async.flushMicrotasks();
expect(syncLog.entries, hasLength(1));
expect(syncLog.entries.first.isOk, isFalse);
expect(syncLog.entries.first.errorMessage, isNotEmpty);
expect(syncLog.entries.first.protocol, 'jmap');
mgr.dispose();
async.elapse(const Duration(seconds: 10));
async.flushMicrotasks();
});
});
});
});
}