Two fixes for the UndoLog: 1. Don't delete the original undo log entry when undo is performed. The entry stays in the log alongside the new inverse action, so the user can retry the undo if it was silently reverted by an IMAP sync. 2. Fix IMAP UID mismatch: after an IMAP move is applied on the server the email gets a new UID in the destination folder. The undo service now looks up the email by its RFC 2822 Message-ID when the original row is gone, so the reverse-move pending change carries the correct UID and actually succeeds on the server. Add findEmailByMessageId to EmailRepository interface and impl. Add a regression test that simulates the UID change scenario. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
307 lines
7.7 KiB
Dart
307 lines
7.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
|
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';
|
|
|
|
Future<imap.ImapClient> _fakeImapConnect(
|
|
Account account,
|
|
String username,
|
|
String password,
|
|
) async =>
|
|
throw const SocketException('fake — no real IMAP server in tests');
|
|
|
|
void main() {
|
|
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
|
() async {
|
|
final accounts = _FakeAccounts('pw');
|
|
final mailboxes = _FakeMailboxes();
|
|
final emails = _FakeEmails();
|
|
final logs = _FakeLogs();
|
|
|
|
final manager = AccountSyncManager(
|
|
accounts,
|
|
mailboxes,
|
|
emails,
|
|
syncLog: logs,
|
|
imapConnect: _fakeImapConnect,
|
|
);
|
|
|
|
final a1 = _account('1');
|
|
final a2 = _account('2');
|
|
|
|
manager.start();
|
|
accounts.push([a1, a2]);
|
|
|
|
// Allow some time for listeners to fire.
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
|
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
|
|
|
manager.dispose();
|
|
});
|
|
|
|
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
|
() async {
|
|
final accounts = _FakeAccounts('pw');
|
|
final mailboxes = _FakeMailboxes();
|
|
final emails = _FakeEmails();
|
|
final logs = _FakeLogs();
|
|
|
|
final manager = AccountSyncManager(
|
|
accounts,
|
|
mailboxes,
|
|
emails,
|
|
syncLog: logs,
|
|
);
|
|
|
|
final a1 = _jmapAccount('1');
|
|
final a2 = _jmapAccount('2');
|
|
|
|
manager.start();
|
|
accounts.push([a1, a2]);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
|
|
|
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
|
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
|
|
|
manager.dispose();
|
|
});
|
|
}
|
|
|
|
Account _account(String id) => Account(
|
|
id: id,
|
|
displayName: 'Account $id',
|
|
email: '$id@example.com',
|
|
imapHost: 'localhost',
|
|
imapPort: 143,
|
|
imapSsl: false,
|
|
smtpHost: 'localhost',
|
|
smtpPort: 25,
|
|
smtpSsl: false,
|
|
);
|
|
|
|
Account _jmapAccount(String id) => Account(
|
|
id: id,
|
|
displayName: 'Account $id',
|
|
email: '$id@example.com',
|
|
type: AccountType.jmap,
|
|
jmapUrl: 'http://localhost:8080/.well-known/jmap',
|
|
smtpHost: 'localhost',
|
|
smtpPort: 25,
|
|
smtpSsl: false,
|
|
);
|
|
|
|
class _FakeAccounts implements AccountRepository {
|
|
_FakeAccounts(this.password);
|
|
final String password;
|
|
final _ctrl = StreamController<List<Account>>.broadcast();
|
|
|
|
@override
|
|
Stream<List<Account>> observeAccounts() => _ctrl.stream;
|
|
|
|
@override
|
|
Future<Account?> getAccount(String id) async => null;
|
|
|
|
@override
|
|
Future<String> getPassword(String accountId) async => password;
|
|
|
|
@override
|
|
Future<void> addAccount(Account a, String p) async {}
|
|
@override
|
|
Future<void> removeAccount(String id) async {}
|
|
@override
|
|
Future<void> updateAccount(Account a, {String? password}) async {}
|
|
|
|
void push(List<Account> accounts) => _ctrl.add(accounts);
|
|
}
|
|
|
|
class _FakeMailboxes implements MailboxRepository {
|
|
@override
|
|
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
|
|
Mailbox(
|
|
id: '$accountId:INBOX',
|
|
accountId: accountId ?? '',
|
|
path: 'INBOX',
|
|
name: 'INBOX',
|
|
unreadCount: 0,
|
|
totalCount: 0,
|
|
role: 'inbox',
|
|
),
|
|
]);
|
|
|
|
@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 _FakeEmails implements EmailRepository {
|
|
final syncCounts = <String, int>{};
|
|
|
|
@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<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<SyncEmailsResult> syncEmails(String a, String m) async {
|
|
syncCounts[a] = (syncCounts[a] ?? 0) + 1;
|
|
return SyncEmailsResult.zero;
|
|
}
|
|
|
|
@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<bool> cancelPendingChange(String id, String type) async => false;
|
|
|
|
@override
|
|
Future<void> snoozeEmail(String emailId, DateTime until) async {}
|
|
|
|
@override
|
|
Future<int> wakeUpEmails(String accountId) async => 0;
|
|
|
|
@override
|
|
Future<void> restoreEmails(List<Email> emails) async {}
|
|
|
|
@override
|
|
Future<Email?> findEmailByMessageId(
|
|
String accountId,
|
|
String messageId,
|
|
) async =>
|
|
null;
|
|
|
|
@override
|
|
Future<String?> deleteEmail(String id) async => null;
|
|
|
|
@override
|
|
Stream<String> get onChangesQueued => const Stream.empty();
|
|
|
|
@override
|
|
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
|
|
|
@override
|
|
Future<void> sendEmail(String a, EmailDraft d) async {}
|
|
|
|
@override
|
|
Future<String> downloadAttachment(
|
|
String emailId,
|
|
EmailAttachment attachment,
|
|
) async =>
|
|
'/tmp/${attachment.filename}';
|
|
|
|
@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 address) async => [];
|
|
|
|
@override
|
|
Future<List<EmailAddress>> searchAddresses(
|
|
String? a,
|
|
String q, {
|
|
int limit = 10,
|
|
}) async =>
|
|
[];
|
|
|
|
@override
|
|
Stream<void> watchJmapPush(String accountId, String password) =>
|
|
const Stream.empty();
|
|
|
|
@override
|
|
Future<ReliabilityResult> verifySyncReliability(
|
|
String accountId,
|
|
String mailboxPath,
|
|
) async =>
|
|
ReliabilityResult.healthy;
|
|
|
|
@override
|
|
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
|
Stream.value([]);
|
|
|
|
@override
|
|
Future<void> discardMutation(int id) async {}
|
|
|
|
@override
|
|
Future<void> retryMutation(int id) async {}
|
|
|
|
@override
|
|
Future<void> clearForResync(String accountId) async {}
|
|
}
|
|
|
|
class _FakeLogs implements SyncLogRepository {
|
|
@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 {}
|
|
|
|
@override
|
|
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
|
|
Stream.value([]);
|
|
|
|
@override
|
|
Stream<String?> observeLastError(String accountId) => Stream.value(null);
|
|
}
|