ci: rename integration tests to test-backend and migrate to Dagger
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||
|
||||
Future<imap.ImapClient> _fakeImapConnect(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async =>
|
||||
throw const SocketException('fake — no real IMAP server in tests');
|
||||
|
||||
void main() {
|
||||
test('AccountSyncManager schedules IMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
imapConnect: _fakeImapConnect,
|
||||
);
|
||||
|
||||
final a1 = _account('1');
|
||||
final a2 = _account('2');
|
||||
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
|
||||
// Allow some time for listeners to fire.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('AccountSyncManager schedules JMAP sync for multiple accounts',
|
||||
() async {
|
||||
final accounts = _FakeAccounts('pw');
|
||||
final mailboxes = _FakeMailboxes();
|
||||
final emails = _FakeEmails();
|
||||
final logs = _FakeLogs();
|
||||
|
||||
final manager = AccountSyncManager(
|
||||
accounts,
|
||||
mailboxes,
|
||||
emails,
|
||||
syncLog: logs,
|
||||
);
|
||||
|
||||
final a1 = _jmapAccount('1');
|
||||
final a2 = _jmapAccount('2');
|
||||
|
||||
manager.start();
|
||||
accounts.push([a1, a2]);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
|
||||
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
Account _account(String id) => Account(
|
||||
id: id,
|
||||
displayName: 'Account $id',
|
||||
email: '$id@example.com',
|
||||
imapHost: 'localhost',
|
||||
imapPort: 143,
|
||||
imapSsl: false,
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
Account _jmapAccount(String id) => Account(
|
||||
id: id,
|
||||
displayName: 'Account $id',
|
||||
email: '$id@example.com',
|
||||
type: AccountType.jmap,
|
||||
jmapUrl: 'http://localhost:8080/.well-known/jmap',
|
||||
smtpHost: 'localhost',
|
||||
smtpPort: 25,
|
||||
smtpSsl: false,
|
||||
);
|
||||
|
||||
class _FakeAccounts implements AccountRepository {
|
||||
_FakeAccounts(this.password);
|
||||
final String password;
|
||||
final _ctrl = StreamController<List<Account>>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<List<Account>> observeAccounts() => _ctrl.stream;
|
||||
|
||||
@override
|
||||
Future<Account?> getAccount(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<String> getPassword(String accountId) async => password;
|
||||
|
||||
@override
|
||||
Future<void> addAccount(Account a, String p) async {}
|
||||
@override
|
||||
Future<void> removeAccount(String id) async {}
|
||||
@override
|
||||
Future<void> updateAccount(Account a, {String? password}) async {}
|
||||
|
||||
void push(List<Account> accounts) => _ctrl.add(accounts);
|
||||
}
|
||||
|
||||
class _FakeMailboxes implements MailboxRepository {
|
||||
@override
|
||||
Stream<List<Mailbox>> observeMailboxes(String? accountId) => Stream.value([
|
||||
Mailbox(
|
||||
id: '$accountId:INBOX',
|
||||
accountId: accountId ?? '',
|
||||
path: 'INBOX',
|
||||
name: 'INBOX',
|
||||
unreadCount: 0,
|
||||
totalCount: 0,
|
||||
role: 'inbox',
|
||||
),
|
||||
]);
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
}
|
||||
|
||||
class _FakeEmails implements EmailRepository {
|
||||
final syncCounts = <String, int>{};
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmails(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<EmailThread>> observeThreads(
|
||||
String a,
|
||||
String m, {
|
||||
int limit = 50,
|
||||
}) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String id) async => null;
|
||||
|
||||
@override
|
||||
Future<EmailBody> getEmailBody(String id) async =>
|
||||
const EmailBody(emailId: '', attachments: []);
|
||||
|
||||
@override
|
||||
Future<SyncEmailsResult> syncEmails(String a, String m) async {
|
||||
syncCounts[a] = (syncCounts[a] ?? 0) + 1;
|
||||
return SyncEmailsResult.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
|
||||
|
||||
@override
|
||||
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
|
||||
|
||||
@override
|
||||
Future<void> moveEmail(String id, String dest) async {}
|
||||
|
||||
@override
|
||||
Future<bool> cancelPendingChange(String id, String type) async => false;
|
||||
|
||||
@override
|
||||
Future<void> snoozeEmail(String emailId, DateTime until) async {}
|
||||
|
||||
@override
|
||||
Future<int> wakeUpEmails(String accountId) async => 0;
|
||||
|
||||
@override
|
||||
Future<void> restoreEmails(List<Email> emails) async {}
|
||||
|
||||
@override
|
||||
Future<Email?> findEmailByMessageId(
|
||||
String accountId,
|
||||
String messageId,
|
||||
) async =>
|
||||
null;
|
||||
|
||||
@override
|
||||
Future<String?> deleteEmail(String id) async => null;
|
||||
|
||||
@override
|
||||
Stream<String> get onChangesQueued => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<int> flushPendingChanges(String accountId, String password) async => 0;
|
||||
|
||||
@override
|
||||
Future<void> sendEmail(String a, EmailDraft d) async {}
|
||||
|
||||
@override
|
||||
Future<String> downloadAttachment(
|
||||
String emailId,
|
||||
EmailAttachment attachment,
|
||||
) async =>
|
||||
'/tmp/${attachment.filename}';
|
||||
|
||||
@override
|
||||
Future<String> fetchRawRfc822(String emailId) async => '';
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmails(String a, String m, String q) async => [];
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
String? a,
|
||||
String q, {
|
||||
int limit = 10,
|
||||
}) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Stream<void> watchJmapPush(String accountId, String password) =>
|
||||
const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<ReliabilityResult> verifySyncReliability(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
) async =>
|
||||
ReliabilityResult.healthy;
|
||||
|
||||
@override
|
||||
Stream<List<FailedMutation>> observeFailedMutations(String accountId) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Future<void> discardMutation(int id) async {}
|
||||
|
||||
@override
|
||||
Future<void> retryMutation(int id) async {}
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {}
|
||||
}
|
||||
|
||||
class _FakeLogs implements SyncLogRepository {
|
||||
@override
|
||||
Future<void> log({
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
required int mailboxesSynced,
|
||||
required int pendingFlushed,
|
||||
required int bytesTransferred,
|
||||
required DateTime startedAt,
|
||||
required DateTime finishedAt,
|
||||
List<MailboxSyncStats> mailboxStats = const [],
|
||||
String? protocolLog,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Stream<List<SyncLogEntry>> observeSyncLogs(String accountId) =>
|
||||
Stream.value([]);
|
||||
|
||||
@override
|
||||
Stream<String?> observeLastError(String accountId) => Stream.value(null);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Integration test — requires a running Stalwart instance.
|
||||
// Run via: stalwart-dev/test.sh (sets the env vars below)
|
||||
//
|
||||
// Concurrently syncs via IMAP (alice) and JMAP (bob), sends emails in both
|
||||
// directions, and verifies the in-memory Drift DB cache is consistent.
|
||||
//
|
||||
// Env vars:
|
||||
// STALWART_URL — JMAP base URL (e.g. http://127.0.0.1:8080)
|
||||
// STALWART_IMAP_HOST — IMAP hostname (default 127.0.0.1)
|
||||
// STALWART_IMAP_PORT — IMAP port
|
||||
// STALWART_SMTP_PORT — SMTP port
|
||||
// STALWART_USER_B / STALWART_PASS_B — alice (IMAP account)
|
||||
// STALWART_USER_C / STALWART_PASS_C — bob (JMAP account)
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:enough_mail/enough_mail.dart' as enough_mail;
|
||||
import 'package:enough_mail/enough_mail.dart' as mail;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
String _env(String key) {
|
||||
final v = Platform.environment[key];
|
||||
if (v == null || v.isEmpty) throw StateError('$key is not set');
|
||||
return v;
|
||||
}
|
||||
|
||||
/// In-memory SecureStorage backed by a plain map — no flutter_secure_storage.
|
||||
class _MemSecureStorage implements SecureStorage {
|
||||
final _map = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async => _map[key];
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) async {
|
||||
if (value == null) {
|
||||
_map.remove(key);
|
||||
} else {
|
||||
_map[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async => _map.remove(key);
|
||||
}
|
||||
|
||||
/// Plain-text IMAP connect for the local Stalwart dev server (no TLS).
|
||||
Future<enough_mail.ImapClient> _connectImapPlaintext(
|
||||
model.Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client = enough_mail.ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
account.imapPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> _sendMessage({
|
||||
required String host,
|
||||
required int port,
|
||||
required String from,
|
||||
required String pass,
|
||||
required String to,
|
||||
required String subject,
|
||||
}) async {
|
||||
final smtp = mail.SmtpClient('sharedinbox-test');
|
||||
await smtp.connectToServer(host, port, isSecure: false);
|
||||
await smtp.ehlo();
|
||||
await smtp.authenticate(from, pass);
|
||||
final builder = mail.MessageBuilder()
|
||||
..from = [mail.MailAddress('', from)]
|
||||
..to = [mail.MailAddress('', to)]
|
||||
..subject = subject
|
||||
..text = 'Concurrent sync test body — $subject';
|
||||
await smtp.sendMessage(builder.buildMimeMessage());
|
||||
await smtp.quit();
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late int smtpPort;
|
||||
late String jmapUrl;
|
||||
late String aliceUser, alicePass;
|
||||
late String bobUser, bobPass;
|
||||
late AppDatabase db;
|
||||
late AccountRepository accounts;
|
||||
|
||||
setUpAll(() {
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT'));
|
||||
smtpPort = int.parse(_env('STALWART_SMTP_PORT'));
|
||||
jmapUrl = _env('STALWART_URL');
|
||||
aliceUser = _env('STALWART_USER_B');
|
||||
alicePass = _env('STALWART_PASS_B');
|
||||
bobUser = _env('STALWART_USER_C');
|
||||
bobPass = _env('STALWART_PASS_C');
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
accounts = AccountRepositoryImpl(db, _MemSecureStorage());
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'concurrent IMAP + JMAP sync caches all emails without errors',
|
||||
timeout: const Timeout(Duration(seconds: 30)),
|
||||
() async {
|
||||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||
const msgCount = 2;
|
||||
|
||||
// ── 1. Send emails in both directions ─────────────────────────────────────
|
||||
// alice → bob (alice uses IMAP; bob uses JMAP)
|
||||
// bob → alice (cross-direction)
|
||||
for (var i = 0; i < msgCount; i++) {
|
||||
await _sendMessage(
|
||||
host: imapHost,
|
||||
port: smtpPort,
|
||||
from: aliceUser,
|
||||
pass: alicePass,
|
||||
to: bobUser,
|
||||
subject: 'alice-to-bob-$ts-$i',
|
||||
);
|
||||
await _sendMessage(
|
||||
host: imapHost,
|
||||
port: smtpPort,
|
||||
from: bobUser,
|
||||
pass: bobPass,
|
||||
to: aliceUser,
|
||||
subject: 'bob-to-alice-$ts-$i',
|
||||
);
|
||||
}
|
||||
// Give Stalwart a moment to deliver all messages.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// ── 2. Insert accounts ─────────────────────────────────────────────────────
|
||||
final aliceAccount = model.Account(
|
||||
id: 'alice',
|
||||
displayName: 'Alice',
|
||||
email: aliceUser,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: imapHost,
|
||||
smtpPort: smtpPort,
|
||||
);
|
||||
final bobAccount = model.Account(
|
||||
id: 'bob',
|
||||
displayName: 'Bob',
|
||||
email: bobUser,
|
||||
type: model.AccountType.jmap,
|
||||
jmapUrl: '$jmapUrl/.well-known/jmap',
|
||||
smtpHost: imapHost,
|
||||
smtpPort: smtpPort,
|
||||
);
|
||||
|
||||
await accounts.addAccount(aliceAccount, alicePass);
|
||||
await accounts.addAccount(bobAccount, bobPass);
|
||||
|
||||
final httpClient = http.Client();
|
||||
addTearDown(httpClient.close);
|
||||
|
||||
final mailboxRepo = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _connectImapPlaintext,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
final emailRepo = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _connectImapPlaintext,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
|
||||
// ── 3. Sync mailboxes concurrently ─────────────────────────────────────────
|
||||
await Future.wait([
|
||||
mailboxRepo.syncMailboxes('alice'),
|
||||
mailboxRepo.syncMailboxes('bob'),
|
||||
]);
|
||||
|
||||
final allMailboxes = await db.select(db.mailboxes).get();
|
||||
expect(
|
||||
allMailboxes,
|
||||
isNotEmpty,
|
||||
reason: 'mailboxes should be cached after sync',
|
||||
);
|
||||
|
||||
// Grab INBOX paths for each account.
|
||||
// IMAP: path is the mailbox path string (e.g. "INBOX").
|
||||
// JMAP: path is the server-assigned JMAP mailbox ID; match by role or name.
|
||||
final aliceInbox = allMailboxes
|
||||
.firstWhere(
|
||||
(m) => m.accountId == 'alice' && m.path.toUpperCase() == 'INBOX',
|
||||
)
|
||||
.path;
|
||||
final bobInbox = allMailboxes
|
||||
.firstWhere(
|
||||
(m) =>
|
||||
m.accountId == 'bob' &&
|
||||
(m.role == 'inbox' || m.name.toLowerCase() == 'inbox'),
|
||||
)
|
||||
.path;
|
||||
|
||||
// ── 4. Sync emails concurrently — run twice to exercise incremental sync ───
|
||||
for (var round = 0; round < 2; round++) {
|
||||
await Future.wait([
|
||||
emailRepo.syncEmails('alice', aliceInbox),
|
||||
emailRepo.syncEmails('bob', bobInbox),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 5. Verify DB consistency ───────────────────────────────────────────────
|
||||
final allEmails = await db.select(db.emails).get();
|
||||
|
||||
// No duplicate email IDs.
|
||||
final ids = allEmails.map((e) => e.id).toList();
|
||||
expect(
|
||||
ids.toSet().length,
|
||||
equals(ids.length),
|
||||
reason: 'duplicate email IDs in DB',
|
||||
);
|
||||
|
||||
// Alice and bob each received at least msgCount messages.
|
||||
final aliceEmails =
|
||||
allEmails.where((e) => e.accountId == 'alice').toList();
|
||||
final bobEmails = allEmails.where((e) => e.accountId == 'bob').toList();
|
||||
expect(
|
||||
aliceEmails.length,
|
||||
greaterThanOrEqualTo(msgCount),
|
||||
reason: "alice's inbox should contain synced emails",
|
||||
);
|
||||
expect(
|
||||
bobEmails.length,
|
||||
greaterThanOrEqualTo(msgCount),
|
||||
reason: "bob's inbox should contain synced emails",
|
||||
);
|
||||
|
||||
// All rows have a non-empty account ID.
|
||||
for (final e in allEmails) {
|
||||
expect(e.accountId, isNotEmpty);
|
||||
}
|
||||
|
||||
// No pending changes left in the queue.
|
||||
final pending = await db.select(db.pendingChanges).get();
|
||||
expect(pending, isEmpty, reason: 'no outbound mutations expected');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
// Integration tests for EmailRepositoryImpl against a real Stalwart instance.
|
||||
// Run via: stalwart-dev/test.sh
|
||||
//
|
||||
// Environment variables (set by the runner script):
|
||||
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
|
||||
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
|
||||
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import '../unit/db_test_helper.dart';
|
||||
|
||||
String _env(String key, [String fallback = '']) =>
|
||||
Platform.environment[key] ?? fallback;
|
||||
|
||||
Future<ImapClient> _imapConnect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String user,
|
||||
required String pass,
|
||||
}) async {
|
||||
final client = ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(host, port, isSecure: false);
|
||||
await client.login(user, pass);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> _ensureMailbox(ImapClient client, String mailboxPath) async {
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
} catch (_) {
|
||||
await client.createMailbox(mailboxPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes every message in [mailboxPath] so tests start with a clean slate.
|
||||
Future<void> _clearMailbox(
|
||||
ImapClient client, {
|
||||
String mailboxPath = 'INBOX',
|
||||
}) async {
|
||||
final box = await client.selectMailboxByPath(mailboxPath);
|
||||
if (box.messagesExists == 0) return;
|
||||
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isEmpty) return;
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await client.uidMarkDeleted(seq);
|
||||
await client.uidExpunge(seq);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late String smtpHost;
|
||||
late int smtpPort;
|
||||
late String userEmail;
|
||||
late String userPass;
|
||||
late Account account;
|
||||
late Directory cacheDir;
|
||||
|
||||
setUpAll(() {
|
||||
configureSqliteForTests();
|
||||
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
|
||||
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
|
||||
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||
userPass = _env('STALWART_PASS_B', 'secret');
|
||||
account = Account(
|
||||
id: 'test',
|
||||
displayName: 'Alice',
|
||||
email: userEmail,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
);
|
||||
cacheDir = Directory.systemTemp.createTempSync('repo_imap_test_');
|
||||
});
|
||||
|
||||
tearDownAll(() => cacheDir.deleteSync(recursive: true));
|
||||
|
||||
setUp(() async {
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await _clearMailbox(client);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
// Plaintext IMAP/SMTP connect functions for the dev Stalwart (no TLS cert).
|
||||
Future<ImapClient> testImapConnect(
|
||||
Account a,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client = ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<SmtpClient> testSmtpConnect(
|
||||
Account a,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final atIndex = a.email.lastIndexOf('@');
|
||||
final domain = atIndex != -1 ? a.email.substring(atIndex + 1) : a.smtpHost;
|
||||
final client = SmtpClient(domain);
|
||||
await client.connectToServer(a.smtpHost, a.smtpPort, isSecure: false);
|
||||
await client.ehlo();
|
||||
await client.authenticate(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
({AppDatabase db, AccountRepositoryImpl accounts, EmailRepositoryImpl emails})
|
||||
makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final storage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, storage);
|
||||
final emails = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: testImapConnect,
|
||||
smtpConnect: testSmtpConnect,
|
||||
getCacheDir: () async => cacheDir,
|
||||
);
|
||||
return (db: db, accounts: accounts, emails: emails);
|
||||
}
|
||||
|
||||
Future<void> appendToInbox(String subject, {String body = 'Body'}) async {
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final msg = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = subject
|
||||
..text = body;
|
||||
await client.appendMessage(
|
||||
msg.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
test('syncEmails fetches messages from INBOX and stores in DB', () async {
|
||||
final subject = 'sync-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await appendToInbox(subject);
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.subject, subject);
|
||||
expect(emails.first.isSeen, isFalse);
|
||||
});
|
||||
|
||||
test('syncEmails saves IMAP checkpoint after full sync', () async {
|
||||
await appendToInbox('checkpoint-test');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final states = await r.db.select(r.db.syncStates).get();
|
||||
expect(states, hasLength(1));
|
||||
final checkpoint = jsonDecode(states.first.state) as Map<String, dynamic>;
|
||||
expect(checkpoint['uidValidity'], isA<int>());
|
||||
expect((checkpoint['lastUid'] as int), greaterThan(0));
|
||||
});
|
||||
|
||||
test(
|
||||
'syncEmails incremental sync fetches only messages newer than checkpoint',
|
||||
() async {
|
||||
await appendToInbox('first');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final afterFirst = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(afterFirst, hasLength(1));
|
||||
expect(afterFirst.first.subject, 'first');
|
||||
|
||||
await appendToInbox('second');
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final afterSecond = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(afterSecond, hasLength(2));
|
||||
expect(afterSecond.map((e) => e.subject).toSet(), {'first', 'second'});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'CONDSTORE fast-path: second sync skips fetch when nothing changed',
|
||||
() async {
|
||||
await appendToInbox('condstore-test');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
|
||||
// First sync — full sync, saves modseq checkpoint.
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
final stateAfterFirst = await r.db.select(r.db.syncStates).get();
|
||||
expect(stateAfterFirst, hasLength(1));
|
||||
|
||||
// Second sync with no server changes — CONDSTORE fast-path should skip
|
||||
// fetching. DB email count must stay the same.
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('CONDSTORE flag refresh updates flags in local DB', () async {
|
||||
await appendToInbox('flag-refresh-test');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails.first.isSeen, isFalse);
|
||||
|
||||
// Mark seen directly on server, advancing modseq.
|
||||
final imap = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await imap.selectMailboxByPath('INBOX');
|
||||
final seq = MessageSequence.fromIds([emails.first.uid], isUid: true);
|
||||
await imap.uidMarkSeen(seq);
|
||||
} finally {
|
||||
await imap.logout();
|
||||
}
|
||||
|
||||
// Second sync — modseq changed, should refresh flags.
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final refreshed = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(refreshed.first.isSeen, isTrue);
|
||||
});
|
||||
|
||||
test('syncEmails reconciliation removes emails deleted on server', () async {
|
||||
await appendToInbox('keep');
|
||||
await appendToInbox('delete-me');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
expect(await r.emails.observeEmails('test', 'INBOX').first, hasLength(2));
|
||||
|
||||
// Delete second message directly on the server.
|
||||
final imap = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await imap.selectMailboxByPath('INBOX');
|
||||
final search = await imap.uidSearchMessages(
|
||||
searchCriteria: 'SUBJECT "delete-me"',
|
||||
);
|
||||
final uids = search.matchingSequence?.toList() ?? [];
|
||||
expect(uids, hasLength(1));
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await imap.uidMarkDeleted(seq);
|
||||
await imap.uidExpunge(seq);
|
||||
} finally {
|
||||
await imap.logout();
|
||||
}
|
||||
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final remaining = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.first.subject, 'keep');
|
||||
});
|
||||
|
||||
test('getEmailBody fetches body from IMAP and caches it', () async {
|
||||
await appendToInbox('body-test', body: 'Hello from IMAP body');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
final emailId = emails.first.id;
|
||||
|
||||
final body = await r.emails.getEmailBody(emailId);
|
||||
expect(body.textBody, contains('Hello from IMAP body'));
|
||||
|
||||
// Second call returns cached result without another IMAP connection.
|
||||
// We verify by checking the body is identical (no error = no IMAP call).
|
||||
final cached = await r.emails.getEmailBody(emailId);
|
||||
expect(cached.textBody, body.textBody);
|
||||
});
|
||||
|
||||
test(
|
||||
'blob expiry: re-fetches body when cachedAt is null (legacy row)',
|
||||
() async {
|
||||
await appendToInbox('legacy-body-test', body: 'Fresh from server');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a legacy row with no cachedAt.
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('stale text'),
|
||||
cachedAt: const Value(null),
|
||||
),
|
||||
);
|
||||
|
||||
final body = await r.emails.getEmailBody(emailId);
|
||||
expect(body.textBody, contains('Fresh from server'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'blob expiry: re-fetches body when cachedAt is older than 7 days',
|
||||
() async {
|
||||
await appendToInbox('old-body-test', body: 'Current content');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
final emailId = emails.first.id;
|
||||
|
||||
// Simulate a row cached 8 days ago.
|
||||
await r.db.into(r.db.emailBodies).insertOnConflictUpdate(
|
||||
EmailBodiesCompanion.insert(
|
||||
emailId: emailId,
|
||||
textBody: const Value('old text'),
|
||||
cachedAt: Value(DateTime.now().subtract(const Duration(days: 8))),
|
||||
),
|
||||
);
|
||||
|
||||
final body = await r.emails.getEmailBody(emailId);
|
||||
expect(body.textBody, contains('Current content'));
|
||||
},
|
||||
);
|
||||
|
||||
test('sendEmail delivers via SMTP and appends copy to Sent folder', () async {
|
||||
final subject = 'send-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
|
||||
await r.emails.sendEmail(
|
||||
'test',
|
||||
EmailDraft(
|
||||
from: EmailAddress(name: 'Alice', email: userEmail),
|
||||
to: [EmailAddress(name: 'Alice', email: userEmail)],
|
||||
cc: [],
|
||||
subject: subject,
|
||||
body: 'Integration test message',
|
||||
),
|
||||
);
|
||||
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final sent = await client.selectMailboxByPath('Sent');
|
||||
expect(sent.messagesExists, greaterThan(0));
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
test('searchEmails returns messages matching query', () async {
|
||||
final uniqueWord = 'searchable-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await appendToInbox(uniqueWord);
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
|
||||
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, uniqueWord);
|
||||
});
|
||||
|
||||
test('searchEmails returns empty list when no messages match', () async {
|
||||
await appendToInbox('unrelated subject');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
|
||||
final results = await r.emails.searchEmails(
|
||||
'test',
|
||||
'INBOX',
|
||||
'xyzzy-no-match',
|
||||
);
|
||||
expect(results, isEmpty);
|
||||
});
|
||||
|
||||
test('flushPendingChanges applies flag_seen to server', () async {
|
||||
await appendToInbox('flag-test');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
await r.emails.setFlag(emails.first.id, seen: true);
|
||||
await r.emails.flushPendingChanges('test', userPass);
|
||||
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final seen = await client.uidSearchMessages(searchCriteria: 'SEEN');
|
||||
expect(seen.matchingSequence?.toList() ?? [], isNotEmpty);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
test('flushPendingChanges moves email to destination folder', () async {
|
||||
await appendToInbox('move-test');
|
||||
|
||||
final setup = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await _ensureMailbox(setup, 'Trash');
|
||||
} finally {
|
||||
await setup.logout();
|
||||
}
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
await r.emails.moveEmail(emails.first.id, 'Trash');
|
||||
await r.emails.flushPendingChanges('test', userPass);
|
||||
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final inbox = await client.selectMailboxByPath('INBOX');
|
||||
expect(inbox.messagesExists, 0);
|
||||
final trash = await client.selectMailboxByPath('Trash');
|
||||
expect(trash.messagesExists, greaterThan(0));
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
test('flushPendingChanges deletes email from server', () async {
|
||||
await appendToInbox('delete-test');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
await r.emails.deleteEmail(emails.first.id);
|
||||
await r.emails.flushPendingChanges('test', userPass);
|
||||
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final inbox = await client.selectMailboxByPath('INBOX');
|
||||
expect(inbox.messagesExists, 0);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: the in-app "sync" button calls syncEmails directly and does
|
||||
// not invoke flushPendingChanges itself. After a local delete, the message
|
||||
// is removed from the local DB optimistically but still lives on the server
|
||||
// until the pending change is flushed. If syncEmails runs before that flush,
|
||||
// it must not resurrect the deleted row from the server.
|
||||
test('syncEmails after local delete does not resurrect message', () async {
|
||||
await appendToInbox('delete-and-sync');
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
|
||||
// User taps delete in the UI: enqueues a pending change, drops local row.
|
||||
await r.emails.deleteEmail(emails.first.id);
|
||||
expect(await r.emails.observeEmails('test', 'INBOX').first, isEmpty);
|
||||
|
||||
// User taps the sync button: syncEmails runs without flushPendingChanges.
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final after = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(
|
||||
after,
|
||||
isEmpty,
|
||||
reason: 'deleted message must not reappear on next sync',
|
||||
);
|
||||
|
||||
// The pending delete must still be queued for the next flush.
|
||||
final pending = await r.db.select(r.db.pendingChanges).get();
|
||||
expect(pending, hasLength(1));
|
||||
expect(pending.first.changeType, 'delete');
|
||||
});
|
||||
|
||||
test('downloadAttachment fetches binary attachment bytes from IMAP',
|
||||
() async {
|
||||
final attachmentBytes = Uint8List.fromList(
|
||||
List.generate(32, (i) => i + 1),
|
||||
);
|
||||
const attachmentName = 'hello.bin';
|
||||
const attachmentMime = 'application/octet-stream';
|
||||
|
||||
// Build a multipart email with a binary attachment and append it.
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final builder = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'attach-${DateTime.now().millisecondsSinceEpoch}'
|
||||
..text = 'See attachment.';
|
||||
builder.addBinary(
|
||||
attachmentBytes,
|
||||
MediaType.fromText(attachmentMime),
|
||||
filename: attachmentName,
|
||||
);
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final emails = await r.emails.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.hasAttachment, isTrue);
|
||||
|
||||
final body = await r.emails.getEmailBody(emails.first.id);
|
||||
expect(body.attachments, hasLength(1));
|
||||
expect(body.attachments.first.filename, attachmentName);
|
||||
expect(body.attachments.first.contentType, attachmentMime);
|
||||
expect(body.attachments.first.fetchPartId, isNotEmpty);
|
||||
|
||||
final path = await r.emails.downloadAttachment(
|
||||
emails.first.id,
|
||||
body.attachments.first,
|
||||
);
|
||||
final downloaded = await File(path).readAsBytes();
|
||||
expect(downloaded, equals(attachmentBytes));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Integration tests for EmailRepositoryImpl JMAP path against a real Stalwart instance.
|
||||
// Run via: stalwart-dev/test.sh
|
||||
//
|
||||
// Environment variables (set by the runner script):
|
||||
// STALWART_URL — JMAP base URL, e.g. http://127.0.0.1:8080
|
||||
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
|
||||
// STALWART_SMTP_PORT
|
||||
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import '../unit/db_test_helper.dart';
|
||||
|
||||
String _env(String key, [String fallback = '']) =>
|
||||
Platform.environment[key] ?? fallback;
|
||||
|
||||
Future<ImapClient> _imapConnect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String user,
|
||||
required String pass,
|
||||
}) async {
|
||||
final client = ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(host, port, isSecure: false);
|
||||
await client.login(user, pass);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> _clearMailbox(
|
||||
ImapClient client, {
|
||||
String mailboxPath = 'INBOX',
|
||||
}) async {
|
||||
final box = await client.selectMailboxByPath(mailboxPath);
|
||||
if (box.messagesExists == 0) return;
|
||||
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isEmpty) return;
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await client.uidMarkDeleted(seq);
|
||||
await client.uidExpunge(seq);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String stalwartUrl;
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late String smtpPort;
|
||||
late String userEmail;
|
||||
late String userPass;
|
||||
late Account account;
|
||||
late Directory cacheDir;
|
||||
|
||||
setUpAll(() {
|
||||
configureSqliteForTests();
|
||||
stalwartUrl = _env('STALWART_URL', 'http://127.0.0.1:8080');
|
||||
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||
smtpPort = _env('STALWART_SMTP_PORT', '1025');
|
||||
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||
userPass = _env('STALWART_PASS_B', 'secret');
|
||||
account = Account(
|
||||
id: 'test-jmap',
|
||||
displayName: 'Alice',
|
||||
email: userEmail,
|
||||
type: AccountType.jmap,
|
||||
jmapUrl: '$stalwartUrl/.well-known/jmap',
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: imapHost,
|
||||
smtpPort: int.parse(smtpPort),
|
||||
);
|
||||
cacheDir = Directory.systemTemp.createTempSync('repo_jmap_test_');
|
||||
});
|
||||
|
||||
tearDownAll(() => cacheDir.deleteSync(recursive: true));
|
||||
|
||||
setUp(() async {
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await _clearMailbox(client);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
({
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
EmailRepositoryImpl emails,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
}) makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final emails = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
getCacheDir: () async => cacheDir,
|
||||
);
|
||||
final mailboxes = MailboxRepositoryImpl(db, accounts);
|
||||
return (db: db, accounts: accounts, emails: emails, mailboxes: mailboxes);
|
||||
}
|
||||
|
||||
// Syncs mailboxes and returns the JMAP ID for the INBOX mailbox.
|
||||
Future<String> setupAndGetInboxId(
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
) 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();
|
||||
if (row == null) throw StateError('INBOX not found after syncMailboxes');
|
||||
return row.path;
|
||||
}
|
||||
|
||||
Future<void> appendToInbox(String subject, {String body = 'Body'}) async {
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final msg = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = subject
|
||||
..text = body;
|
||||
await client.appendMessage(
|
||||
msg.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
test('syncEmails full sync fetches messages and stores in DB', () async {
|
||||
final subject = 'jmap-full-${DateTime.now().millisecondsSinceEpoch}';
|
||||
await appendToInbox(subject);
|
||||
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
expect(emails, hasLength(1));
|
||||
expect(emails.first.subject, subject);
|
||||
expect(emails.first.isSeen, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'syncEmails saves state and incremental sync picks up new messages',
|
||||
() async {
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
|
||||
await appendToInbox('first');
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
expect(
|
||||
await r.emails.observeEmails('test-jmap', inboxId).first,
|
||||
hasLength(1),
|
||||
);
|
||||
|
||||
await appendToInbox('second');
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
expect(emails, hasLength(2));
|
||||
expect(emails.map((e) => e.subject).toSet(), {'first', 'second'});
|
||||
},
|
||||
);
|
||||
|
||||
test('syncEmails removes email deleted on server from local DB', () async {
|
||||
await appendToInbox('keep');
|
||||
await appendToInbox('delete-me');
|
||||
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
expect(
|
||||
await r.emails.observeEmails('test-jmap', inboxId).first,
|
||||
hasLength(2),
|
||||
);
|
||||
|
||||
// Delete via IMAP directly on the server.
|
||||
final imap = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await imap.selectMailboxByPath('INBOX');
|
||||
final search = await imap.uidSearchMessages(
|
||||
searchCriteria: 'SUBJECT "delete-me"',
|
||||
);
|
||||
final uids = search.matchingSequence?.toList() ?? [];
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await imap.uidMarkDeleted(seq);
|
||||
await imap.uidExpunge(seq);
|
||||
} finally {
|
||||
await imap.logout();
|
||||
}
|
||||
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
final remaining = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
expect(remaining, hasLength(1));
|
||||
expect(remaining.first.subject, 'keep');
|
||||
});
|
||||
|
||||
test('getEmailBody fetches and caches body via JMAP', () async {
|
||||
await appendToInbox('body-test', body: 'Hello from JMAP integration');
|
||||
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
final emailId = emails.first.id;
|
||||
|
||||
final body = await r.emails.getEmailBody(emailId);
|
||||
expect(body.textBody, contains('Hello from JMAP integration'));
|
||||
|
||||
// Second call should hit the cache (no network error = cache used).
|
||||
final cached = await r.emails.getEmailBody(emailId);
|
||||
expect(cached.textBody, body.textBody);
|
||||
});
|
||||
|
||||
test(
|
||||
'sendEmail submits via JMAP EmailSubmission and creates Sent copy',
|
||||
() async {
|
||||
final subject = 'jmap-send-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.mailboxes.syncMailboxes('test-jmap');
|
||||
|
||||
await r.emails.sendEmail(
|
||||
'test-jmap',
|
||||
EmailDraft(
|
||||
from: EmailAddress(name: 'Alice', email: userEmail),
|
||||
to: [EmailAddress(name: 'Alice', email: userEmail)],
|
||||
cc: [],
|
||||
subject: subject,
|
||||
body: 'Integration test message via JMAP',
|
||||
),
|
||||
);
|
||||
|
||||
// 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 sentId = sentRow?.path;
|
||||
|
||||
if (sentId != null) {
|
||||
await r.emails.syncEmails('test-jmap', sentId);
|
||||
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.
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('flushPendingChanges marks email as seen on server', () async {
|
||||
await appendToInbox('flag-test');
|
||||
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
await r.emails.setFlag(emails.first.id, seen: true);
|
||||
await r.emails.flushPendingChanges('test-jmap', userPass);
|
||||
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final seen = await client.uidSearchMessages(searchCriteria: 'SEEN');
|
||||
expect(seen.matchingSequence?.toList() ?? [], isNotEmpty);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
test('flushPendingChanges deletes email from server', () async {
|
||||
await appendToInbox('delete-test');
|
||||
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
|
||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
await r.emails.deleteEmail(emails.first.id);
|
||||
await r.emails.flushPendingChanges('test-jmap', userPass);
|
||||
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
final inbox = await client.selectMailboxByPath('INBOX');
|
||||
expect(inbox.messagesExists, 0);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
});
|
||||
|
||||
test('flushPendingChanges moves email to Trash on server', () async {
|
||||
await appendToInbox('move-test');
|
||||
|
||||
final r = makeRepo();
|
||||
final inboxId = await setupAndGetInboxId(r.db, r.accounts, r.mailboxes);
|
||||
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();
|
||||
if (trashRow == null) {
|
||||
markTestSkipped('No trash mailbox found on this Stalwart instance');
|
||||
return;
|
||||
}
|
||||
final trashId = trashRow.path;
|
||||
|
||||
final emails = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
await r.emails.moveEmail(emails.first.id, trashId);
|
||||
await r.emails.flushPendingChanges('test-jmap', userPass);
|
||||
|
||||
// Re-sync both mailboxes to see updated state.
|
||||
await r.emails.syncEmails('test-jmap', inboxId);
|
||||
final inboxAfter = await r.emails.observeEmails('test-jmap', inboxId).first;
|
||||
expect(inboxAfter, isEmpty);
|
||||
|
||||
await r.emails.syncEmails('test-jmap', trashId);
|
||||
final trashAfter = await r.emails.observeEmails('test-jmap', trashId).first;
|
||||
expect(trashAfter, isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Integration tests — requires a running Stalwart instance.
|
||||
// Run via: stalwart-dev/test.sh (sets the env vars below)
|
||||
//
|
||||
// STALWART_IMAP_HOST, STALWART_IMAP_PORT, STALWART_SMTP_HOST, STALWART_SMTP_PORT
|
||||
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
|
||||
// STALWART_USER_C / STALWART_PASS_C (bob@example.com)
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
String _env(String key) {
|
||||
final v = Platform.environment[key];
|
||||
if (v == null || v.isEmpty) throw StateError('$key is not set');
|
||||
return v;
|
||||
}
|
||||
|
||||
Future<ImapClient> _connect(
|
||||
String user,
|
||||
String pass, {
|
||||
required String host,
|
||||
required int port,
|
||||
}) async {
|
||||
final client = ImapClient();
|
||||
await client.connectToServer(host, port, isSecure: false);
|
||||
await client.login(user, pass);
|
||||
return client;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late int smtpPort;
|
||||
late String userA, passA, userB, passB;
|
||||
|
||||
setUpAll(() {
|
||||
imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT'));
|
||||
smtpPort = int.parse(_env('STALWART_SMTP_PORT'));
|
||||
userA = _env('STALWART_USER_B'); // alice
|
||||
passA = _env('STALWART_PASS_B');
|
||||
userB = _env('STALWART_USER_C'); // bob
|
||||
passB = _env('STALWART_PASS_C');
|
||||
});
|
||||
|
||||
test('login and list mailboxes', () async {
|
||||
final client = await _connect(userA, passA, host: imapHost, port: imapPort);
|
||||
addTearDown(() => client.logout().ignore());
|
||||
|
||||
// listMailboxes() returns List<Mailbox> directly
|
||||
final mailboxes = await client.listMailboxes();
|
||||
expect(mailboxes, isNotEmpty);
|
||||
expect(mailboxes.map((m) => m.name), contains('INBOX'));
|
||||
});
|
||||
|
||||
test('send via SMTP and receive via IMAP', () async {
|
||||
final smtpClient = SmtpClient('test');
|
||||
await smtpClient.connectToServer(imapHost, smtpPort, isSecure: false);
|
||||
await smtpClient.ehlo();
|
||||
await smtpClient.authenticate(userA, passA);
|
||||
|
||||
final builder = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userA)]
|
||||
..to = [MailAddress('Bob', userB)]
|
||||
..subject = 'Integration test ${DateTime.now().millisecondsSinceEpoch}'
|
||||
..text = 'Hello from SharedInbox integration test.';
|
||||
await smtpClient.sendMessage(builder.buildMimeMessage());
|
||||
await smtpClient.quit();
|
||||
|
||||
// Give Stalwart a moment to deliver the message.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final imapClient = await _connect(
|
||||
userB,
|
||||
passB,
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
);
|
||||
addTearDown(() => imapClient.logout().ignore());
|
||||
|
||||
final inbox = await imapClient.selectMailboxByPath('INBOX');
|
||||
expect(inbox.messagesExists, greaterThan(0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Integration tests for MailboxRepositoryImpl against a real Stalwart instance.
|
||||
// Run via: stalwart-dev/test.sh
|
||||
//
|
||||
// Environment variables (set by the runner script):
|
||||
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
|
||||
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import '../unit/db_test_helper.dart';
|
||||
|
||||
String _env(String key, [String fallback = '']) =>
|
||||
Platform.environment[key] ?? fallback;
|
||||
|
||||
Future<ImapClient> _imapConnect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String user,
|
||||
required String pass,
|
||||
}) async {
|
||||
final client = ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(host, port, isSecure: false);
|
||||
await client.login(user, pass);
|
||||
return client;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late String userEmail;
|
||||
late String userPass;
|
||||
late Account account;
|
||||
|
||||
setUpAll(() {
|
||||
configureSqliteForTests();
|
||||
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||
userPass = _env('STALWART_PASS_B', 'secret');
|
||||
account = Account(
|
||||
id: 'test',
|
||||
displayName: 'Alice',
|
||||
email: userEmail,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: imapHost,
|
||||
smtpPort: 1025,
|
||||
);
|
||||
});
|
||||
|
||||
Future<ImapClient> testImapConnect(
|
||||
Account a,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client = ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(a.imapHost, a.imapPort, isSecure: false);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
({
|
||||
AppDatabase db,
|
||||
AccountRepositoryImpl accounts,
|
||||
MailboxRepositoryImpl mailboxes,
|
||||
}) makeRepo() {
|
||||
final db = openTestDatabase();
|
||||
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
|
||||
final mailboxes = MailboxRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: testImapConnect,
|
||||
);
|
||||
return (db: db, accounts: accounts, mailboxes: mailboxes);
|
||||
}
|
||||
|
||||
test('syncMailboxes stores mailboxes from IMAP in DB', () async {
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.mailboxes.syncMailboxes('test');
|
||||
|
||||
final mailboxes = await r.mailboxes.observeMailboxes('test').first;
|
||||
expect(mailboxes, isNotEmpty);
|
||||
expect(mailboxes.map((m) => m.path), contains('INBOX'));
|
||||
});
|
||||
|
||||
test('syncMailboxes populates unread and total counts', () async {
|
||||
// Append a message so INBOX has at least 1 unseen message.
|
||||
final client = await _imapConnect(
|
||||
host: imapHost,
|
||||
port: imapPort,
|
||||
user: userEmail,
|
||||
pass: userPass,
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final msg = MessageBuilder()
|
||||
..from = [MailAddress('Alice', userEmail)]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'count-test'
|
||||
..text = 'body';
|
||||
await client.appendMessage(
|
||||
msg.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.mailboxes.syncMailboxes('test');
|
||||
|
||||
final mailboxes = await r.mailboxes.observeMailboxes('test').first;
|
||||
final inbox = mailboxes.firstWhere((m) => m.path == 'INBOX');
|
||||
expect(inbox.totalCount, greaterThan(0));
|
||||
expect(inbox.unreadCount, greaterThan(0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../scripts/sync_reliability.dart' as reliability;
|
||||
|
||||
void main() {
|
||||
test('sync reliability script runner', timeout: Timeout.none, () async {
|
||||
final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS'];
|
||||
final args = rawArgs == null || rawArgs.isEmpty
|
||||
? const <String>[]
|
||||
: const LineSplitter().convert(rawArgs);
|
||||
await reliability.runSyncReliability(args);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import '../unit/db_test_helper.dart';
|
||||
|
||||
String _env(String key, [String fallback = '']) =>
|
||||
Platform.environment[key] ?? fallback;
|
||||
|
||||
Future<ImapClient> _imapConnectPlain(
|
||||
model.Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client = ImapClient(
|
||||
defaultResponseTimeout: const Duration(seconds: 20),
|
||||
);
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
account.imapPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late String userEmail;
|
||||
late String userPass;
|
||||
late model.Account account;
|
||||
late AppDatabase db;
|
||||
late EmailRepositoryImpl repo;
|
||||
late MapSecureStorage secureStorage;
|
||||
|
||||
setUpAll(() {
|
||||
configureSqliteForTests();
|
||||
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||
userPass = _env('STALWART_PASS_B', 'secret');
|
||||
account = model.Account(
|
||||
id: 'test',
|
||||
displayName: 'Alice',
|
||||
email: userEmail,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 1025,
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
db = openTestDatabase();
|
||||
secureStorage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||
await accounts.addAccount(account, userPass);
|
||||
repo = EmailRepositoryImpl(db, accounts, imapConnect: _imapConnectPlain);
|
||||
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isNotEmpty) {
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await client.uidMarkDeleted(seq);
|
||||
await client.uidExpunge(seq);
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('verifySyncReliability identifies missing local emails', () async {
|
||||
// 1. Inject an email directly into the server via IMAP
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
final builder = MessageBuilder()
|
||||
..from = [const MailAddress('Sender', 'sender@example.com')]
|
||||
..to = [MailAddress('Alice', userEmail)]
|
||||
..subject = 'Ground Truth Test'
|
||||
..text = 'Hello';
|
||||
await client.appendMessage(
|
||||
builder.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
await client.logout();
|
||||
|
||||
// 2. Verify reliability (local DB is empty)
|
||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.missingLocally, hasLength(1));
|
||||
expect(result.missingOnServer, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'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(
|
||||
EmailsCompanion.insert(
|
||||
id: 'test:999',
|
||||
accountId: 'test',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 999,
|
||||
subject: const Value('Ghost'),
|
||||
receivedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Verify reliability
|
||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.missingOnServer, contains('test:999'));
|
||||
expect(result.missingLocally, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('verifySyncReliability identifies flag mismatches', () async {
|
||||
// 1. Sync one email
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
await client.appendMessage(
|
||||
(MessageBuilder()
|
||||
..subject = 'Flag Test'
|
||||
..text = 'Body')
|
||||
.buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
await client.logout();
|
||||
|
||||
await repo.syncEmails('test', 'INBOX');
|
||||
final emails = await repo.observeEmails('test', 'INBOX').first;
|
||||
expect(emails, hasLength(1));
|
||||
final emailId = emails.first.id;
|
||||
expect(emails.first.isSeen, isFalse);
|
||||
|
||||
// 2. Mark as seen on server only
|
||||
final client2 = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client2.selectMailboxByPath('INBOX');
|
||||
await client2.uidMarkSeen(MessageSequence.fromAll());
|
||||
await client2.logout();
|
||||
|
||||
// 3. Verify reliability
|
||||
final result = await repo.verifySyncReliability('test', 'INBOX');
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.flagMismatches, hasLength(1));
|
||||
expect(result.flagMismatches.first.id, emailId);
|
||||
expect(result.flagMismatches.first.serverSeen, isTrue);
|
||||
expect(result.flagMismatches.first.localSeen, isFalse);
|
||||
});
|
||||
|
||||
group('JMAP Reliability', () {
|
||||
late String stalwartUrl;
|
||||
late model.Account jmapAccount;
|
||||
|
||||
setUp(() async {
|
||||
stalwartUrl = _env('STALWART_URL', 'http://127.0.0.1:8080');
|
||||
jmapAccount = model.Account(
|
||||
id: 'test-jmap',
|
||||
displayName: 'Alice JMAP',
|
||||
email: userEmail,
|
||||
type: model.AccountType.jmap,
|
||||
jmapUrl: '$stalwartUrl/.well-known/jmap',
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: imapHost,
|
||||
smtpPort: 1025,
|
||||
);
|
||||
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||
await accounts.addAccount(jmapAccount, userPass);
|
||||
});
|
||||
|
||||
test('identifies missing local emails in JMAP', () async {
|
||||
// 1. Inject via IMAP (Stalwart reflects it in JMAP)
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
await client.selectMailboxByPath('INBOX');
|
||||
await client.appendMessage(
|
||||
(MessageBuilder()..subject = 'JMAP Ground Truth').buildMimeMessage(),
|
||||
targetMailboxPath: 'INBOX',
|
||||
);
|
||||
await client.logout();
|
||||
|
||||
// 2. Need to find the JMAP mailbox ID for INBOX
|
||||
final mailboxRepo = MailboxRepositoryImpl(
|
||||
db,
|
||||
AccountRepositoryImpl(db, secureStorage),
|
||||
);
|
||||
await mailboxRepo.syncMailboxes('test-jmap');
|
||||
final mailboxes = await mailboxRepo.observeMailboxes('test-jmap').first;
|
||||
final inbox = mailboxes.firstWhere((m) => m.role == 'inbox');
|
||||
|
||||
// 3. Verify
|
||||
final result = await repo.verifySyncReliability('test-jmap', inbox.path);
|
||||
expect(result.isHealthy, isFalse);
|
||||
expect(result.missingLocally, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user