fix: format, analyze-fix and update mocks
This commit is contained in:
@@ -16,7 +16,8 @@ Future<imap.ImapClient> _fakeImapConnect(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async => throw const SocketException('fake — no real IMAP server in tests');
|
||||
) async =>
|
||||
throw const SocketException('fake — no real IMAP server in tests');
|
||||
|
||||
void main() {
|
||||
test(
|
||||
@@ -83,27 +84,27 @@ void main() {
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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);
|
||||
@@ -132,16 +133,16 @@ class _FakeAccounts implements AccountRepository {
|
||||
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',
|
||||
),
|
||||
]);
|
||||
Mailbox(
|
||||
id: '$accountId:INBOX',
|
||||
accountId: accountId ?? '',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
@@ -158,15 +159,16 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeEmails implements EmailRepository {
|
||||
@@ -181,7 +183,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
@@ -225,7 +228,8 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@@ -243,7 +247,8 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async => '/tmp/${attachment.filename}';
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => '';
|
||||
@@ -262,7 +267,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
@@ -272,7 +278,8 @@ class _FakeEmails implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
|
||||
@@ -246,9 +246,8 @@ void main() {
|
||||
);
|
||||
|
||||
// Alice and bob each received at least msgCount messages.
|
||||
final aliceEmails = allEmails
|
||||
.where((e) => e.accountId == 'alice')
|
||||
.toList();
|
||||
final aliceEmails =
|
||||
allEmails.where((e) => e.accountId == 'alice').toList();
|
||||
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
|
||||
expect(
|
||||
aliceEmails.length,
|
||||
|
||||
@@ -138,7 +138,7 @@ void main() {
|
||||
}
|
||||
|
||||
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
|
||||
makeRepo() {
|
||||
makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final storage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, storage);
|
||||
@@ -346,9 +346,7 @@ void main() {
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a legacy row with no cachedAt.
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('stale text'),
|
||||
@@ -374,9 +372,7 @@ void main() {
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a row cached 8 days ago.
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('old text'),
|
||||
|
||||
@@ -107,8 +107,7 @@ void main() {
|
||||
AccountRepositoryImpl accounts,
|
||||
EmailRepositoryImpl emails,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
})
|
||||
makeRepo() {
|
||||
}) makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final emails = EmailRepositoryImpl(
|
||||
@@ -128,13 +127,12 @@ void main() {
|
||||
) async {
|
||||
await accounts.addAccount(account, userPass);
|
||||
await mailboxes.syncMailboxes('test-jmap');
|
||||
final row =
|
||||
await (db.select(db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final row = await (db.select(db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('inbox'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw StateError('INBOX not found after syncMailboxes');
|
||||
return row.path;
|
||||
}
|
||||
@@ -272,21 +270,18 @@ void main() {
|
||||
);
|
||||
|
||||
// A sent copy should appear in the Sent mailbox.
|
||||
final sentRow =
|
||||
await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final sentRow = await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('sent'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final sentId = sentRow?.path;
|
||||
|
||||
if (sentId != null) {
|
||||
await r.emails.syncEmails('test-jmap', sentId);
|
||||
final sentEmails = await r.emails
|
||||
.observeEmails('test-jmap', sentId)
|
||||
.first;
|
||||
final sentEmails =
|
||||
await r.emails.observeEmails('test-jmap', sentId).first;
|
||||
expect(sentEmails.any((e) => e.subject == subject), isTrue);
|
||||
} else {
|
||||
// If no Sent mailbox exists, just verify sendEmail didn't throw.
|
||||
@@ -353,13 +348,12 @@ void main() {
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
// Find a destination mailbox (Trash).
|
||||
final trashRow =
|
||||
await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('trash'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
final trashRow = await (r.db.select(r.db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals('test-jmap') & t.role.equals('trash'),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (trashRow == null) {
|
||||
markTestSkipped('No trash mailbox found on this Stalwart instance');
|
||||
return;
|
||||
|
||||
@@ -76,8 +76,7 @@ void main() {
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
})
|
||||
makeRepo() {
|
||||
}) makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
|
||||
@@ -107,9 +107,7 @@ void main() {
|
||||
'verifySyncReliability identifies extra local emails (missing on server)',
|
||||
() async {
|
||||
// 1. Manually insert a row into local DB that doesn't exist on server
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'test:999',
|
||||
accountId: 'test',
|
||||
|
||||
@@ -78,7 +78,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -113,7 +114,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
@@ -138,7 +140,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
@override
|
||||
Stream<void> watchJmapPush(String a, String p) => const Stream.empty();
|
||||
@override
|
||||
@@ -153,7 +156,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
@@ -201,16 +205,16 @@ class FakeSyncLogRepository implements SyncLogRepository {
|
||||
class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
|
||||
const Mailbox(
|
||||
id: '1:INBOX',
|
||||
accountId: '1',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
const Mailbox(
|
||||
id: '1:INBOX',
|
||||
accountId: '1',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
@override
|
||||
Future<int> syncMailboxes(String id) async => 1;
|
||||
@override
|
||||
@@ -222,15 +226,16 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||
@@ -248,11 +253,11 @@ class _AccountRepositoryWithMissingPlugin implements AccountRepository {
|
||||
|
||||
@override
|
||||
Future<String> getPassword(String accountId) => Future.error(
|
||||
MissingPluginException(
|
||||
'No implementation found for method read on channel '
|
||||
'plugins.it.nomads.com/flutter_secure_storage',
|
||||
),
|
||||
);
|
||||
MissingPluginException(
|
||||
'No implementation found for method read on channel '
|
||||
'plugins.it.nomads.com/flutter_secure_storage',
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> addAccount(Account account, String password) async {}
|
||||
|
||||
@@ -40,9 +40,7 @@ Future<String> _insertInboxEmail(
|
||||
String from = 'sender@example.com',
|
||||
String mailboxPath = 'INBOX',
|
||||
}) async {
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
@@ -59,9 +57,7 @@ Future<String> _insertInboxEmail(
|
||||
),
|
||||
);
|
||||
// Insert a thread row so _updateThread does not throw.
|
||||
await db
|
||||
.into(db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
await db.into(db.threads).insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
@@ -75,9 +71,7 @@ Future<String> _insertInboxEmail(
|
||||
|
||||
/// Creates an active Sieve script for the test account.
|
||||
Future<void> _insertSieveScript(AppDatabase db, String content) async {
|
||||
await db
|
||||
.into(db.localSieveScripts)
|
||||
.insert(
|
||||
await db.into(db.localSieveScripts).insert(
|
||||
LocalSieveScriptsCompanion.insert(
|
||||
accountId: _account.id,
|
||||
name: 'test-script',
|
||||
@@ -224,9 +218,7 @@ if header :contains "subject" ["SPAM"] {
|
||||
}
|
||||
''');
|
||||
// Insert without messageId.
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'sieve-acc:2',
|
||||
accountId: _account.id,
|
||||
@@ -236,9 +228,7 @@ if header :contains "subject" ["SPAM"] {
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.threads)
|
||||
.insertOnConflictUpdate(
|
||||
await db.into(db.threads).insertOnConflictUpdate(
|
||||
ThreadsCompanion.insert(
|
||||
id: 'sieve-acc:2',
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -59,8 +59,7 @@ void main() {
|
||||
|
||||
test('leaves HTML unchanged when there are no inline parts', () {
|
||||
// A plain text-only message.
|
||||
const plainMime =
|
||||
'MIME-Version: 1.0\r\n'
|
||||
const plainMime = 'MIME-Version: 1.0\r\n'
|
||||
'Content-Type: text/plain\r\n'
|
||||
'\r\n'
|
||||
'Hello';
|
||||
|
||||
@@ -23,8 +23,7 @@ const _jmapAccount = Account(
|
||||
jmapUrl: 'https://example.com/jmap/session',
|
||||
);
|
||||
|
||||
const _jmapSessionJson =
|
||||
'{'
|
||||
const _jmapSessionJson = '{'
|
||||
'"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}},'
|
||||
'"accounts":{},"primaryAccounts":{},"username":"alice@example.com",'
|
||||
'"apiUrl":"https://example.com/jmap/","downloadUrl":"","uploadUrl":"","state":"0"'
|
||||
@@ -117,15 +116,14 @@ void main() {
|
||||
MockClient((_) async => http.Response('', 200)),
|
||||
imapConnect: (_, __, ___) async => FakeImapClient(),
|
||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||
manageSieveConnect:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
sieveCalled = true;
|
||||
throw Exception('should not be called');
|
||||
},
|
||||
manageSieveConnect: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
sieveCalled = true;
|
||||
throw Exception('should not be called');
|
||||
},
|
||||
);
|
||||
await svc.testConnection(_imapAccount, 'pw');
|
||||
expect(sieveCalled, false);
|
||||
@@ -144,12 +142,12 @@ void main() {
|
||||
MockClient((_) async => http.Response('', 200)),
|
||||
imapConnect: (_, __, ___) async => FakeImapClient(),
|
||||
smtpConnect: (_, __, ___) async => FakeSmtpClient(),
|
||||
manageSieveConnect:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async => throw Exception('sieve boom'),
|
||||
manageSieveConnect: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async =>
|
||||
throw Exception('sieve boom'),
|
||||
);
|
||||
expect(
|
||||
() => svc.testConnection(accountWithSieve, 'pw'),
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:test/test.dart';
|
||||
// Mirrors the encoding logic in EmailRepositoryImpl so we can test it
|
||||
// independently without spinning up a database.
|
||||
String encodeAddresses(List<EmailAddress> addresses) => jsonEncode(
|
||||
addresses.map((a) => {'name': a.name, 'email': a.email}).toList(),
|
||||
);
|
||||
addresses.map((a) => {'name': a.name, 'email': a.email}).toList(),
|
||||
);
|
||||
|
||||
List<EmailAddress> decodeAddresses(String json) {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
|
||||
@@ -34,9 +34,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('cancelPendingChange removes an unattempted change', () async {
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -55,9 +53,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('cancelPendingChange does not remove attempted changes', () async {
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -78,9 +74,7 @@ void main() {
|
||||
|
||||
test('cancelPendingChange only removes the latest matching change', () async {
|
||||
final now = DateTime.now();
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
@@ -90,9 +84,7 @@ void main() {
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc1',
|
||||
resourceType: 'Email',
|
||||
|
||||
@@ -186,9 +186,7 @@ class _EmailRepositoryImplContract extends EmailRepositoryContract {
|
||||
bool isFlagged = false,
|
||||
DateTime? receivedAt,
|
||||
}) async {
|
||||
await _db
|
||||
.into(_db.emails)
|
||||
.insert(
|
||||
await _db.into(_db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -68,25 +68,26 @@ Map<String, dynamic> _emailGetResponse({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
int? total,
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'ids': list.map((e) => e['id']).toList(),
|
||||
'total': total ?? list.length,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'ids': list.map((e) => e['id']).toList(),
|
||||
'total': total ?? list.length,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
[
|
||||
'Email/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _emailChangesResponse({
|
||||
required String oldState,
|
||||
@@ -94,38 +95,40 @@ Map<String, dynamic> _emailChangesResponse({
|
||||
List<String> created = const [],
|
||||
List<String> updated = const [],
|
||||
List<String> destroyed = const [],
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/changes',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'oldState': oldState,
|
||||
'newState': newState,
|
||||
'hasMoreChanges': false,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'destroyed': destroyed,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/changes',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'oldState': oldState,
|
||||
'newState': newState,
|
||||
'hasMoreChanges': false,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'destroyed': destroyed,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _emailGetOnly({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'1',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _jmapEmail({
|
||||
required String id,
|
||||
@@ -133,24 +136,25 @@ Map<String, dynamic> _jmapEmail({
|
||||
String subject = 'Hello',
|
||||
bool seen = false,
|
||||
String? threadId,
|
||||
}) => {
|
||||
'id': id,
|
||||
'mailboxIds': {mailboxId: true},
|
||||
'subject': subject,
|
||||
'sentAt': '2024-01-01T10:00:00Z',
|
||||
'receivedAt': '2024-01-01T10:00:01Z',
|
||||
'from': [
|
||||
{'name': 'Sender', 'email': 'sender@example.com'},
|
||||
],
|
||||
'to': [
|
||||
{'name': 'Alice', 'email': 'alice@example.com'},
|
||||
],
|
||||
'cc': [],
|
||||
'keywords': seen ? {r'$seen': true} : <String, dynamic>{},
|
||||
'hasAttachment': false,
|
||||
'preview': 'Hello world',
|
||||
'threadId': threadId,
|
||||
};
|
||||
}) =>
|
||||
{
|
||||
'id': id,
|
||||
'mailboxIds': {mailboxId: true},
|
||||
'subject': subject,
|
||||
'sentAt': '2024-01-01T10:00:00Z',
|
||||
'receivedAt': '2024-01-01T10:00:01Z',
|
||||
'from': [
|
||||
{'name': 'Sender', 'email': 'sender@example.com'},
|
||||
],
|
||||
'to': [
|
||||
{'name': 'Alice', 'email': 'alice@example.com'},
|
||||
],
|
||||
'cc': [],
|
||||
'keywords': seen ? {r'$seen': true} : <String, dynamic>{},
|
||||
'hasAttachment': false,
|
||||
'preview': 'Hello world',
|
||||
'threadId': threadId,
|
||||
};
|
||||
|
||||
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
||||
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
|
||||
@@ -159,7 +163,7 @@ Future<imap.SmtpClient> _noSmtpConnect(Account a, String u, String p) =>
|
||||
Future.error(UnsupportedError('SMTP unavailable in unit tests'));
|
||||
|
||||
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
|
||||
_makeRepos({
|
||||
_makeRepos({
|
||||
http.Client? httpClient,
|
||||
Future<imap.ImapClient> Function(Account, String, String)? imapConnect,
|
||||
Future<imap.SmtpClient> Function(Account, String, String)? smtpConnect,
|
||||
@@ -199,9 +203,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:42',
|
||||
accountId: 'acc-1',
|
||||
@@ -221,9 +223,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:7',
|
||||
accountId: 'acc-1',
|
||||
@@ -247,9 +247,7 @@ void main() {
|
||||
(3, DateTime(2024, 3)),
|
||||
(2, DateTime(2024, 2)),
|
||||
]) {
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:$uid',
|
||||
accountId: 'acc-1',
|
||||
@@ -276,9 +274,7 @@ void main() {
|
||||
test('getEmailBody propagates IMAP error when not cached', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -296,9 +292,7 @@ void main() {
|
||||
test('getEmailBody returns cached body without IMAP call', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -307,9 +301,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insert(
|
||||
await r.db.into(r.db.emailBodies).insert(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: 'acc-1:1',
|
||||
textBody: const Value('Hello'),
|
||||
@@ -330,9 +322,7 @@ void main() {
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
final now = DateTime.now();
|
||||
await r.db
|
||||
.into(r.db.threads)
|
||||
.insert(
|
||||
await r.db.into(r.db.threads).insert(
|
||||
ThreadsCompanion.insert(
|
||||
id: 'tid1',
|
||||
accountId: 'acc-1',
|
||||
@@ -359,9 +349,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -371,9 +359,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
@@ -384,9 +370,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final emails = await r.emails
|
||||
.observeEmailsInThread('acc-1', 'INBOX', 'tid1')
|
||||
.first;
|
||||
final emails =
|
||||
await r.emails.observeEmailsInThread('acc-1', 'INBOX', 'tid1').first;
|
||||
expect(emails, hasLength(2));
|
||||
expect(emails.map((e) => e.id).toSet(), {'acc-1:1', 'acc-1:2'});
|
||||
});
|
||||
@@ -401,9 +386,7 @@ void main() {
|
||||
'pw',
|
||||
);
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -413,9 +396,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-2:1',
|
||||
accountId: 'acc-2',
|
||||
@@ -444,9 +425,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -456,9 +435,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
@@ -486,9 +463,7 @@ void main() {
|
||||
final newer = DateTime(2024, 6);
|
||||
|
||||
// Two emails — older one has alice@, newer one has bob@.
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:old',
|
||||
accountId: 'acc-1',
|
||||
@@ -500,9 +475,7 @@ void main() {
|
||||
),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:new',
|
||||
accountId: 'acc-1',
|
||||
@@ -531,9 +504,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -559,9 +530,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -585,9 +554,7 @@ void main() {
|
||||
test('setFlag flagged=true enqueues flag_flagged change', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -610,9 +577,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -636,9 +601,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -665,9 +628,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -691,9 +652,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
// _makeRepos uses _noImapConnect which throws UnsupportedError
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'Email',
|
||||
@@ -714,9 +673,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
// Pre-seed a flag_seen at attempts=4
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: _account.id,
|
||||
resourceType: 'Email',
|
||||
@@ -748,9 +705,7 @@ void main() {
|
||||
final spy = SnoozeSpyImapClient();
|
||||
final r = _makeRepos(imapConnect: (_, __, ___) async => spy);
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -759,9 +714,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'Email',
|
||||
@@ -793,9 +746,7 @@ void main() {
|
||||
test('snoozeEmail enqueues snooze change and updates local DB', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -823,9 +774,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
// Seed Inbox mailbox
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -836,9 +785,7 @@ void main() {
|
||||
);
|
||||
|
||||
final past = DateTime.now().subtract(const Duration(hours: 1));
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -867,65 +814,64 @@ void main() {
|
||||
http.Client mockBodyClient({
|
||||
String text = 'Hello from JMAP',
|
||||
String html = '<p>Hello from JMAP</p>',
|
||||
}) => MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'apiUrl': 'https://jmap.example.com/api/',
|
||||
'accounts': {
|
||||
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': 'acct1',
|
||||
'urn:ietf:params:jmap:mail': 'acct1',
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'sess1',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'state': 'es1',
|
||||
'list': [
|
||||
}) =>
|
||||
MockClient((req) async {
|
||||
if (req.url.path.contains('well-known')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'apiUrl': 'https://jmap.example.com/api/',
|
||||
'accounts': {
|
||||
'acct1': {'name': 'alice@example.com', 'isPersonal': true},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': 'acct1',
|
||||
'urn:ietf:params:jmap:mail': 'acct1',
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'sess1',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
'id': 'e1',
|
||||
'textBody': [
|
||||
{'partId': '1', 'type': 'text/plain'},
|
||||
'accountId': 'acct1',
|
||||
'state': 'es1',
|
||||
'list': [
|
||||
{
|
||||
'id': 'e1',
|
||||
'textBody': [
|
||||
{'partId': '1', 'type': 'text/plain'},
|
||||
],
|
||||
'htmlBody': [
|
||||
{'partId': '2', 'type': 'text/html'},
|
||||
],
|
||||
'bodyValues': {
|
||||
'1': {'value': text, 'isTruncated': false},
|
||||
'2': {'value': html, 'isTruncated': false},
|
||||
},
|
||||
'attachments': [],
|
||||
},
|
||||
],
|
||||
'htmlBody': [
|
||||
{'partId': '2', 'type': 'text/html'},
|
||||
],
|
||||
'bodyValues': {
|
||||
'1': {'value': text, 'isTruncated': false},
|
||||
'2': {'value': html, 'isTruncated': false},
|
||||
},
|
||||
'attachments': [],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('fetches body via JMAP Email/get and caches it', () async {
|
||||
final r = _makeRepos(httpClient: mockBodyClient());
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -994,9 +940,7 @@ void main() {
|
||||
}),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1075,9 +1019,7 @@ void main() {
|
||||
}),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1107,9 +1049,7 @@ void main() {
|
||||
test('mimeTree is null when bodyStructure is absent', () async {
|
||||
final r = _makeRepos(httpClient: mockBodyClient());
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1188,9 +1128,7 @@ void main() {
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
// Pre-populate
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1200,9 +1138,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emails).insertOnConflictUpdate(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e2',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1212,9 +1148,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1241,9 +1175,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1298,9 +1230,7 @@ void main() {
|
||||
AccountRepositoryImpl accounts,
|
||||
) async {
|
||||
await accounts.addAccount(_jmapAccount, 'pw');
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'jmap-1:e1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -1416,9 +1346,7 @@ void main() {
|
||||
String payload = '{"seen":true}',
|
||||
}) async {
|
||||
await accounts.addAccount(_jmapAccount, 'pw');
|
||||
await db
|
||||
.into(db.pendingChanges)
|
||||
.insert(
|
||||
await db.into(db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1532,9 +1460,7 @@ void main() {
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1542,9 +1468,7 @@ void main() {
|
||||
syncedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1605,9 +1529,7 @@ void main() {
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1615,9 +1537,7 @@ void main() {
|
||||
syncedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1682,9 +1602,7 @@ void main() {
|
||||
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1706,9 +1624,7 @@ void main() {
|
||||
final r = _makeRepos(httpClient: mockFlush(500));
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
// Seed a change already at attempts=4 (one below the eviction threshold)
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Email',
|
||||
@@ -1813,12 +1729,10 @@ void main() {
|
||||
expect(firstCall, 'Mailbox/set');
|
||||
|
||||
// Second call should be Email/set using the newly created mailbox ID.
|
||||
final secondCallArgs =
|
||||
((capturedBodies[1]['methodCalls'] as List).first as List)[1]
|
||||
as Map<String, dynamic>;
|
||||
final update =
|
||||
(secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||
as Map<String, dynamic>;
|
||||
final secondCallArgs = ((capturedBodies[1]['methodCalls'] as List).first
|
||||
as List)[1] as Map<String, dynamic>;
|
||||
final update = (secondCallArgs['update'] as Map<String, dynamic>)['e1']
|
||||
as Map<String, dynamic>;
|
||||
expect(update['mailboxIds/mbx-snoozed'], true);
|
||||
},
|
||||
);
|
||||
@@ -1853,30 +1767,31 @@ void main() {
|
||||
required String mailboxId,
|
||||
String? textContent,
|
||||
String? htmlContent,
|
||||
}) => {
|
||||
..._jmapEmail(id: id, mailboxId: mailboxId),
|
||||
'textBody': [
|
||||
if (textContent != null) {'partId': 'text1', 'type': 'text/plain'},
|
||||
],
|
||||
'htmlBody': [
|
||||
if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'},
|
||||
],
|
||||
'bodyValues': {
|
||||
if (textContent != null)
|
||||
'text1': {
|
||||
'value': textContent,
|
||||
'isEncodingProblem': false,
|
||||
'isTruncated': false,
|
||||
}) =>
|
||||
{
|
||||
..._jmapEmail(id: id, mailboxId: mailboxId),
|
||||
'textBody': [
|
||||
if (textContent != null) {'partId': 'text1', 'type': 'text/plain'},
|
||||
],
|
||||
'htmlBody': [
|
||||
if (htmlContent != null) {'partId': 'html1', 'type': 'text/html'},
|
||||
],
|
||||
'bodyValues': {
|
||||
if (textContent != null)
|
||||
'text1': {
|
||||
'value': textContent,
|
||||
'isEncodingProblem': false,
|
||||
'isTruncated': false,
|
||||
},
|
||||
if (htmlContent != null)
|
||||
'html1': {
|
||||
'value': htmlContent,
|
||||
'isEncodingProblem': false,
|
||||
'isTruncated': false,
|
||||
},
|
||||
},
|
||||
if (htmlContent != null)
|
||||
'html1': {
|
||||
'value': htmlContent,
|
||||
'isEncodingProblem': false,
|
||||
'isTruncated': false,
|
||||
},
|
||||
},
|
||||
'attachments': [],
|
||||
};
|
||||
'attachments': [],
|
||||
};
|
||||
|
||||
test('full sync caches bodies when bodyValues are present', () async {
|
||||
final r = _makeRepos(
|
||||
@@ -2164,9 +2079,7 @@ void main() {
|
||||
final r = _makeRepos(httpClient: client);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
// Seed a Sent mailbox with role='sent'
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:sentMbx',
|
||||
accountId: 'jmap-1',
|
||||
@@ -2267,9 +2180,7 @@ void main() {
|
||||
// no IMAP connection was made.
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -2278,9 +2189,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emailBodies)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: 'acc-1:1',
|
||||
textBody: const Value('cached text'),
|
||||
@@ -2300,9 +2209,7 @@ void main() {
|
||||
test('observeFailedMutations emits only rows with lastError set', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2313,9 +2220,7 @@ void main() {
|
||||
lastError: const Value('network error'),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2338,9 +2243,7 @@ void main() {
|
||||
test('discardMutation removes the row', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
final rowId = await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
final rowId = await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2362,9 +2265,7 @@ void main() {
|
||||
test('retryMutation resets attempts and clears lastError', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
final rowId = await r.db
|
||||
.into(r.db.pendingChanges)
|
||||
.insert(
|
||||
final rowId = await r.db.into(r.db.pendingChanges).insert(
|
||||
PendingChangesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'email',
|
||||
@@ -2391,9 +2292,7 @@ void main() {
|
||||
() async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:5',
|
||||
accountId: 'acc-1',
|
||||
@@ -2412,9 +2311,8 @@ void main() {
|
||||
expect(changes, hasLength(2));
|
||||
expect(changes.map((c) => c.changeType), everyElement('move'));
|
||||
|
||||
final destinations = changes
|
||||
.map((c) => (jsonDecode(c.payload) as Map)['dest'])
|
||||
.toSet();
|
||||
final destinations =
|
||||
changes.map((c) => (jsonDecode(c.payload) as Map)['dest']).toSet();
|
||||
expect(destinations, containsAll(['Archive', 'Trash']));
|
||||
|
||||
final email = await r.emails.getEmail('acc-1:5');
|
||||
@@ -2467,9 +2365,7 @@ void main() {
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Pre-seed two emails from the old server epoch (uidValidity=123).
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
@@ -2478,9 +2374,7 @@ void main() {
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.emails)
|
||||
.insert(
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
@@ -2492,9 +2386,7 @@ void main() {
|
||||
|
||||
// Seed an IMAP checkpoint with the old uidValidity so the code detects
|
||||
// a mismatch and triggers a full re-sync.
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'acc-1',
|
||||
resourceType: 'IMAP:INBOX',
|
||||
@@ -2510,13 +2402,13 @@ void main() {
|
||||
expect(remaining, isEmpty);
|
||||
|
||||
// Checkpoint must be updated to the new uidValidity.
|
||||
final stateRow =
|
||||
await (r.db.select(r.db.syncStates)..where(
|
||||
(t) =>
|
||||
t.accountId.equals('acc-1') &
|
||||
t.resourceType.equals('IMAP:INBOX'),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
final stateRow = await (r.db.select(r.db.syncStates)
|
||||
..where(
|
||||
(t) =>
|
||||
t.accountId.equals('acc-1') &
|
||||
t.resourceType.equals('IMAP:INBOX'),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
expect(stateRow, isNotNull);
|
||||
final state = jsonDecode(stateRow!.state) as Map<String, dynamic>;
|
||||
expect(state['uidValidity'], 456);
|
||||
@@ -2535,20 +2427,22 @@ class _FakeImapClientUidValidity extends FakeImapClient {
|
||||
String path, {
|
||||
bool enableCondStore = false,
|
||||
imap.QResyncParameters? qresync,
|
||||
}) async => imap.Mailbox(
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
flags: [],
|
||||
pathSeparator: '/',
|
||||
uidValidity: _uidValidity,
|
||||
);
|
||||
}) async =>
|
||||
imap.Mailbox(
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
flags: [],
|
||||
pathSeparator: '/',
|
||||
uidValidity: _uidValidity,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<imap.SearchImapResult> uidSearchMessages({
|
||||
String searchCriteria = 'ALL',
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
Duration? responseTimeout,
|
||||
}) async => imap.SearchImapResult();
|
||||
}) async =>
|
||||
imap.SearchImapResult();
|
||||
}
|
||||
|
||||
// ── SSE test helper ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,11 +24,11 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
String? movedToMailbox;
|
||||
|
||||
imap.Mailbox _fakeMailbox(String path) => imap.Mailbox(
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
pathSeparator: '/',
|
||||
flags: [],
|
||||
);
|
||||
encodedName: path,
|
||||
encodedPath: path,
|
||||
pathSeparator: '/',
|
||||
flags: [],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox> selectMailboxByPath(
|
||||
@@ -53,7 +53,8 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
imap.StoreAction? action,
|
||||
bool? silent,
|
||||
int? unchangedSinceModSequence,
|
||||
}) async => imap.StoreImapResult();
|
||||
}) async =>
|
||||
imap.StoreImapResult();
|
||||
|
||||
@override
|
||||
Future<imap.GenericImapResult> uidMove(
|
||||
@@ -71,7 +72,8 @@ class SnoozeSpyImapClient extends FakeImapClient {
|
||||
String? fetchContentDefinition, {
|
||||
int? changedSinceModSequence,
|
||||
Duration? responseTimeout,
|
||||
}) async => const imap.FetchImapResult([], null);
|
||||
}) async =>
|
||||
const imap.FetchImapResult([], null);
|
||||
}
|
||||
|
||||
/// Minimal fake SMTP client; only `quit` is exercised by ConnectionTestService.
|
||||
|
||||
@@ -56,8 +56,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('real-world HTML email snippet', () {
|
||||
const html =
|
||||
'<p>Hello <b>Alice</b>,</p>'
|
||||
const html = '<p>Hello <b>Alice</b>,</p>'
|
||||
'<p>Please find the invoice attached.</p>'
|
||||
'<p>Best regards,<br/>Bob</p>';
|
||||
final result = htmlToPlain(html);
|
||||
|
||||
@@ -11,23 +11,23 @@ const _apiUrl = 'https://jmap.example.com/api/';
|
||||
const _accountId = 'u1';
|
||||
|
||||
Map<String, dynamic> _sessionBody({String? apiUrl, String? accountId}) => {
|
||||
'apiUrl': apiUrl ?? _apiUrl,
|
||||
'accounts': {
|
||||
accountId ?? _accountId: {
|
||||
'name': 'alice@example.com',
|
||||
'isPersonal': true,
|
||||
'isReadOnly': false,
|
||||
'accountCapabilities': {},
|
||||
},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': accountId ?? _accountId,
|
||||
'urn:ietf:params:jmap:mail': accountId ?? _accountId,
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'st1',
|
||||
};
|
||||
'apiUrl': apiUrl ?? _apiUrl,
|
||||
'accounts': {
|
||||
accountId ?? _accountId: {
|
||||
'name': 'alice@example.com',
|
||||
'isPersonal': true,
|
||||
'isReadOnly': false,
|
||||
'accountCapabilities': {},
|
||||
},
|
||||
},
|
||||
'primaryAccounts': {
|
||||
'urn:ietf:params:jmap:core': accountId ?? _accountId,
|
||||
'urn:ietf:params:jmap:mail': accountId ?? _accountId,
|
||||
},
|
||||
'capabilities': {},
|
||||
'username': 'alice@example.com',
|
||||
'state': 'st1',
|
||||
};
|
||||
|
||||
http.Client _sessionClient({
|
||||
int sessionStatus = 200,
|
||||
|
||||
@@ -111,9 +111,7 @@ class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
|
||||
int unread = 0,
|
||||
int total = 0,
|
||||
}) async {
|
||||
await _db
|
||||
.into(_db.mailboxes)
|
||||
.insert(
|
||||
await _db.into(_db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: _account.id,
|
||||
|
||||
@@ -66,16 +66,17 @@ http.Client _mockJmap({required List<Map<String, dynamic>> apiResponses}) {
|
||||
Map<String, dynamic> _mailboxGetResponse({
|
||||
required String state,
|
||||
required List<Map<String, dynamic>> list,
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/get',
|
||||
{'accountId': 'acct1', 'state': state, 'list': list},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Map<String, dynamic> _mailboxChangesResponse({
|
||||
required String oldState,
|
||||
@@ -83,24 +84,25 @@ Map<String, dynamic> _mailboxChangesResponse({
|
||||
List<String> created = const [],
|
||||
List<String> updated = const [],
|
||||
List<String> destroyed = const [],
|
||||
}) => {
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/changes',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'oldState': oldState,
|
||||
'newState': newState,
|
||||
'hasMoreChanges': false,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'destroyed': destroyed,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
}) =>
|
||||
{
|
||||
'sessionState': 'sess1',
|
||||
'methodResponses': [
|
||||
[
|
||||
'Mailbox/changes',
|
||||
{
|
||||
'accountId': 'acct1',
|
||||
'oldState': oldState,
|
||||
'newState': newState,
|
||||
'hasMoreChanges': false,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'destroyed': destroyed,
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
||||
Future.error(UnsupportedError('IMAP unavailable in unit tests'));
|
||||
@@ -109,8 +111,7 @@ Future<imap.ImapClient> _noImapConnect(Account a, String u, String p) =>
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
})
|
||||
_makeRepos({http.Client? httpClient}) {
|
||||
}) _makeRepos({http.Client? httpClient}) {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
@@ -144,9 +145,7 @@ void main() {
|
||||
('INBOX', 'Inbox'),
|
||||
('Drafts', 'Drafts'),
|
||||
]) {
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:$path',
|
||||
accountId: 'acc-1',
|
||||
@@ -179,9 +178,7 @@ void main() {
|
||||
);
|
||||
await r.accounts.addAccount(other, 'pw2');
|
||||
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -189,9 +186,7 @@ void main() {
|
||||
name: 'Inbox',
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-2:INBOX',
|
||||
accountId: 'acc-2',
|
||||
@@ -210,9 +205,7 @@ void main() {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:INBOX',
|
||||
accountId: 'acc-1',
|
||||
@@ -312,9 +305,7 @@ void main() {
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
|
||||
// Pre-populate DB with existing mailboxes and state
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx1',
|
||||
accountId: 'jmap-1',
|
||||
@@ -324,9 +315,7 @@ void main() {
|
||||
totalCount: const Value(10),
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx2',
|
||||
accountId: 'jmap-1',
|
||||
@@ -334,9 +323,7 @@ void main() {
|
||||
name: 'Sent',
|
||||
),
|
||||
);
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Mailbox',
|
||||
@@ -364,9 +351,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.syncStates)
|
||||
.insertOnConflictUpdate(
|
||||
await r.db.into(r.db.syncStates).insertOnConflictUpdate(
|
||||
SyncStatesCompanion.insert(
|
||||
accountId: 'jmap-1',
|
||||
resourceType: 'Mailbox',
|
||||
@@ -434,9 +419,7 @@ void main() {
|
||||
test('findMailboxByRole returns matching mailbox', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_jmapAccount, 'pw');
|
||||
await r.db
|
||||
.into(r.db.mailboxes)
|
||||
.insert(
|
||||
await r.db.into(r.db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap-1:mbx-inbox',
|
||||
accountId: 'jmap-1',
|
||||
@@ -569,9 +552,7 @@ void main() {
|
||||
await accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Pre-seed the DB with role='archive' (as if user created the folder).
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc-1:Archive',
|
||||
accountId: 'acc-1',
|
||||
@@ -608,20 +589,22 @@ class _PlainArchiveImapClient extends SnoozeSpyImapClient {
|
||||
List<String>? mailboxPatterns,
|
||||
List<String>? selectionOptions,
|
||||
List<imap.ReturnOption>? returnOptions,
|
||||
}) async => [
|
||||
imap.Mailbox(
|
||||
encodedName: 'Archive',
|
||||
encodedPath: 'Archive',
|
||||
pathSeparator: '/',
|
||||
flags: [], // No \Archive special-use flag
|
||||
),
|
||||
];
|
||||
}) async =>
|
||||
[
|
||||
imap.Mailbox(
|
||||
encodedName: 'Archive',
|
||||
encodedPath: 'Archive',
|
||||
pathSeparator: '/',
|
||||
flags: [], // No \Archive special-use flag
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<imap.Mailbox> statusMailbox(
|
||||
imap.Mailbox mailbox,
|
||||
List<imap.StatusFlags> flags,
|
||||
) async => mailbox;
|
||||
) async =>
|
||||
mailbox;
|
||||
|
||||
@override
|
||||
Future<dynamic> logout() async {}
|
||||
|
||||
@@ -27,12 +27,12 @@ class _RecordingRepo implements AccountRepository {
|
||||
ManageSieveProbeService _service(_RecordingRepo repo, {required bool result}) {
|
||||
return ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async => result,
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async =>
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,15 +71,14 @@ void main() {
|
||||
var probeCalled = false;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const jmap = Account(
|
||||
id: 'acc-2',
|
||||
@@ -98,15 +97,14 @@ void main() {
|
||||
var probeCalled = false;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probeCalled = true;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const blank = Account(
|
||||
id: 'acc-3',
|
||||
@@ -125,17 +123,16 @@ void main() {
|
||||
bool? probedTls;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
probedPort = port;
|
||||
probedTls = useTls;
|
||||
return true;
|
||||
},
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
probedPort = port;
|
||||
probedTls = useTls;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const account = Account(
|
||||
id: 'acc-1',
|
||||
@@ -158,15 +155,14 @@ void main() {
|
||||
String? probedHost;
|
||||
final svc = ManageSieveProbeService(
|
||||
repo,
|
||||
probeFn:
|
||||
({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
return true;
|
||||
},
|
||||
probeFn: ({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool useTls,
|
||||
}) async {
|
||||
probedHost = host;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await svc.probe(_imapAccount);
|
||||
expect(probedHost, 'imap.example.com');
|
||||
|
||||
@@ -162,9 +162,8 @@ void main() {
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames = allTriggers
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
@@ -361,9 +360,8 @@ void main() {
|
||||
final allIndexes = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
.get();
|
||||
final indexNames = allIndexes
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
final indexNames =
|
||||
allIndexes.map((r) => r.read<String>('name')).toSet();
|
||||
expect(indexNames, contains('mailboxes_account_id'));
|
||||
expect(indexNames, contains('threads_latest_date'));
|
||||
|
||||
@@ -371,9 +369,8 @@ void main() {
|
||||
final allTriggers = await db
|
||||
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
|
||||
.get();
|
||||
final triggerNames = allTriggers
|
||||
.map((r) => r.read<String>('name'))
|
||||
.toSet();
|
||||
final triggerNames =
|
||||
allTriggers.map((r) => r.read<String>('name')).toSet();
|
||||
expect(
|
||||
triggerNames,
|
||||
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
|
||||
|
||||
@@ -67,15 +67,16 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeEmails implements EmailRepository {
|
||||
@@ -99,7 +100,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -136,7 +138,8 @@ class _FakeEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
||||
Stream.value([]);
|
||||
|
||||
@@ -13,11 +13,11 @@ 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',
|
||||
);
|
||||
id: id,
|
||||
displayName: 'Test',
|
||||
email: 'test@example.com',
|
||||
imapHost: 'localhost',
|
||||
);
|
||||
|
||||
class _FakeAccounts implements AccountRepository {
|
||||
final List<Account> accounts;
|
||||
@@ -57,15 +57,16 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async => Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
) async =>
|
||||
Mailbox(
|
||||
id: '$accountId:$name',
|
||||
accountId: accountId,
|
||||
path: name,
|
||||
name: name,
|
||||
role: role,
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _CountingEmails implements EmailRepository {
|
||||
@@ -98,7 +99,8 @@ class _CountingEmails implements EmailRepository {
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) => Stream.value([]);
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
@@ -132,7 +134,8 @@ class _CountingEmails implements EmailRepository {
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String a) =>
|
||||
Stream.value([]);
|
||||
@@ -150,7 +153,8 @@ class _CountingEmails implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
@override
|
||||
@@ -160,7 +164,8 @@ class _CountingEmails implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
@override
|
||||
@@ -372,7 +377,7 @@ void main() {
|
||||
|
||||
class _OverrideEmails extends _CountingEmails {
|
||||
_OverrideEmails({required Future<SyncEmailsResult> Function(String) onSync})
|
||||
: _onSync = onSync;
|
||||
: _onSync = onSync;
|
||||
|
||||
final Future<SyncEmailsResult> Function(String) _onSync;
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ void main() {
|
||||
late final db = openTestDatabase();
|
||||
|
||||
setUpAll(() async {
|
||||
await db
|
||||
.into(db.accounts)
|
||||
.insert(
|
||||
await db.into(db.accounts).insert(
|
||||
AccountsCompanion.insert(
|
||||
id: 'acc1',
|
||||
displayName: 'Test',
|
||||
@@ -122,7 +120,8 @@ void main() {
|
||||
|
||||
final rows = await (db.select(
|
||||
db.syncLogs,
|
||||
)..where((r) => r.result.equals('error'))).get();
|
||||
)..where((r) => r.result.equals('error')))
|
||||
.get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.result, 'error');
|
||||
expect(rows.first.errorMessage, 'Connection refused');
|
||||
|
||||
@@ -48,9 +48,7 @@ void main() {
|
||||
await accounts.addAccount(account, 'password');
|
||||
|
||||
// Setup Inbox and Trash mailboxes
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc1:INBOX',
|
||||
accountId: 'acc1',
|
||||
@@ -58,9 +56,7 @@ void main() {
|
||||
name: 'Inbox',
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'acc1:Trash',
|
||||
accountId: 'acc1',
|
||||
@@ -71,9 +67,7 @@ void main() {
|
||||
);
|
||||
|
||||
// Setup an email in Inbox
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc1:101',
|
||||
accountId: 'acc1',
|
||||
@@ -100,11 +94,10 @@ void main() {
|
||||
await repo.deleteEmail(emailId);
|
||||
|
||||
// Verify it moved from INBOX (locally deleted for IMAP move)
|
||||
final inInbox =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final inInbox = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(inInbox, isEmpty, reason: 'Email should be gone from Inbox');
|
||||
|
||||
// 2. Push undo action and undo
|
||||
@@ -120,11 +113,10 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
|
||||
expect(
|
||||
restored,
|
||||
@@ -149,9 +141,7 @@ void main() {
|
||||
await accounts.addAccount(jmapAccount, 'password');
|
||||
|
||||
// Setup Inbox and Trash mailboxes for JMAP
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap1:INBOX',
|
||||
accountId: 'jmap1',
|
||||
@@ -160,9 +150,7 @@ void main() {
|
||||
role: const Value('inbox'),
|
||||
),
|
||||
);
|
||||
await db
|
||||
.into(db.mailboxes)
|
||||
.insert(
|
||||
await db.into(db.mailboxes).insert(
|
||||
MailboxesCompanion.insert(
|
||||
id: 'jmap1:Trash',
|
||||
accountId: 'jmap1',
|
||||
@@ -173,9 +161,7 @@ void main() {
|
||||
);
|
||||
|
||||
// Setup an email in JMAP Inbox
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: emailId,
|
||||
accountId: 'jmap1',
|
||||
@@ -190,11 +176,10 @@ void main() {
|
||||
await repo.deleteEmail(emailId);
|
||||
|
||||
// Verify it moved to Trash locally (JMAP moveEmail updates mailboxPath)
|
||||
final inTrash =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('Trash')))
|
||||
.get();
|
||||
final inTrash = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('Trash')))
|
||||
.get();
|
||||
expect(inTrash, isNotEmpty, reason: 'Email should be in Trash');
|
||||
|
||||
// 2. Push undo action and undo
|
||||
@@ -209,11 +194,10 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 3. Verify it is back in Inbox
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(
|
||||
restored,
|
||||
isNotEmpty,
|
||||
@@ -250,11 +234,10 @@ void main() {
|
||||
await container.read(undoServiceProvider.notifier).undo();
|
||||
|
||||
// 4. Verify local state
|
||||
final restored =
|
||||
await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
final restored = await (db.select(db.emails)
|
||||
..where((t) => t.id.equals(emailId))
|
||||
..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(restored, isNotEmpty);
|
||||
|
||||
// 5. Verify a NEW pending change was enqueued (Trash -> INBOX)
|
||||
@@ -290,9 +273,7 @@ void main() {
|
||||
// 2. Simulate IMAP sync: the server assigned a new UID (205) in Trash.
|
||||
// The old row (acc1:101) is removed and a new row (acc1:205) is inserted.
|
||||
await (db.delete(db.emails)..where((t) => t.id.equals(oldEmailId))).go();
|
||||
await db
|
||||
.into(db.emails)
|
||||
.insert(
|
||||
await db.into(db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc1:205',
|
||||
accountId: 'acc1',
|
||||
@@ -325,7 +306,8 @@ void main() {
|
||||
// 4. Verify the current email row is now in INBOX.
|
||||
final inInbox = await (db.select(
|
||||
db.emails,
|
||||
)..where((t) => t.mailboxPath.equals('INBOX'))).get();
|
||||
)..where((t) => t.mailboxPath.equals('INBOX')))
|
||||
.get();
|
||||
expect(
|
||||
inInbox,
|
||||
isNotEmpty,
|
||||
|
||||
@@ -37,8 +37,7 @@ class ThrowingUrlLauncher extends Mock
|
||||
Future<bool> launchUrl(String? url, LaunchOptions? options) async {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message:
|
||||
'Unable to establish connection on channel: '
|
||||
message: 'Unable to establish connection on channel: '
|
||||
'"dev.flutter.pigeon.url_launcher_android.UrlLauncherApi.launchUrl".',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +227,8 @@ void main() {
|
||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows discrepancy details when sync health has discrepancies', (
|
||||
testWidgets('shows discrepancy details when sync health has discrepancies',
|
||||
(
|
||||
tester,
|
||||
) async {
|
||||
const summary =
|
||||
|
||||
@@ -41,19 +41,20 @@ class _FakeFile extends Fake implements File {
|
||||
FileMode mode = FileMode.write,
|
||||
Encoding encoding = utf8,
|
||||
bool flush = false,
|
||||
}) async => this;
|
||||
}) async =>
|
||||
this;
|
||||
}
|
||||
|
||||
// Shared overrides for email detail tests.
|
||||
List<Override> _overrides({required EmailBody body, Email? email}) => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
|
||||
),
|
||||
];
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emailDetail: email ?? testEmail(), emailBody: body),
|
||||
),
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('EmailDetailScreen', () {
|
||||
|
||||
@@ -15,42 +15,44 @@ Email _email({
|
||||
String subject = 'Hello world',
|
||||
bool isSeen = true,
|
||||
bool isFlagged = false,
|
||||
}) => Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: int.parse(id.split(':').last),
|
||||
subject: subject,
|
||||
receivedAt: _kDate,
|
||||
sentAt: _kDate,
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: int.parse(id.split(':').last),
|
||||
subject: subject,
|
||||
receivedAt: _kDate,
|
||||
sentAt: _kDate,
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
|
||||
List<Override> _overrides({
|
||||
List<Email> emails = const [],
|
||||
List<Email> searchResults = const [],
|
||||
String? syncError,
|
||||
}) => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: emails, searchResults: searchResults),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
|
||||
];
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository([kTestMailbox]),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(emails: emails, searchResults: searchResults),
|
||||
),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
searchHistoryRepositoryProvider.overrideWithValue(
|
||||
FakeSearchHistoryRepository(),
|
||||
),
|
||||
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(syncError)),
|
||||
];
|
||||
|
||||
void main() {
|
||||
group('EmailListScreen goldens', () {
|
||||
|
||||
@@ -27,7 +27,8 @@ class _MutableFakeEmailRepository extends FakeEmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async => _results;
|
||||
) async =>
|
||||
_results;
|
||||
}
|
||||
|
||||
final _kDate = DateTime(2024, 6);
|
||||
|
||||
+93
-78
@@ -49,7 +49,7 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
|
||||
class FakeAccountRepository implements AccountRepository {
|
||||
FakeAccountRepository([List<Account>? accounts])
|
||||
: _accounts = List.of(accounts ?? []);
|
||||
: _accounts = List.of(accounts ?? []);
|
||||
|
||||
final List<Account> _accounts;
|
||||
bool hasPassword = true;
|
||||
@@ -137,7 +137,8 @@ class FakeDraftRepository implements DraftRepository {
|
||||
final matches = _drafts.values.where((d) {
|
||||
if (replyToEmailId == null) return d.replyToEmailId == null;
|
||||
return d.replyToEmailId == replyToEmailId;
|
||||
}).toList()..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
}).toList()
|
||||
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ class FakeMailboxRepository implements MailboxRepository {
|
||||
final List<Mailbox> _mailboxes;
|
||||
|
||||
FakeMailboxRepository([List<Mailbox>? mailboxes])
|
||||
: _mailboxes = mailboxes ?? [];
|
||||
: _mailboxes = mailboxes ?? [];
|
||||
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) =>
|
||||
@@ -205,49 +206,52 @@ class FakeEmailRepository implements EmailRepository {
|
||||
EmailBody? emailBody,
|
||||
List<Email>? searchResults,
|
||||
String rawRfc822 = '',
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
_rawRfc822 = rawRfc822,
|
||||
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
_rawRfc822 = rawRfc822,
|
||||
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) => Stream.value(List.of(_emails));
|
||||
}) =>
|
||||
Stream.value(List.of(_emails));
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String accountId,
|
||||
String mailboxPath, {
|
||||
int limit = 50,
|
||||
}) => observeEmails(accountId, mailboxPath).map((emails) {
|
||||
return emails.map((e) {
|
||||
return EmailThread(
|
||||
threadId: e.threadId ?? e.id,
|
||||
subject: e.subject,
|
||||
preview: e.preview,
|
||||
participants: e.from,
|
||||
latestDate: e.sentAt ?? e.receivedAt,
|
||||
messageCount: 1,
|
||||
hasUnread: !e.isSeen,
|
||||
isFlagged: e.isFlagged,
|
||||
latestEmailId: e.id,
|
||||
emailIds: [e.id],
|
||||
accountId: e.accountId,
|
||||
mailboxPath: e.mailboxPath,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}) =>
|
||||
observeEmails(accountId, mailboxPath).map((emails) {
|
||||
return emails.map((e) {
|
||||
return EmailThread(
|
||||
threadId: e.threadId ?? e.id,
|
||||
subject: e.subject,
|
||||
preview: e.preview,
|
||||
participants: e.from,
|
||||
latestDate: e.sentAt ?? e.receivedAt,
|
||||
messageCount: 1,
|
||||
hasUnread: !e.isSeen,
|
||||
isFlagged: e.isFlagged,
|
||||
latestEmailId: e.id,
|
||||
emailIds: [e.id],
|
||||
accountId: e.accountId,
|
||||
mailboxPath: e.mailboxPath,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String threadId,
|
||||
) => Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
) =>
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
@@ -259,7 +263,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<SyncEmailsResult> syncEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => SyncEmailsResult.zero;
|
||||
) async =>
|
||||
SyncEmailsResult.zero;
|
||||
|
||||
@override
|
||||
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
|
||||
@@ -285,7 +290,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async => null;
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String emailId) async => null;
|
||||
@@ -303,7 +309,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async => '/tmp/${attachment.filename}';
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => _rawRfc822;
|
||||
@@ -313,26 +320,30 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async => _searchResults;
|
||||
) async =>
|
||||
_searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
String query,
|
||||
) async => _searchResults;
|
||||
) async =>
|
||||
_searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
String address,
|
||||
) async => [];
|
||||
) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
String? accountId,
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) async => [];
|
||||
}) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
@@ -342,7 +353,8 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async => ReliabilityResult.healthy;
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
@@ -541,26 +553,28 @@ List<Override> baseOverrides({
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
SyncHealthRow? syncHealth,
|
||||
}) => [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
accountDiscoveryServiceProvider.overrideWithValue(
|
||||
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
|
||||
),
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
// syncHealthProvider is backed by a Drift StreamQuery; override with a
|
||||
// plain stream to avoid "A Timer is still pending" in tests.
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
|
||||
];
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
|
||||
),
|
||||
mailboxRepositoryProvider
|
||||
.overrideWithValue(FakeMailboxRepository(mailboxes)),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||
accountDiscoveryServiceProvider.overrideWithValue(
|
||||
FakeDiscoveryService(discovery ?? UnknownDiscovery()),
|
||||
),
|
||||
connectionTestServiceProvider.overrideWithValue(
|
||||
FakeConnectionTestService(error: connectionError),
|
||||
),
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
// syncHealthProvider is backed by a Drift StreamQuery; override with a
|
||||
// plain stream to avoid "A Timer is still pending" in tests.
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Common test fixtures
|
||||
@@ -590,22 +604,23 @@ Email testEmail({
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? listUnsubscribeHeader,
|
||||
}) => Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 42,
|
||||
subject: subject,
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 42,
|
||||
subject: subject,
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
|
||||
class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
FakeUserPreferencesRepository({
|
||||
@@ -620,12 +635,12 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
UserPreferences(
|
||||
menuPosition: menuPosition,
|
||||
mailViewButtonPosition: mailViewButtonPosition,
|
||||
afterMailViewAction: afterMailViewAction,
|
||||
),
|
||||
);
|
||||
UserPreferences(
|
||||
menuPosition: menuPosition,
|
||||
mailViewButtonPosition: mailViewButtonPosition,
|
||||
afterMailViewAction: afterMailViewAction,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(MenuPosition position) async {
|
||||
|
||||
@@ -11,12 +11,12 @@ void _expectLightMode(String html) {
|
||||
}
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('buildEmailHtml', () {
|
||||
@@ -44,7 +44,8 @@ void main() {
|
||||
_expectLightMode(html);
|
||||
});
|
||||
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off', () {
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
||||
() {
|
||||
final html = buildEmailHtml(
|
||||
'<table width="600"><tr><td>x</td></tr></table>',
|
||||
);
|
||||
|
||||
@@ -11,22 +11,23 @@ Email _threadEmail({
|
||||
String id = 'acc-1:10',
|
||||
bool isFlagged = false,
|
||||
bool isSeen = true,
|
||||
}) => Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 10,
|
||||
threadId: 'thread-1',
|
||||
subject: 'Project update',
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6, 1, 9),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 10,
|
||||
threadId: 'thread-1',
|
||||
subject: 'Project update',
|
||||
receivedAt: DateTime(2024, 6),
|
||||
sentAt: DateTime(2024, 6, 1, 9),
|
||||
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||
to: const [EmailAddress(email: 'alice@example.com')],
|
||||
cc: const [],
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: false,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('ThreadDetailScreen', () {
|
||||
|
||||
@@ -4,12 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/ui/widgets/try_connection_button.dart';
|
||||
|
||||
Widget _wrap(Widget child) => MaterialApp(
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('TryConnectionButton', () {
|
||||
|
||||
@@ -88,11 +88,10 @@ void main() {
|
||||
await tester.tap(find.text('Top').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo =
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.menuPosition, MenuPosition.top);
|
||||
});
|
||||
@@ -111,11 +110,10 @@ void main() {
|
||||
await tester.tap(find.text('Top').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo =
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.mailViewButtonPosition, MenuPosition.top);
|
||||
},
|
||||
@@ -175,11 +173,10 @@ void main() {
|
||||
await tester.tap(find.text('Return to mailbox'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo =
|
||||
ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user