2026-04-16 11:48:37 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
|
2026-04-16 13:44:55 +02:00
|
|
|
import 'package:fake_async/fake_async.dart';
|
2026-04-16 11:48:37 +02:00
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
// ── 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 {}
|
|
|
|
|
|
2026-04-18 15:13:47 +02:00
|
|
|
@override
|
|
|
|
|
Future<void> updateAccount(Account account, {String? password}) async {}
|
|
|
|
|
|
2026-04-16 11:48:37 +02:00
|
|
|
@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<void> syncMailboxes(String accountId) async {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 13:44:55 +02:00
|
|
|
class FailingMailboxRepository implements MailboxRepository {
|
|
|
|
|
@override
|
|
|
|
|
Stream<List<Mailbox>> observeMailboxes(String accountId) => Stream.value([]);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> syncMailboxes(String accountId) async =>
|
|
|
|
|
throw Exception('simulated sync failure');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 11:48:37 +02:00
|
|
|
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<void> syncEmails(String accountId, String mailboxPath) async {}
|
|
|
|
|
|
|
|
|
|
@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<void> sendEmail(String accountId, EmailDraft draft) async {}
|
|
|
|
|
|
2026-04-18 17:04:25 +02:00
|
|
|
@override
|
|
|
|
|
Future<String> downloadAttachment(
|
|
|
|
|
String emailId, EmailAttachment attachment) async =>
|
|
|
|
|
'/tmp/${attachment.filename}';
|
|
|
|
|
|
2026-04-16 11:48:37 +02:00
|
|
|
@override
|
|
|
|
|
Future<List<Email>> searchEmails(
|
|
|
|
|
String accountId,
|
|
|
|
|
String mailboxPath,
|
|
|
|
|
String query,
|
|
|
|
|
) async =>
|
|
|
|
|
[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const _account = Account(
|
|
|
|
|
id: 'test-account',
|
|
|
|
|
displayName: 'Test',
|
|
|
|
|
email: 'test@example.com',
|
|
|
|
|
imapHost: 'imap.example.com',
|
|
|
|
|
smtpHost: 'smtp.example.com',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
});
|
2026-04-16 13:44:55 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-16 11:48:37 +02:00
|
|
|
});
|
|
|
|
|
}
|