ci: rename integration tests to test-backend and migrate to Dagger

This commit is contained in:
GuettliBot2
2026-05-17 08:47:15 +02:00
parent 96c9c74151
commit 90ab0a6905
11 changed files with 20 additions and 9 deletions
+306
View File
@@ -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);
}
+273
View File
@@ -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);
});
}
+85
View File
@@ -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);
});
}
+210
View File
@@ -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));
});
});
}